业务规则(免码、密码强度、频控)在 {@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 注册/登录链路"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 两条关键分支(接入层只验证字段形态,判定在这里):免费集一律给签名地址;
+ * 付费集在没权益时**不给地址**,只回 `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();
+ }
+}