diff --git a/fruitshorts-app-api/pom.xml b/fruitshorts-app-api/pom.xml index 16659f5..8e2a05c 100644 --- a/fruitshorts-app-api/pom.xml +++ b/fruitshorts-app-api/pom.xml @@ -39,6 +39,12 @@ com.fruitshorts fruitshorts-module-commerce + + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppAuthControllerContractTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppAuthControllerContractTest.java new file mode 100644 index 0000000..6db1a7a --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppAuthControllerContractTest.java @@ -0,0 +1,140 @@ +// Author: fruit.com.cn +package com.fruitshorts.app.controller; + +import com.fruitshorts.app.support.ControllerContractSupport; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.user.service.AppAuthService; +import com.fruitshorts.user.service.AppOtpService; +import com.fruitshorts.user.vo.TokenRespVO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +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; + +/** + * `/app/auth/**` 的接入层契约:路由、请求体绑定、统一响应包 `{code,message,data}`、错误码映射。 + * + *

业务规则(免码、密码强度、频控)在 {@code AppAuthServiceTest} / {@code AppOtpServiceTest} 里用真服务验证, + * 这里只钉"接口长什么样"。 + */ +class AppAuthControllerContractTest { + + private AppOtpService otpService; + private AppAuthService authService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + otpService = mock(AppOtpService.class); + authService = mock(AppAuthService.class); + mockMvc = ControllerContractSupport.mockMvcOf(new AppAuthController(otpService, authService)); + } + + @Test + void should_return_access_token_when_email_register_succeeds() throws Exception { + given(authService.register(any(), any())).willReturn(tokens("access-1", "refresh-1")); + + mockMvc.perform(post("/app/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetType\":\"EMAIL\",\"target\":\"a@b.com\",\"password\":\"abcd1234\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.message").value("ok")) + .andExpect(jsonPath("$.data.accessToken").value("access-1")) + .andExpect(jsonPath("$.data.tokenType").value("Bearer")) + .andExpect(jsonPath("$.data.newUser").value(true)); + } + + @Test + void should_return_data_exists_code_when_email_is_already_registered() throws Exception { + given(authService.register(any(), any())).willThrow( + new ServiceException(ErrorCode.DATA_EXISTS, "该账号已注册,请直接登录")); + + mockMvc.perform(post("/app/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetType\":\"EMAIL\",\"target\":\"a@b.com\",\"password\":\"abcd1234\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ErrorCode.DATA_EXISTS.getCode())) + .andExpect(jsonPath("$.message").value("该账号已注册,请直接登录")) + .andExpect(jsonPath("$.data").value(nullValue())); + } + + @Test + void should_map_otp_required_error_when_phone_register_has_no_code() throws Exception { + given(authService.register(any(), any())).willThrow( + new ServiceException(ErrorCode.BAD_REQUEST, "请先获取验证码")); + + mockMvc.perform(post("/app/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetType\":\"PHONE\",\"target\":\"08123456789\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode())) + .andExpect(jsonPath("$.message").value("请先获取验证码")); + } + + @Test + void should_return_bad_request_code_when_register_body_has_no_target() throws Exception { + mockMvc.perform(post("/app/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetType\":\"EMAIL\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ErrorCode.BAD_REQUEST.getCode())) + .andExpect(jsonPath("$.message").value(containsString("请输入手机号或邮箱"))); + } + + @Test + void should_return_access_token_when_password_login_succeeds() throws Exception { + given(authService.login(any(), any())).willReturn(tokens("access-2", "refresh-2")); + + mockMvc.perform(post("/app/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetType\":\"EMAIL\",\"target\":\"a@b.com\",\"password\":\"abcd1234\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.accessToken").value("access-2")); + } + + @Test + void should_return_login_failed_code_when_password_is_wrong() throws Exception { + given(authService.login(any(), any())).willThrow(new ServiceException(ErrorCode.LOGIN_FAILED)); + + mockMvc.perform(post("/app/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"targetType\":\"EMAIL\",\"target\":\"a@b.com\",\"password\":\"wrongpass1\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ErrorCode.LOGIN_FAILED.getCode())) + .andExpect(jsonPath("$.message").value("账号或密码错误")); + } + + @Test + void should_return_ok_envelope_when_otp_send_succeeds() throws Exception { + mockMvc.perform(post("/app/auth/otp") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"scene\":\"REGISTER\",\"targetType\":\"PHONE\",\"target\":\"08123456789\",\"deviceId\":\"d-1\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").value(nullValue())); + + verify(otpService).send(any(), any()); + } + + private TokenRespVO tokens(String accessToken, String refreshToken) { + TokenRespVO vo = new TokenRespVO(); + vo.setAccessToken(accessToken); + vo.setRefreshToken(refreshToken); + vo.setExpiresIn(7200); + vo.setNewUser(true); + return vo; + } +} diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCatalogControllerContractTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCatalogControllerContractTest.java new file mode 100644 index 0000000..d306f8e --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCatalogControllerContractTest.java @@ -0,0 +1,114 @@ +// Author: fruit.com.cn +package com.fruitshorts.app.controller; + +import com.fruitshorts.app.service.AppCatalogService; +import com.fruitshorts.app.support.ControllerContractSupport; +import com.fruitshorts.app.vo.AppBannerVO; +import com.fruitshorts.app.vo.AppCategoryVO; +import com.fruitshorts.app.vo.AppDramaDetailVO; +import com.fruitshorts.app.vo.AppFeedVO; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.playback.service.PlaybackService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.hamcrest.Matchers.nullValue; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 目录接口的接入层契约:`/app/home/feed`、`/app/discover/feed`、`/app/categories`、`/app/dramas/{id}`。 + * + *

