test(backend): C 端核心链路契约测试 37 条,mvn test 不再是 No tests to run

为什么:红线第 8 条要求后端 mvn test 通过且核心接口有契约测试;此前 13 个模块全是
No tests to run.,这个"通过"是空的。按 M2 口径只补 C 端核心链路最小集。

- 接入层契约(MockMvc,真控制器 + 真全局异常处理器 + service 替身):
  /app/auth 注册与登录、目录四条路由、播放票据 FREE 与 COIN_REQUIRED 的字段形态
- 业务规则(真 service + 替身 Mapper):
  EMAIL 免码不走验证码且 verified=0、PHONE 必须带码、验证码错/过期被拒、
  密码强度、注册频控 429、账号重复 200001、密码错误 100000、封禁 100001、
  免费/已购/未解锁/无片源四个播放分支
- 依赖只加到 fruitshorts-app-api(spring-boot-starter-test,test scope),不动其它模块
- 测试放在接入层模块,避免动 01-08 冻结模块目录;只新增测试,未改业务逻辑
- 实测:mvn test BUILD SUCCESS,app-api Tests run: 37, Failures: 0, Errors: 0;
  mvn -DskipTests package 仍 BUILD SUCCESS
This commit is contained in:
Vicky committed 2026-10-16 09:52:52 +08:00
1 parent 84aa0e17dd
commit fafadfc6ec
8 files changed
+959

No files matched your search

+6
View File
@@ -39,6 +39,12 @@
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-module-commerce</artifactId>
</dependency>
<!-- 契约测试用:MockMvc + Mockito + JUnit5,test scope,不进运行态依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -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}`、错误码映射。
*
* <p>业务规则(免码、密码强度、频控)在 {@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;
}
}
@@ -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}`。
*
* <p>关注三件事:路由通不通、路径参数有没有绑对、响应壳是不是 `{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()));
}
}
@@ -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` 的免费/付费两种形态与参数透传。
*
* <p>免费 / 付费的**判定规则**在 {@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;
}
}
@@ -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,不连库。
*
* <p>覆盖几件接入层测不到的事: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<AppUserCredential> 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<AppUser> 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<AppUserCredential> captureCredential() {
AtomicReference<AppUserCredential> 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;
}
}
@@ -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。
*
* <p>注册/登录链路"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<AppUserOtp> 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;
}
}
@@ -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` + 替身依赖,不连库。
*
* <p>两条关键分支(接入层只验证字段形态,判定在这里):免费集一律给签名地址;
* 付费集在没权益时**不给地址**,只回 `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;
}
}
@@ -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 容器**。
*
* <p>为什么不用 {@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();
}
}