feat(interaction): 支持游客互动与登录合并
This commit is contained in:
1 parent
2fe24b9db7
commit
4b0fbd73d3
41 files changed
+1207
-90
No files matched your search
+7
-1
@@ -6,6 +6,8 @@ import com.fruitshorts.app.support.ControllerContractSupport;
|
||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||
import com.fruitshorts.content.service.EpisodeService;
|
||||
import com.fruitshorts.interaction.service.CommentService;
|
||||
import com.fruitshorts.framework.interaction.InteractionIdentityResolver;
|
||||
import com.fruitshorts.app.service.AnonymousInteractionRateLimiter;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
@@ -32,7 +34,11 @@ class AppCommentControllerContractTest {
|
||||
commentService = mock(CommentService.class);
|
||||
episodeService = mock(EpisodeService.class);
|
||||
mockMvc = ControllerContractSupport.mockMvcOf(
|
||||
new AppCommentController(commentService, episodeService));
|
||||
new AppCommentController(
|
||||
commentService,
|
||||
episodeService,
|
||||
mock(InteractionIdentityResolver.class),
|
||||
mock(AnonymousInteractionRateLimiter.class)));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+148
-2
@@ -3,9 +3,17 @@
|
||||
package com.fruitshorts.app.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fruitshorts.app.service.AppEpisodeStatsService;
|
||||
import com.fruitshorts.app.service.AppCatalogService;
|
||||
import com.fruitshorts.app.vo.AppEpisodeStatsVO;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.pojo.CursorResult;
|
||||
import com.fruitshorts.interaction.service.LikeService;
|
||||
import com.fruitshorts.playback.service.FavoriteService;
|
||||
import com.fruitshorts.playback.service.PlaybackService;
|
||||
import com.fruitshorts.content.service.EpisodeService;
|
||||
import com.fruitshorts.framework.interaction.InteractionIdentityResolver;
|
||||
import com.fruitshorts.app.service.AnonymousInteractionRateLimiter;
|
||||
import com.fruitshorts.framework.log.AccessDeniedRecorder;
|
||||
import com.fruitshorts.framework.security.JwtAuthenticationFilter;
|
||||
import com.fruitshorts.framework.security.JwtTokenProvider;
|
||||
@@ -36,6 +44,7 @@ import java.util.List;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@@ -52,12 +61,23 @@ class AppCommentCountAnonymousSecurityTest {
|
||||
private CommentService commentService;
|
||||
@Autowired
|
||||
private AppCatalogService catalogService;
|
||||
@Autowired
|
||||
private AppEpisodeStatsService episodeStatsService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
given(commentService.countByDrama(7L)).willReturn(9L);
|
||||
given(catalogService.feed("ANIME", null, null, 20))
|
||||
.willReturn(new CursorResult<>(List.of(), null, false));
|
||||
AppEpisodeStatsVO stats = new AppEpisodeStatsVO();
|
||||
stats.setEpisodeId(11L);
|
||||
stats.setDramaId(7L);
|
||||
stats.setLikeCount(12L);
|
||||
stats.setCommentCount(3L);
|
||||
stats.setFavoriteCount(8L);
|
||||
stats.setLiked(true);
|
||||
stats.setFavorited(false);
|
||||
given(episodeStatsService.get(11L, null, "device-a")).willReturn(stats);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -78,6 +98,52 @@ class AppCommentCountAnonymousSecurityTest {
|
||||
.andExpect(jsonPath("$.data.list").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_allow_anonymous_non_paid_interactions_through_security_chain() throws Exception {
|
||||
mockMvc.perform(post("/app/likes/toggle")
|
||||
.header("X-Device-Id", "device-a")
|
||||
.contentType("application/json")
|
||||
.content("{\"targetType\":\"EPISODE\",\"targetId\":\"11\"}"))
|
||||
.andExpect(status().isOk());
|
||||
mockMvc.perform(post("/app/favorites/toggle")
|
||||
.header("X-Device-Id", "device-a")
|
||||
.contentType("application/json")
|
||||
.content("{\"dramaId\":\"7\"}"))
|
||||
.andExpect(status().isOk());
|
||||
mockMvc.perform(post("/app/comments")
|
||||
.header("X-Device-Id", "device-a")
|
||||
.contentType("application/json")
|
||||
.content("{\"dramaId\":\"7\",\"content\":\"hello\"}"))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_allow_anonymous_episode_stats_through_security_chain() throws Exception {
|
||||
mockMvc.perform(get("/app/episodes/11/stats")
|
||||
.header("X-Device-Id", "device-a"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.episodeId").value(11))
|
||||
.andExpect(jsonPath("$.data.liked").value(true))
|
||||
.andExpect(jsonPath("$.data.favoriteCount").value(8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_keep_paid_and_entitlement_operations_login_required() throws Exception {
|
||||
mockMvc.perform(post("/app/episodes/11/unlock"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(ErrorCode.UNAUTHORIZED.getCode()));
|
||||
mockMvc.perform(get("/app/wallet"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(ErrorCode.UNAUTHORIZED.getCode()));
|
||||
mockMvc.perform(post("/app/orders"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(ErrorCode.UNAUTHORIZED.getCode()));
|
||||
mockMvc.perform(get("/app/orders"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(ErrorCode.UNAUTHORIZED.getCode()));
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebMvc
|
||||
@EnableWebSecurity
|
||||
@@ -90,8 +156,87 @@ class AppCommentCountAnonymousSecurityTest {
|
||||
}
|
||||
|
||||
@Bean
|
||||
AppCommentController appCommentController(CommentService commentService) {
|
||||
return new AppCommentController(commentService, mock(EpisodeService.class));
|
||||
EpisodeService episodeService() {
|
||||
return mock(EpisodeService.class);
|
||||
}
|
||||
|
||||
@Bean
|
||||
AppCommentController appCommentController(
|
||||
CommentService commentService,
|
||||
EpisodeService episodeService,
|
||||
InteractionIdentityResolver identityResolver,
|
||||
AnonymousInteractionRateLimiter rateLimiter) {
|
||||
return new AppCommentController(
|
||||
commentService,
|
||||
episodeService,
|
||||
identityResolver,
|
||||
rateLimiter);
|
||||
}
|
||||
|
||||
@Bean
|
||||
LikeService likeService() {
|
||||
return mock(LikeService.class);
|
||||
}
|
||||
|
||||
@Bean
|
||||
FavoriteService favoriteService() {
|
||||
return mock(FavoriteService.class);
|
||||
}
|
||||
|
||||
@Bean
|
||||
AppInteractionController appInteractionController(
|
||||
LikeService likeService,
|
||||
InteractionIdentityResolver identityResolver,
|
||||
AnonymousInteractionRateLimiter rateLimiter) {
|
||||
return new AppInteractionController(
|
||||
likeService,
|
||||
mock(com.fruitshorts.interaction.service.ReportService.class),
|
||||
identityResolver,
|
||||
rateLimiter);
|
||||
}
|
||||
|
||||
@Bean
|
||||
AppFavoriteController appFavoriteController(
|
||||
FavoriteService favoriteService,
|
||||
InteractionIdentityResolver identityResolver,
|
||||
AnonymousInteractionRateLimiter rateLimiter) {
|
||||
return new AppFavoriteController(
|
||||
favoriteService,
|
||||
mock(AppCatalogService.class),
|
||||
identityResolver,
|
||||
rateLimiter);
|
||||
}
|
||||
|
||||
@Bean
|
||||
AppEpisodeStatsService appEpisodeStatsService() {
|
||||
return mock(AppEpisodeStatsService.class);
|
||||
}
|
||||
|
||||
@Bean
|
||||
AppDramaController appDramaController(
|
||||
AppCatalogService appCatalogService,
|
||||
EpisodeService episodeService,
|
||||
CommentService commentService,
|
||||
AppEpisodeStatsService appEpisodeStatsService) {
|
||||
return new AppDramaController(
|
||||
appCatalogService,
|
||||
mock(PlaybackService.class),
|
||||
episodeService,
|
||||
commentService,
|
||||
appEpisodeStatsService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
InteractionIdentityResolver identityResolver() {
|
||||
return new InteractionIdentityResolver();
|
||||
}
|
||||
|
||||
@Bean
|
||||
AnonymousInteractionRateLimiter anonymousInteractionRateLimiter() {
|
||||
AnonymousInteractionRateLimiter limiter =
|
||||
mock(AnonymousInteractionRateLimiter.class);
|
||||
Mockito.doNothing().when(limiter).check(Mockito.any(), Mockito.anyString());
|
||||
return limiter;
|
||||
}
|
||||
|
||||
@Bean
|
||||
@@ -132,4 +277,5 @@ class AppCommentCountAnonymousSecurityTest {
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+7
-1
@@ -8,6 +8,8 @@ import com.fruitshorts.app.vo.AppDramaCardVO;
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.content.dal.entity.Drama;
|
||||
import com.fruitshorts.framework.security.LoginUser;
|
||||
import com.fruitshorts.framework.interaction.InteractionIdentityResolver;
|
||||
import com.fruitshorts.app.service.AnonymousInteractionRateLimiter;
|
||||
import com.fruitshorts.playback.service.FavoriteService;
|
||||
import com.fruitshorts.playback.service.WatchProgressService;
|
||||
import com.fruitshorts.playback.vo.ProgressRespVO;
|
||||
@@ -45,7 +47,11 @@ class AppLibraryPagingContractTest {
|
||||
catalogService = mock(AppCatalogService.class);
|
||||
progressService = mock(WatchProgressService.class);
|
||||
favoriteMvc = ControllerContractSupport.mockMvcOf(
|
||||
new AppFavoriteController(favoriteService, catalogService));
|
||||
new AppFavoriteController(
|
||||
favoriteService,
|
||||
catalogService,
|
||||
mock(InteractionIdentityResolver.class),
|
||||
mock(AnonymousInteractionRateLimiter.class)));
|
||||
watchMvc = ControllerContractSupport.mockMvcOf(
|
||||
new AppWatchController(progressService));
|
||||
SecurityContextHolder.getContext().setAuthentication(
|
||||
|
||||
+2
-2
@@ -116,7 +116,7 @@ class AppPlaybackContractTest {
|
||||
stats.setFavoriteCount(1800L);
|
||||
stats.setLiked(true);
|
||||
stats.setFavorited(false);
|
||||
given(episodeStatsService.get(11L, null)).willReturn(stats);
|
||||
given(episodeStatsService.get(11L, null, null)).willReturn(stats);
|
||||
|
||||
mockMvc.perform(get("/app/episodes/11/stats"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -128,7 +128,7 @@ class AppPlaybackContractTest {
|
||||
.andExpect(jsonPath("$.data.liked").value(true))
|
||||
.andExpect(jsonPath("$.data.favorited").value(false));
|
||||
|
||||
verify(episodeStatsService).get(11L, null);
|
||||
verify(episodeStatsService).get(11L, null, null);
|
||||
verify(playbackService, org.mockito.Mockito.never()).play(any(), any(), any(), any());
|
||||
}
|
||||
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.app.service;
|
||||
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.common.interaction.InteractionIdentity;
|
||||
import com.fruitshorts.framework.redis.RedisService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
class AnonymousInteractionRateLimiterTest {
|
||||
|
||||
@Test
|
||||
void should_reject_anonymous_device_after_per_minute_limit() {
|
||||
RedisService redisService = mock(RedisService.class);
|
||||
given(redisService.increment(anyString(), any())).willReturn(31L);
|
||||
AnonymousInteractionRateLimiter limiter =
|
||||
new AnonymousInteractionRateLimiter(redisService);
|
||||
ReflectionTestUtils.setField(limiter, "writesPerMinute", 30);
|
||||
|
||||
assertThatThrownBy(() -> limiter.check(
|
||||
new InteractionIdentity(null, "device-a"),
|
||||
"like"))
|
||||
.isInstanceOfSatisfying(ServiceException.class, error ->
|
||||
org.assertj.core.api.Assertions.assertThat(error.getCode())
|
||||
.isEqualTo(ErrorCode.TOO_MANY_REQUESTS.getCode()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_not_rate_limit_authenticated_user() {
|
||||
RedisService redisService = mock(RedisService.class);
|
||||
AnonymousInteractionRateLimiter limiter =
|
||||
new AnonymousInteractionRateLimiter(redisService);
|
||||
|
||||
limiter.check(new InteractionIdentity(9L, ""), "comment");
|
||||
|
||||
verify(redisService, never()).increment(anyString(), any());
|
||||
}
|
||||
}
|
||||
+25
-4
@@ -16,6 +16,7 @@ import com.fruitshorts.user.dal.mapper.AppUserMapper;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserOtpMapper;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserSessionMapper;
|
||||
import com.fruitshorts.user.enums.AppUserStatus;
|
||||
import com.fruitshorts.user.event.AppInteractionMergeRequested;
|
||||
import com.fruitshorts.user.service.AppAuthService;
|
||||
import com.fruitshorts.user.service.AppOtpService;
|
||||
import com.fruitshorts.user.service.CredentialHasher;
|
||||
@@ -27,7 +28,9 @@ import com.fruitshorts.user.vo.RegisterReqVO;
|
||||
import com.fruitshorts.user.vo.TokenRespVO;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.Duration;
|
||||
@@ -63,6 +66,7 @@ class AppAuthServiceTest {
|
||||
private PasswordEncoder passwordEncoder;
|
||||
private ConfigService configService;
|
||||
private GoogleIdTokenVerifier googleVerifier;
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
private AppAuthService authService;
|
||||
|
||||
@BeforeEach
|
||||
@@ -78,11 +82,12 @@ class AppAuthServiceTest {
|
||||
passwordEncoder = mock(PasswordEncoder.class);
|
||||
configService = mock(ConfigService.class);
|
||||
googleVerifier = mock(GoogleIdTokenVerifier.class);
|
||||
eventPublisher = mock(ApplicationEventPublisher.class);
|
||||
given(configService.get(anyString(), anyString()))
|
||||
.willAnswer(invocation -> invocation.getArgument(1));
|
||||
authService = new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper,
|
||||
deviceMapper, otpService, hasher, tokenProvider, passwordEncoder, configService,
|
||||
googleVerifier);
|
||||
googleVerifier, eventPublisher);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -98,6 +103,9 @@ class AppAuthServiceTest {
|
||||
// 免码注册不等于"已验证":邮箱可达性没验证过,所以先记 0,等日后点验证链接再置 1
|
||||
assertThat(saved.get().getVerified()).isZero();
|
||||
verify(otpService, never()).consume(anyString(), anyString(), anyString(), any());
|
||||
AppInteractionMergeRequested event = capturedMergeEvent();
|
||||
assertThat(event.userId()).isEqualTo(1001L);
|
||||
assertThat(event.deviceId()).isEqualTo("device-9");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -251,6 +259,9 @@ class AppAuthServiceTest {
|
||||
assertThat(resp.getAccessToken()).isEqualTo("access-1");
|
||||
assertThat(resp.getUser().getUid()).isEqualTo("UAB12CD");
|
||||
verify(sessionMapper).insert(any(AppUserSession.class));
|
||||
AppInteractionMergeRequested event = capturedMergeEvent();
|
||||
assertThat(event.userId()).isEqualTo(1001L);
|
||||
assertThat(event.deviceId()).isEqualTo("device-9");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -307,11 +318,14 @@ class AppAuthServiceTest {
|
||||
});
|
||||
given(userMapper.selectById(any())).willAnswer(invocation -> inserted.get());
|
||||
|
||||
TokenRespVO resp = authService.googleLogin("google-id-token");
|
||||
TokenRespVO resp = authService.googleLogin("google-id-token", "device-9");
|
||||
|
||||
assertThat(resp.getAccessToken()).isEqualTo("access-1");
|
||||
assertThat(resp.isNewUser()).isTrue();
|
||||
verify(sessionMapper).insert(any(AppUserSession.class));
|
||||
AppInteractionMergeRequested event = capturedMergeEvent();
|
||||
assertThat(event.userId()).isEqualTo(1001L);
|
||||
assertThat(event.deviceId()).isEqualTo("device-9");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -319,7 +333,7 @@ class AppAuthServiceTest {
|
||||
given(googleVerifier.verify(anyString()))
|
||||
.willReturn(new GoogleUser("sub-1", "", "Alice", ""));
|
||||
|
||||
assertThatThrownBy(() -> authService.googleLogin("google-id-token"))
|
||||
assertThatThrownBy(() -> authService.googleLogin("google-id-token", "device-9"))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.hasMessage("Google 账号未返回邮箱,无法登录");
|
||||
}
|
||||
@@ -353,6 +367,13 @@ class AppAuthServiceTest {
|
||||
return saved;
|
||||
}
|
||||
|
||||
private AppInteractionMergeRequested capturedMergeEvent() {
|
||||
ArgumentCaptor<AppInteractionMergeRequested> event =
|
||||
ArgumentCaptor.forClass(AppInteractionMergeRequested.class);
|
||||
verify(eventPublisher).publishEvent(event.capture());
|
||||
return event.getValue();
|
||||
}
|
||||
|
||||
private void stubLogin(AppUserCredential credential, AppUser user) {
|
||||
given(credentialMapper.selectOne(any())).willReturn(credential);
|
||||
given(userMapper.selectById(any())).willReturn(user);
|
||||
@@ -371,7 +392,7 @@ class AppAuthServiceTest {
|
||||
AppOtpService realOtpService = new AppOtpService(otpMapper, mock(OtpSender.class), otpHasher);
|
||||
return new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper,
|
||||
deviceMapper, realOtpService, hasher, tokenProvider, passwordEncoder, configService,
|
||||
googleVerifier);
|
||||
googleVerifier, eventPublisher);
|
||||
}
|
||||
|
||||
private void setRegisterLimits(int perIp, int perDevice) {
|
||||
|
||||
+7
-5
@@ -42,12 +42,14 @@ class AppEpisodeStatsServiceTest {
|
||||
given(dramaService.listPublishedByIds(List.of(7L))).willReturn(List.of(drama));
|
||||
given(commentService.countByEpisodes(List.of(11L))).willReturn(Map.of(11L, 8L));
|
||||
given(likeService.likedTargetIds(
|
||||
100L, LikeService.TYPE_EPISODE, List.of(11L))).willReturn(Set.of(11L));
|
||||
given(favoriteService.favoriteDramaIds(100L, List.of(7L))).willReturn(Set.of());
|
||||
100L, "device-1", LikeService.TYPE_EPISODE, List.of(11L)))
|
||||
.willReturn(Set.of(11L));
|
||||
given(favoriteService.favoriteDramaIds(100L, "device-1", List.of(7L)))
|
||||
.willReturn(Set.of());
|
||||
|
||||
AppEpisodeStatsService service = new AppEpisodeStatsService(
|
||||
episodeService, dramaService, commentService, likeService, favoriteService);
|
||||
AppEpisodeStatsVO result = service.get(11L, 100L);
|
||||
AppEpisodeStatsVO result = service.get(11L, 100L, "device-1");
|
||||
|
||||
assertThat(result.getEpisodeId()).isEqualTo(11L);
|
||||
assertThat(result.getDramaId()).isEqualTo(7L);
|
||||
@@ -59,7 +61,7 @@ class AppEpisodeStatsServiceTest {
|
||||
verify(episodeService).listPublishedByIds(List.of(11L));
|
||||
verify(commentService).countByEpisodes(List.of(11L));
|
||||
verify(likeService).likedTargetIds(
|
||||
100L, LikeService.TYPE_EPISODE, List.of(11L));
|
||||
verify(favoriteService).favoriteDramaIds(100L, List.of(7L));
|
||||
100L, "device-1", LikeService.TYPE_EPISODE, List.of(11L));
|
||||
verify(favoriteService).favoriteDramaIds(100L, "device-1", List.of(7L));
|
||||
}
|
||||
}
|
||||
+27
@@ -1,6 +1,8 @@
|
||||
// Author: fruit.com.cn
|
||||
package com.fruitshorts.app.service;
|
||||
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.content.dal.entity.Drama;
|
||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||
import com.fruitshorts.content.service.DramaService;
|
||||
@@ -20,6 +22,7 @@ import org.junit.jupiter.api.Test;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
@@ -112,6 +115,30 @@ class PlaybackServiceTest {
|
||||
verify(ticketMapper, never()).insert(any(PlaybackTicket.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_locked_episode_ticket_for_anonymous_device() {
|
||||
given(episodeService.getPublished(12L)).willReturn(episode(12L, 0, 60, "/files/episode/2.mp4"));
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
playbackService.play(null, 12L, "device-9", "203.0.113.8"))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.extracting(error -> ((ServiceException) error).getCode())
|
||||
.isEqualTo(ErrorCode.UNAUTHORIZED.getCode());
|
||||
verify(signer, never()).sign(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_locked_episode_ticket_before_no_media_for_anonymous_device() {
|
||||
given(episodeService.getPublished(17L)).willReturn(episode(17L, 0, 60, ""));
|
||||
|
||||
assertThatThrownBy(() ->
|
||||
playbackService.play(null, 17L, "device-9", "203.0.113.8"))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.extracting(error -> ((ServiceException) error).getCode())
|
||||
.isEqualTo(ErrorCode.UNAUTHORIZED.getCode());
|
||||
verify(signer, never()).sign(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_return_entitled_access_when_entitlement_port_confirms_unlock() {
|
||||
given(episodeService.getPublished(13L)).willReturn(episode(13L, 0, 60, "/files/episode/3.mp4"));
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.interaction.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.fruitshorts.common.interaction.InteractionIdentity;
|
||||
import com.fruitshorts.content.service.DramaService;
|
||||
import com.fruitshorts.content.service.EpisodeService;
|
||||
import com.fruitshorts.interaction.config.InteractionProperties;
|
||||
import com.fruitshorts.interaction.dal.entity.ContentComment;
|
||||
import com.fruitshorts.interaction.dal.mapper.ContentAuditRecordMapper;
|
||||
import com.fruitshorts.interaction.dal.mapper.ContentCommentMapper;
|
||||
import com.fruitshorts.interaction.dal.mapper.ContentLikeMapper;
|
||||
import com.fruitshorts.interaction.vo.CommentPublishReqVO;
|
||||
import com.fruitshorts.interaction.vo.CommentRespVO;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserMapper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
class AnonymousCommentServiceTest {
|
||||
|
||||
private ContentCommentMapper commentMapper;
|
||||
private SensitiveWordService sensitiveWordService;
|
||||
private CommentService commentService;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||
ContentComment.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
commentMapper = mock(ContentCommentMapper.class);
|
||||
sensitiveWordService = mock(SensitiveWordService.class);
|
||||
AuditRecorder auditRecorder = mock(AuditRecorder.class);
|
||||
commentService = new CommentService(
|
||||
commentMapper,
|
||||
mock(ContentLikeMapper.class),
|
||||
mock(ContentAuditRecordMapper.class),
|
||||
mock(AppUserMapper.class),
|
||||
mock(DramaService.class),
|
||||
mock(EpisodeService.class),
|
||||
sensitiveWordService,
|
||||
auditRecorder,
|
||||
new InteractionProperties());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_publish_anonymous_comment_with_guest_name_and_device_owner() {
|
||||
given(commentMapper.selectCount(any())).willReturn(0L);
|
||||
given(commentMapper.insert(any(ContentComment.class))).willReturn(1);
|
||||
given(sensitiveWordService.check("hello")).willReturn(
|
||||
SensitiveWordCheck.pass("hello"));
|
||||
|
||||
CommentRespVO result = commentService.publish(
|
||||
new InteractionIdentity(null, "device-a"),
|
||||
request("hello"),
|
||||
"127.0.0.1");
|
||||
|
||||
ArgumentCaptor<ContentComment> captor =
|
||||
ArgumentCaptor.forClass(ContentComment.class);
|
||||
verify(commentMapper).insert(captor.capture());
|
||||
assertThat(captor.getValue().getUserId()).isNull();
|
||||
assertThat(captor.getValue().getDeviceId()).isEqualTo("device-a");
|
||||
assertThat(result.getNickname()).isEqualTo("游客");
|
||||
assertThat(result.getMine()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_duplicate_anonymous_content_within_one_minute() {
|
||||
given(commentMapper.selectCount(any())).willReturn(0L, 1L);
|
||||
given(sensitiveWordService.check("hello")).willReturn(
|
||||
SensitiveWordCheck.pass("hello"));
|
||||
|
||||
assertThatThrownBy(() -> commentService.publish(
|
||||
new InteractionIdentity(null, "device-a"),
|
||||
request("hello"),
|
||||
"127.0.0.1"))
|
||||
.hasMessageContaining("重复");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_merge_device_comments_and_remove_duplicate() {
|
||||
ContentComment duplicate = comment(1L, "same");
|
||||
ContentComment unique = comment(2L, "new");
|
||||
given(commentMapper.selectList(any())).willReturn(List.of(duplicate, unique));
|
||||
given(commentMapper.selectOne(any())).willReturn(comment(3L, "same"), null);
|
||||
given(commentMapper.physicalDeleteById(1L)).willReturn(1);
|
||||
|
||||
CommentService.MergeResult result =
|
||||
commentService.mergeDeviceIntoUser(9L, "device-a");
|
||||
|
||||
assertThat(result.migrated()).isEqualTo(1);
|
||||
assertThat(result.duplicates()).isEqualTo(1);
|
||||
verify(commentMapper).physicalDeleteById(1L);
|
||||
verify(commentMapper).updateById(unique);
|
||||
assertThat(unique.getUserId()).isEqualTo(9L);
|
||||
}
|
||||
|
||||
private CommentPublishReqVO request(String content) {
|
||||
CommentPublishReqVO req = new CommentPublishReqVO();
|
||||
req.setDramaId(7L);
|
||||
req.setContent(content);
|
||||
return req;
|
||||
}
|
||||
|
||||
private ContentComment comment(Long id, String content) {
|
||||
ContentComment comment = new ContentComment();
|
||||
comment.setId(id);
|
||||
comment.setDramaId(7L);
|
||||
comment.setEpisodeId(0L);
|
||||
comment.setParentId(0L);
|
||||
comment.setContent(content);
|
||||
comment.setStatus(CommentService.STATUS_PUBLISHED);
|
||||
comment.setDeviceId("device-a");
|
||||
return comment;
|
||||
}
|
||||
}
|
||||
+50
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.common.interaction.InteractionIdentity;
|
||||
import com.fruitshorts.content.dal.entity.Drama;
|
||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||
import com.fruitshorts.content.service.DramaService;
|
||||
@@ -19,6 +20,9 @@ import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
@@ -95,6 +99,44 @@ class LikeServiceEpisodeTest {
|
||||
verify(likeMapper, never()).insert(any(ContentLike.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_store_anonymous_episode_like_by_device() {
|
||||
DramaEpisode published = episode(1L);
|
||||
given(episodeService.getPublished(11L)).willReturn(published);
|
||||
given(dramaService.getPublished(5L)).willReturn(new Drama());
|
||||
given(likeMapper.selectOne(any())).willReturn(null);
|
||||
given(episodeService.getExisting(11L)).willReturn(published);
|
||||
|
||||
likeService.toggle(new InteractionIdentity(null, "device-a"), request(11L));
|
||||
|
||||
ArgumentCaptor<ContentLike> captor = ArgumentCaptor.forClass(ContentLike.class);
|
||||
verify(likeMapper).insert(captor.capture());
|
||||
assertThat(captor.getValue().getUserId()).isNull();
|
||||
assertThat(captor.getValue().getDeviceId()).isEqualTo("device-a");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_merge_device_likes_and_decrement_duplicate_count_once() {
|
||||
DramaEpisode published = episode(2L);
|
||||
ContentLike duplicate = like(1L, LikeService.TYPE_EPISODE, 11L);
|
||||
ContentLike unique = like(2L, LikeService.TYPE_EPISODE, 12L);
|
||||
given(likeMapper.selectList(any())).willReturn(List.of(duplicate, unique));
|
||||
given(likeMapper.selectOne(any())).willReturn(
|
||||
like(3L, LikeService.TYPE_EPISODE, 11L),
|
||||
null);
|
||||
given(likeMapper.physicalDeleteById(1L)).willReturn(1);
|
||||
given(episodeService.getExisting(11L)).willReturn(published);
|
||||
given(episodeService.getExisting(12L)).willReturn(published);
|
||||
|
||||
LikeService.MergeResult result = likeService.mergeDeviceIntoUser(9L, "device-a");
|
||||
|
||||
assertThat(result.migrated()).isEqualTo(1);
|
||||
assertThat(result.duplicates()).isEqualTo(1);
|
||||
verify(episodeService).adjustLikeCount(11L, -1);
|
||||
verify(likeMapper).updateById(unique);
|
||||
assertThat(unique.getUserId()).isEqualTo(9L);
|
||||
}
|
||||
|
||||
private DramaEpisode episode(Long likeCount) {
|
||||
DramaEpisode episode = new DramaEpisode();
|
||||
episode.setId(11L);
|
||||
@@ -109,4 +151,12 @@ class LikeServiceEpisodeTest {
|
||||
req.setTargetId(episodeId);
|
||||
return req;
|
||||
}
|
||||
|
||||
private ContentLike like(Long id, String type, Long targetId) {
|
||||
ContentLike like = new ContentLike();
|
||||
like.setId(id);
|
||||
like.setTargetType(type);
|
||||
like.setTargetId(targetId);
|
||||
return like;
|
||||
}
|
||||
}
|
||||
+33
@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.common.interaction.InteractionIdentity;
|
||||
import com.fruitshorts.content.dal.entity.Drama;
|
||||
import com.fruitshorts.content.service.DramaService;
|
||||
import com.fruitshorts.playback.dal.entity.UserFavorite;
|
||||
@@ -58,6 +59,38 @@ class FavoriteServiceTest {
|
||||
verify(dramaService).adjustFavoriteCount(7L, 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_store_anonymous_favorite_by_device() {
|
||||
given(favoriteMapper.selectOne(any())).willReturn(null);
|
||||
given(favoriteMapper.insert(any(UserFavorite.class))).willReturn(1);
|
||||
|
||||
favoriteService.toggle(new InteractionIdentity(null, "device-a"), 7L);
|
||||
|
||||
ArgumentCaptor<UserFavorite> captor = ArgumentCaptor.forClass(UserFavorite.class);
|
||||
verify(favoriteMapper).insert(captor.capture());
|
||||
assertThat(captor.getValue().getUserId()).isNull();
|
||||
assertThat(captor.getValue().getDeviceId()).isEqualTo("device-a");
|
||||
verify(dramaService).adjustFavoriteCount(7L, 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_merge_device_favorites_and_decrement_duplicate_count_once() {
|
||||
UserFavorite duplicate = favorite(7L);
|
||||
UserFavorite unique = favorite(8L);
|
||||
given(favoriteMapper.selectList(any())).willReturn(List.of(duplicate, unique));
|
||||
given(favoriteMapper.selectOne(any())).willReturn(favorite(9L), null);
|
||||
given(favoriteMapper.physicalDeleteById(duplicate.getId())).willReturn(1);
|
||||
|
||||
FavoriteService.MergeResult result =
|
||||
favoriteService.mergeDeviceIntoUser(1L, "device-a");
|
||||
|
||||
assertThat(result.migrated()).isEqualTo(1);
|
||||
assertThat(result.duplicates()).isEqualTo(1);
|
||||
verify(dramaService).adjustFavoriteCount(7L, -1);
|
||||
verify(favoriteMapper).updateById(unique);
|
||||
assertThat(unique.getUserId()).isEqualTo(1L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_decrement_favorite_count_when_toggle_removes_relation() {
|
||||
given(favoriteMapper.selectOne(any())).willReturn(new UserFavorite());
|
||||
|
||||
Reference in new issue
Block a user