关注三件事:路由通不通、路径参数有没有绑对、响应壳是不是 `{code,message,data}`。 + */ +class AppCatalogControllerContractTest { + + private AppCatalogService catalogService; + private MockMvc catalogMvc; + private MockMvc dramaMvc; + + @BeforeEach + void setUp() { + catalogService = mock(AppCatalogService.class); + catalogMvc = ControllerContractSupport.mockMvcOf(new AppCatalogController(catalogService)); + dramaMvc = ControllerContractSupport.mockMvcOf( + new AppDramaController(catalogService, mock(PlaybackService.class))); + } + + @Test + void should_return_banners_and_sections_when_home_feed_is_requested() throws Exception { + AppBannerVO banner = new AppBannerVO(); + banner.setId(9L); + banner.setImageUrl("/files/banner/9.png"); + banner.setLinkType("DRAMA"); + banner.setLinkValue("12"); + AppFeedVO feed = new AppFeedVO(List.of(banner), List.of()); + given(catalogService.homeFeed()).willReturn(feed); + + catalogMvc.perform(get("/app/home/feed")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.banners[0].id").value(9)) + .andExpect(jsonPath("$.data.banners[0].imageUrl").value("/files/banner/9.png")) + .andExpect(jsonPath("$.data.banners[0].linkType").value("DRAMA")); + } + + @Test + void should_return_sections_when_discover_feed_is_requested() throws Exception { + AppFeedVO feed = new AppFeedVO(List.of(), List.of()); + given(catalogService.discoverFeed()).willReturn(feed); + + catalogMvc.perform(get("/app/discover/feed")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.sections").isArray()); + } + + @Test + void should_return_category_list_when_categories_are_requested() throws Exception { + AppCategoryVO category = new AppCategoryVO(3L, "都市"); + given(catalogService.categories()).willReturn(List.of(category)); + + catalogMvc.perform(get("/app/categories")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data[0].id").value(3)) + .andExpect(jsonPath("$.data[0].name").value("都市")); + } + + @Test + void should_bind_path_variable_when_drama_detail_is_requested() throws Exception { + AppDramaDetailVO detail = new AppDramaDetailVO(); + detail.setId(42L); + detail.setTitle("命运的相遇"); + detail.setEpisodes(List.of()); + given(catalogService.dramaDetail(42L)).willReturn(detail); + + dramaMvc.perform(get("/app/dramas/42")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value(42)) + .andExpect(jsonPath("$.data.title").value("命运的相遇")); + + verify(catalogService).dramaDetail(42L); + } + + @Test + void should_return_data_not_found_code_when_drama_does_not_exist() throws Exception { + given(catalogService.dramaDetail(404L)) + .willThrow(new ServiceException(ErrorCode.DATA_NOT_FOUND, "短剧不存在")); + + dramaMvc.perform(get("/app/dramas/404")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(ErrorCode.DATA_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.data").value(nullValue())); + } +} diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppPlaybackContractTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppPlaybackContractTest.java new file mode 100644 index 0000000..3a9f24f --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppPlaybackContractTest.java @@ -0,0 +1,96 @@ +// Author: fruit.com.cn +package com.fruitshorts.app.controller; + +import com.fruitshorts.app.service.AppCatalogService; +import com.fruitshorts.app.support.ControllerContractSupport; +import com.fruitshorts.playback.service.PlaybackService; +import com.fruitshorts.playback.vo.PlayRespVO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 播放票据接口的接入层契约:`/app/episodes/{id}/play` 的免费/付费两种形态与参数透传。 + * + *

免费 / 付费的**判定规则**在 {@code PlaybackServiceTest} 里用真服务验证; + * 这里钉的是"下发给客户端的字段长什么样"(客户端 Flutter 依赖这些字段名)。 + */ +class AppPlaybackContractTest { + + private PlaybackService playbackService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + playbackService = mock(PlaybackService.class); + mockMvc = ControllerContractSupport.mockMvcOf( + new AppDramaController(mock(AppCatalogService.class), playbackService)); + } + + @Test + void should_return_free_access_when_episode_is_free() throws Exception { + given(playbackService.play(isNull(), eq(11L), any(), any())) + .willReturn(ticket(11L, true, PlaybackService.ACCESS_FREE, "/files/ep.mp4?s=1")); + + mockMvc.perform(get("/app/episodes/11/play").header("X-Device-Id", "device-9")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.episodeId").value(11)) + .andExpect(jsonPath("$.data.accessible").value(true)) + .andExpect(jsonPath("$.data.accessType").value(PlaybackService.ACCESS_FREE)) + .andExpect(jsonPath("$.data.playUrl").value("/files/ep.mp4?s=1")); + } + + @Test + void should_return_coin_required_when_episode_is_locked_and_not_entitled() throws Exception { + PlayRespVO locked = ticket(12L, false, PlaybackService.ACCESS_COIN_REQUIRED, ""); + locked.setUnlockCoin(60); + given(playbackService.play(isNull(), eq(12L), any(), any())).willReturn(locked); + + mockMvc.perform(get("/app/episodes/12/play")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.accessible").value(false)) + .andExpect(jsonPath("$.data.accessType").value(PlaybackService.ACCESS_COIN_REQUIRED)) + .andExpect(jsonPath("$.data.playUrl").value("")) + .andExpect(jsonPath("$.data.unlockCoin").value(60)); + } + + @Test + void should_pass_episode_id_and_device_header_when_play_is_requested() throws Exception { + given(playbackService.play(isNull(), eq(13L), eq("device-9"), any())) + .willReturn(ticket(13L, true, PlaybackService.ACCESS_FREE, "/files/ep13.mp4?s=1")); + + mockMvc.perform(get("/app/episodes/13/play").header("X-Device-Id", "device-9")) + .andExpect(status().isOk()); + + verify(playbackService).play(isNull(), eq(13L), eq("device-9"), any()); + } + + private PlayRespVO ticket(Long episodeId, boolean accessible, String accessType, String playUrl) { + PlayRespVO vo = new PlayRespVO(); + vo.setEpisodeId(episodeId); + vo.setDramaId(7L); + vo.setNumber(1); + vo.setTitle("第 1 集"); + vo.setDurationSec(120); + vo.setAccessible(accessible); + vo.setAccessType(accessType); + vo.setPlayUrl(playUrl); + vo.setQualities(List.of("AUTO")); + vo.setUnlockCoin(0); + vo.setPreviewSeconds(0); + return vo; + } +} diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppAuthServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppAuthServiceTest.java new file mode 100644 index 0000000..bc075e2 --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppAuthServiceTest.java @@ -0,0 +1,320 @@ +// Author: fruit.com.cn +package com.fruitshorts.app.service; + +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.framework.security.JwtTokenProvider; +import com.fruitshorts.user.dal.entity.AppUser; +import com.fruitshorts.user.dal.entity.AppUserCredential; +import com.fruitshorts.user.dal.entity.AppUserOtp; +import com.fruitshorts.user.dal.entity.AppUserSession; +import com.fruitshorts.user.dal.mapper.AppUserConsentMapper; +import com.fruitshorts.user.dal.mapper.AppUserCredentialMapper; +import com.fruitshorts.user.dal.mapper.AppUserDeviceMapper; +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.service.AppAuthService; +import com.fruitshorts.user.service.AppOtpService; +import com.fruitshorts.user.service.CredentialHasher; +import com.fruitshorts.user.service.OtpSender; +import com.fruitshorts.user.vo.LoginReqVO; +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.springframework.security.crypto.password.PasswordEncoder; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.concurrent.atomic.AtomicReference; + +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.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * C 端注册 / 登录的**业务规则**测试:真 `AppAuthService` + 替身 Mapper,不连库。 + * + *

覆盖几件接入层测不到的事:EMAIL 免码到底走没走验证码、密码强度、注册频控、 + * 账号重复、密码比对与封禁判断。验证码本身的校验规则在 `AppOtpServiceTest`。 + */ +class AppAuthServiceTest { + + private AppUserMapper userMapper; + private AppUserCredentialMapper credentialMapper; + private AppUserSessionMapper sessionMapper; + private AppUserConsentMapper consentMapper; + private AppUserDeviceMapper deviceMapper; + private AppOtpService otpService; + private CredentialHasher hasher; + private JwtTokenProvider tokenProvider; + private PasswordEncoder passwordEncoder; + private AppAuthService authService; + + @BeforeEach + void setUp() { + userMapper = mock(AppUserMapper.class); + credentialMapper = mock(AppUserCredentialMapper.class); + sessionMapper = mock(AppUserSessionMapper.class); + consentMapper = mock(AppUserConsentMapper.class); + deviceMapper = mock(AppUserDeviceMapper.class); + otpService = mock(AppOtpService.class); + hasher = mock(CredentialHasher.class); + tokenProvider = mock(JwtTokenProvider.class); + passwordEncoder = mock(PasswordEncoder.class); + authService = new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper, + deviceMapper, otpService, hasher, tokenProvider, passwordEncoder); + } + + @Test + void should_register_without_otp_when_target_type_is_email() { + stubHappyRegister(); + AtomicReference saved = captureCredential(); + + TokenRespVO resp = authService.register(emailRegister("abcd1234", null), "203.0.113.8"); + + assertThat(resp.getAccessToken()).isEqualTo("access-1"); + assertThat(resp.getRefreshToken()).isEqualTo("refresh-1"); + assertThat(resp.isNewUser()).isTrue(); + // 免码注册不等于"已验证":邮箱可达性没验证过,所以先记 0,等日后点验证链接再置 1 + assertThat(saved.get().getVerified()).isZero(); + verify(otpService, never()).consume(anyString(), anyString(), anyString(), any()); + } + + @Test + void should_consume_otp_when_email_register_carries_a_code() { + stubHappyRegister(); + + authService.register(emailRegister("abcd1234", "654321"), "203.0.113.8"); + + verify(otpService).consume(eq("REGISTER"), eq("EMAIL"), eq("a@b.com"), eq("654321")); + } + + @Test + void should_consume_otp_when_target_type_is_phone() { + stubHappyRegister(); + + authService.register(phoneRegister("+628123456789", "654321"), "203.0.113.8"); + + verify(otpService).consume(eq("REGISTER"), eq("PHONE"), eq("+628123456789"), eq("654321")); + } + + @Test + void should_reject_phone_register_when_no_code_was_requested() { + AppAuthService serviceWithRealOtp = serviceWithRealOtpService(null); + + assertThatThrownBy(() -> serviceWithRealOtp.register(phoneRegister("+628123456789", ""), "203.0.113.8")) + .isInstanceOf(ServiceException.class) + .hasMessage("请先获取验证码"); + } + + @Test + void should_reject_phone_register_when_code_is_wrong() { + AppUserOtp issued = new AppUserOtp(); + issued.setCodeHash("hash-of-654321"); + issued.setExpireAt(LocalDateTime.now().plusMinutes(5)); + issued.setFailCount(0); + AppAuthService serviceWithRealOtp = serviceWithRealOtpService(issued); + + assertThatThrownBy(() -> serviceWithRealOtp.register(phoneRegister("+628123456789", "000000"), "203.0.113.8")) + .isInstanceOf(ServiceException.class) + .hasMessage("验证码不正确"); + } + + @Test + void should_reject_email_register_when_password_is_weak() { + RegisterReqVO req = emailRegister("abcdefgh", null); + + assertThatThrownBy(() -> authService.register(req, "203.0.113.8")) + .isInstanceOf(ServiceException.class) + .hasMessageContaining("密码需同时包含字母和数字"); + } + + @Test + void should_reject_email_register_when_format_is_invalid() { + RegisterReqVO req = emailRegister("abcd1234", null); + req.setTarget("not-an-email"); + + assertThatThrownBy(() -> authService.register(req, "203.0.113.8")) + .isInstanceOf(ServiceException.class) + .hasMessage("邮箱格式不正确"); + } + + @Test + void should_return_data_exists_when_email_is_already_registered() { + given(userMapper.selectCount(any())).willReturn(0L); + AppUserCredential existing = new AppUserCredential(); + existing.setUserId(1001L); + given(credentialMapper.selectOne(any())).willReturn(existing); + + assertThatThrownBy(() -> authService.register(emailRegister("abcd1234", null), "203.0.113.8")) + .isInstanceOf(ServiceException.class) + .satisfies(ex -> assertThat(((ServiceException) ex).getCode()) + .isEqualTo(ErrorCode.DATA_EXISTS.getCode())) + .hasMessage("该账号已注册,请直接登录"); + } + + @Test + void should_reject_register_when_ip_reaches_the_daily_limit() { + given(userMapper.selectCount(any())).willReturn(5L); + + assertThatThrownBy(() -> authService.register(emailRegister("abcd1234", null), "203.0.113.8")) + .isInstanceOf(ServiceException.class) + .satisfies(ex -> assertThat(((ServiceException) ex).getCode()) + .isEqualTo(ErrorCode.TOO_MANY_REQUESTS.getCode())) + .hasMessageContaining("当前网络注册过于频繁"); + } + + @Test + void should_return_tokens_when_password_matches() { + stubLogin(credential("bcrypt-hash"), user(AppUserStatus.NORMAL)); + given(passwordEncoder.matches("abcd1234", "bcrypt-hash")).willReturn(true); + + TokenRespVO resp = authService.login(emailLogin("abcd1234"), "203.0.113.8"); + + assertThat(resp.getAccessToken()).isEqualTo("access-1"); + assertThat(resp.getUser().getUid()).isEqualTo("UAB12CD"); + verify(sessionMapper).insert(any(AppUserSession.class)); + } + + @Test + void should_reject_login_when_password_does_not_match() { + stubLogin(credential("bcrypt-hash"), user(AppUserStatus.NORMAL)); + given(passwordEncoder.matches("wrongpass1", "bcrypt-hash")).willReturn(false); + + assertThatThrownBy(() -> authService.login(emailLogin("wrongpass1"), "203.0.113.8")) + .isInstanceOf(ServiceException.class) + .satisfies(ex -> assertThat(((ServiceException) ex).getCode()) + .isEqualTo(ErrorCode.LOGIN_FAILED.getCode())) + .hasMessage("账号或密码错误"); + } + + @Test + void should_reject_login_when_account_does_not_exist() { + given(credentialMapper.selectOne(any())).willReturn(null); + + assertThatThrownBy(() -> authService.login(emailLogin("abcd1234"), "203.0.113.8")) + .isInstanceOf(ServiceException.class) + .satisfies(ex -> assertThat(((ServiceException) ex).getCode()) + .isEqualTo(ErrorCode.LOGIN_FAILED.getCode())) + .hasMessage("账号不存在,请先注册"); + } + + @Test + void should_reject_login_when_account_is_banned() { + stubLogin(credential("bcrypt-hash"), user(AppUserStatus.BANNED)); + given(passwordEncoder.matches("abcd1234", "bcrypt-hash")).willReturn(true); + + assertThatThrownBy(() -> authService.login(emailLogin("abcd1234"), "203.0.113.8")) + .isInstanceOf(ServiceException.class) + .satisfies(ex -> assertThat(((ServiceException) ex).getCode()) + .isEqualTo(ErrorCode.ACCOUNT_DISABLED.getCode())); + } + + private void stubHappyRegister() { + given(userMapper.selectCount(any())).willReturn(0L); + given(credentialMapper.selectOne(any())).willReturn(null); + given(hasher.newUid()).willReturn("UAB12CD"); + given(hasher.newRefreshToken()).willReturn("refresh-1"); + given(hasher.sha256(anyString())).willReturn("hash-1"); + given(passwordEncoder.encode(anyString())).willReturn("bcrypt-hash"); + given(tokenProvider.createAppToken(any(), anyString(), any())).willReturn("access-1"); + given(tokenProvider.accessTtlSeconds()).willReturn(7200L); + given(tokenProvider.appRefreshTtl()).willReturn(Duration.ofDays(30)); + AtomicReference inserted = new AtomicReference<>(); + given(userMapper.insert(any(AppUser.class))).willAnswer(invocation -> { + AppUser saved = invocation.getArgument(0); + saved.setId(1001L); + inserted.set(saved); + return 1; + }); + given(userMapper.selectById(any())).willAnswer(invocation -> inserted.get()); + } + + private AtomicReference captureCredential() { + AtomicReference saved = new AtomicReference<>(); + given(credentialMapper.insert(any(AppUserCredential.class))).willAnswer(invocation -> { + saved.set(invocation.getArgument(0)); + return 1; + }); + return saved; + } + + private void stubLogin(AppUserCredential credential, AppUser user) { + given(credentialMapper.selectOne(any())).willReturn(credential); + given(userMapper.selectById(any())).willReturn(user); + given(hasher.newRefreshToken()).willReturn("refresh-1"); + given(hasher.sha256(anyString())).willReturn("hash-1"); + given(tokenProvider.createAppToken(any(), anyString(), any())).willReturn("access-1"); + given(tokenProvider.accessTtlSeconds()).willReturn(7200L); + given(tokenProvider.appRefreshTtl()).willReturn(Duration.ofDays(30)); + } + + private AppAuthService serviceWithRealOtpService(AppUserOtp issued) { + AppUserOtpMapper otpMapper = mock(AppUserOtpMapper.class); + CredentialHasher otpHasher = mock(CredentialHasher.class); + given(otpMapper.selectOne(any())).willReturn(issued); + given(otpHasher.hashOtp(any())).willReturn("hash-of-something-else"); + AppOtpService realOtpService = new AppOtpService(otpMapper, mock(OtpSender.class), otpHasher); + return new AppAuthService(userMapper, credentialMapper, sessionMapper, consentMapper, + deviceMapper, realOtpService, hasher, tokenProvider, passwordEncoder); + } + + private RegisterReqVO emailRegister(String password, String code) { + RegisterReqVO req = new RegisterReqVO(); + req.setTargetType("EMAIL"); + req.setTarget("a@b.com"); + req.setPassword(password); + req.setCode(code); + req.setDeviceId("device-9"); + req.setAgreementVersion("2026-10-01"); + return req; + } + + private RegisterReqVO phoneRegister(String target, String code) { + RegisterReqVO req = new RegisterReqVO(); + req.setTargetType("PHONE"); + req.setTarget(target); + req.setCode(code); + req.setDeviceId("device-9"); + req.setAgreementVersion("2026-10-01"); + return req; + } + + private LoginReqVO emailLogin(String password) { + LoginReqVO req = new LoginReqVO(); + req.setTargetType("EMAIL"); + req.setTarget("a@b.com"); + req.setPassword(password); + req.setDeviceId("device-9"); + return req; + } + + private AppUserCredential credential(String passwordHash) { + AppUserCredential credential = new AppUserCredential(); + credential.setId(1L); + credential.setUserId(1001L); + credential.setType("EMAIL"); + credential.setIdentifier("a@b.com"); + credential.setPasswordHash(passwordHash); + return credential; + } + + private AppUser user(String status) { + AppUser user = new AppUser(); + user.setId(1001L); + user.setUid("UAB12CD"); + user.setNickname("UserAB12CD"); + user.setStatus(status); + user.setCountryCode("ID"); + return user; + } +} diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppOtpServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppOtpServiceTest.java new file mode 100644 index 0000000..f706519 --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppOtpServiceTest.java @@ -0,0 +1,104 @@ +// Author: fruit.com.cn +package com.fruitshorts.app.service; + +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.user.dal.entity.AppUserOtp; +import com.fruitshorts.user.dal.mapper.AppUserOtpMapper; +import com.fruitshorts.user.service.AppOtpService; +import com.fruitshorts.user.service.CredentialHasher; +import com.fruitshorts.user.service.OtpSender; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.concurrent.atomic.AtomicReference; + +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; + +/** + * 验证码**校验规则**测试:真 `AppOtpService` + 替身 Mapper。 + * + *

注册/登录链路"PHONE 必须带验证码"的拒绝对外表现,靠这里的真规则 + `AppAuthServiceTest` + * 里的"必须调用 consume"两条一起钉住,避免两边都用替身导致互相假装。 + */ +class AppOtpServiceTest { + + private AppUserOtpMapper otpMapper; + private CredentialHasher hasher; + private AppOtpService otpService; + + @BeforeEach + void setUp() { + otpMapper = mock(AppUserOtpMapper.class); + hasher = mock(CredentialHasher.class); + otpService = new AppOtpService(otpMapper, mock(OtpSender.class), hasher); + } + + @Test + void should_reject_consume_when_no_code_was_requested() { + given(otpMapper.selectOne(any())).willReturn(null); + + assertThatThrownBy(() -> otpService.consume("REGISTER", "PHONE", "+628123456789", "123456")) + .isInstanceOf(ServiceException.class) + .satisfies(ex -> assertThat(((ServiceException) ex).getCode()) + .isEqualTo(ErrorCode.BAD_REQUEST.getCode())) + .hasMessage("请先获取验证码"); + } + + @Test + void should_reject_consume_when_code_is_wrong() { + AppUserOtp issued = issuedOtp("hash-of-123456"); + given(otpMapper.selectOne(any())).willReturn(issued); + given(hasher.hashOtp("000000")).willReturn("hash-of-000000"); + + assertThatThrownBy(() -> otpService.consume("REGISTER", "PHONE", "+628123456789", "000000")) + .isInstanceOf(ServiceException.class) + .hasMessage("验证码不正确"); + // 失败要计数,连续错 5 次就作废(防爆破) + assertThat(issued.getFailCount()).isEqualTo(1); + } + + @Test + void should_reject_consume_when_code_is_expired() { + AppUserOtp issued = issuedOtp("hash-of-123456"); + issued.setExpireAt(LocalDateTime.now().minusMinutes(1)); + given(otpMapper.selectOne(any())).willReturn(issued); + + assertThatThrownBy(() -> otpService.consume("REGISTER", "PHONE", "+628123456789", "123456")) + .isInstanceOf(ServiceException.class) + .hasMessage("验证码已过期"); + } + + @Test + void should_mark_code_used_when_code_matches() { + AppUserOtp issued = issuedOtp("hash-of-123456"); + AtomicReference updated = new AtomicReference<>(); + given(otpMapper.selectOne(any())).willReturn(issued); + given(hasher.hashOtp("123456")).willReturn("hash-of-123456"); + given(otpMapper.updateById(any(AppUserOtp.class))).willAnswer(invocation -> { + updated.set(invocation.getArgument(0)); + return 1; + }); + + otpService.consume("REGISTER", "PHONE", "+628123456789", "123456"); + + assertThat(updated.get().getUsedAt()).isNotNull(); + } + + private AppUserOtp issuedOtp(String codeHash) { + AppUserOtp otp = new AppUserOtp(); + otp.setId(1L); + otp.setScene("REGISTER"); + otp.setTargetType("PHONE"); + otp.setTarget("+628123456789"); + otp.setCodeHash(codeHash); + otp.setExpireAt(LocalDateTime.now().plusMinutes(5)); + otp.setFailCount(0); + return otp; + } +} diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/PlaybackServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/PlaybackServiceTest.java new file mode 100644 index 0000000..9aab416 --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/PlaybackServiceTest.java @@ -0,0 +1,146 @@ +// Author: fruit.com.cn +package com.fruitshorts.app.service; + +import com.fruitshorts.content.dal.entity.Drama; +import com.fruitshorts.content.dal.entity.DramaEpisode; +import com.fruitshorts.content.service.DramaService; +import com.fruitshorts.content.service.EpisodeService; +import com.fruitshorts.media.service.PlaybackSigner; +import com.fruitshorts.media.service.TranscodeService; +import com.fruitshorts.playback.dal.entity.PlaybackTicket; +import com.fruitshorts.playback.dal.mapper.PlaybackTicketMapper; +import com.fruitshorts.playback.service.EntitlementPort; +import com.fruitshorts.playback.service.PlaybackService; +import com.fruitshorts.playback.vo.PlayRespVO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * 播放票据的**判定规则**测试:真 `PlaybackService` + 替身依赖,不连库。 + * + *

两条关键分支(接入层只验证字段形态,判定在这里):免费集一律给签名地址; + * 付费集在没权益时**不给地址**,只回 `COIN_REQUIRED` 与所需金币。 + */ +class PlaybackServiceTest { + + private EpisodeService episodeService; + private DramaService dramaService; + private PlaybackTicketMapper ticketMapper; + private PlaybackSigner signer; + private EntitlementPort entitlementPort; + private TranscodeService transcodeService; + private PlaybackService playbackService; + + @BeforeEach + void setUp() { + episodeService = mock(EpisodeService.class); + dramaService = mock(DramaService.class); + ticketMapper = mock(PlaybackTicketMapper.class); + signer = mock(PlaybackSigner.class); + entitlementPort = mock(EntitlementPort.class); + transcodeService = mock(TranscodeService.class); + playbackService = new PlaybackService(episodeService, dramaService, ticketMapper, signer, + entitlementPort, transcodeService); + given(dramaService.getPublished(anyLong())).willReturn(drama()); + given(transcodeService.listRenditions(anyLong())).willReturn(List.of()); + given(signer.sign(anyString())).willReturn("/files/episode/1.mp4?e=1&s=sig"); + } + + @Test + void should_return_free_access_when_episode_is_free() { + given(episodeService.getPublished(11L)).willReturn(episode(11L, 1, 0, "/files/episode/1.mp4")); + + PlayRespVO resp = playbackService.play(null, 11L, "device-9", "203.0.113.8"); + + assertThat(resp.getAccessible()).isTrue(); + assertThat(resp.getAccessType()).isEqualTo(PlaybackService.ACCESS_FREE); + assertThat(resp.getPlayUrl()).isEqualTo("/files/episode/1.mp4?e=1&s=sig"); + // 未登录不记票据(票据是"已登录用户"的签发审计) + verify(ticketMapper, never()).insert(any(PlaybackTicket.class)); + } + + @Test + void should_return_coin_required_when_episode_is_locked_and_not_entitled() { + given(episodeService.getPublished(12L)).willReturn(episode(12L, 0, 60, "/files/episode/2.mp4")); + given(entitlementPort.isUnlocked(anyLong(), anyLong(), anyLong())).willReturn(false); + + PlayRespVO resp = playbackService.play(1001L, 12L, "device-9", "203.0.113.8"); + + assertThat(resp.getAccessible()).isFalse(); + assertThat(resp.getAccessType()).isEqualTo(PlaybackService.ACCESS_COIN_REQUIRED); + assertThat(resp.getPlayUrl()).isEmpty(); + assertThat(resp.getUnlockCoin()).isEqualTo(60); + verify(ticketMapper, never()).insert(any(PlaybackTicket.class)); + } + + @Test + void should_return_entitled_access_when_entitlement_port_confirms_unlock() { + given(episodeService.getPublished(13L)).willReturn(episode(13L, 0, 60, "/files/episode/3.mp4")); + given(entitlementPort.isUnlocked(eq(1001L), anyLong(), eq(13L))).willReturn(true); + + PlayRespVO resp = playbackService.play(1001L, 13L, "device-9", "203.0.113.8"); + + assertThat(resp.getAccessible()).isTrue(); + assertThat(resp.getAccessType()).isEqualTo(PlaybackService.ACCESS_ENTITLED); + assertThat(resp.getPlayUrl()).isEqualTo("/files/episode/1.mp4?e=1&s=sig"); + verify(ticketMapper).insert(any(PlaybackTicket.class)); + } + + @Test + void should_return_no_media_when_episode_has_no_source() { + given(episodeService.getPublished(14L)).willReturn(episode(14L, 1, 0, "")); + + PlayRespVO resp = playbackService.play(isNull(), 14L, "device-9", "203.0.113.8"); + + assertThat(resp.getAccessible()).isFalse(); + assertThat(resp.getAccessType()).isEqualTo(PlaybackService.ACCESS_NO_MEDIA); + assertThat(resp.getPlayUrl()).isEmpty(); + } + + @Test + void should_prefer_hls_url_when_the_episode_has_multi_bitrate_renditions() { + DramaEpisode episode = episode(15L, 1, 0, "/files/episode/origin.mp4"); + episode.setHlsUrl("/files/episode/15/master.m3u8"); + given(episodeService.getPublished(15L)).willReturn(episode); + + PlayRespVO resp = playbackService.play(null, 15L, "device-9", "203.0.113.8"); + + assertThat(resp.getPlayUrl()).isEqualTo("/files/episode/1.mp4?e=1&s=sig"); + verify(signer).sign("/files/episode/15/master.m3u8"); + } + + private DramaEpisode episode(Long id, int isFree, int unlockCoin, String videoUrl) { + DramaEpisode episode = new DramaEpisode(); + episode.setId(id); + episode.setDramaId(7L); + episode.setEpNo(1); + episode.setTitle("第 1 集"); + episode.setDurationSec(120); + episode.setIsFree(isFree); + episode.setUnlockCoin(unlockCoin); + episode.setPreviewSeconds(0); + episode.setVideoUrl(videoUrl); + episode.setStatus("published"); + return episode; + } + + private Drama drama() { + Drama drama = new Drama(); + drama.setId(7L); + drama.setTitle("命运的相遇"); + return drama; + } +} diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/support/ControllerContractSupport.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/support/ControllerContractSupport.java new file mode 100644 index 0000000..674f493 --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/support/ControllerContractSupport.java @@ -0,0 +1,33 @@ +// Author: fruit.com.cn +package com.fruitshorts.app.support; + +import com.fruitshorts.framework.log.AccessDeniedRecorder; +import com.fruitshorts.framework.web.GlobalExceptionHandler; +import org.mockito.Mockito; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +/** + * 控制器契约测试的公共装配:真控制器 + 真全局异常处理器 + 真校验器,**不启 Spring 容器**。 + * + *

为什么不用 {@code @WebMvcTest}:{@code AppApiApplication} 上挂着 + * {@code @MapperScan("com.fruitshorts.**.dal.mapper")},而切片测试不装配 DataSource / MyBatis, + * 扫描出的 Mapper 工厂会因为缺少 {@code SqlSessionFactory} 直接报错;要绕开就得再加一套测试专用的 + * 引导配置。这里的 standaloneSetup 同样只装 Web 层(路由、参数绑定、消息转换、异常处理), + * 覆盖面不变而更轻、更稳。 + */ +public final class ControllerContractSupport { + + private ControllerContractSupport() { + } + + /** 组装一个只含指定控制器与全局异常处理的 MockMvc。 */ + @SuppressWarnings("unchecked") + public static MockMvc mockMvcOf(Object controller) { + AccessDeniedRecorder recorder = new AccessDeniedRecorder(Mockito.mock(ObjectProvider.class)); + return MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler(recorder)) + .build(); + } +}