完成自动化测试套件实施(W1-W11)

W1-W3: 基线修复与测试基础设施搭建
- 修复 Jenkins JDK 21 兼容性,统一 E2E 目录,修复 storageState 冲突
- 搭建后端测试基类 BaseContractTest + Testcontainers PostgreSQL
- 创建 TestDataFactory 链式构造,完善 Vitest 基座与 Playwright fixtures
- 建立 docker-compose.test.yml 与测试数据隔离方案

W4-W5: 单元测试补齐(阶段 2)
- 补齐 gym-member/gym-groupCourse/gym-checkIn/gym-payment 核心模块单元测试
- 补齐 gym-coach/manage-sys 模块单元测试
- 前端 utils/composables/stores 单元测试,37 文件 502 项测试
- JaCoCo 覆盖率门禁从 30% 调整至 55%,21 模块全部通过

W6-W7: 集成与契约测试(阶段 3)
- Repository 集成测试:会员/团课/签到/支付关键表,Testcontainers 100% 通过
- Handler 集成测试:WebTestClient 覆盖正向/异常/权限路径
- 网关集成测试:JWT/RBAC/签名/限流/重试
- Flyway 迁移测试:验证迁移脚本可重复执行
- OpenAPI 契约测试:覆盖 ≥80% P0 接口,202 项契约测试 0 失败
- 跨模块契约测试:会员-支付-团课数据一致性

W8-W9: E2E 与用户旅程测试(阶段 4)
- 管理员 Web 核心流程 E2E:用户/角色/菜单/字典/配置
- 小程序会员端核心页面 E2E:购卡/预约/签到
- 5 条 P0 用户旅程全链路自动化,60 条 journey 测试 0 失败

W10: 变异测试与质量门禁(阶段 5)
- 后端 PIT 配置:pitest-maven 1.19.1 + JUnit 5,覆盖率阈值 55%/变异阈值 45%
- P0 模块基线:manage-sys 48%,gym-member 30%,gym-payment 36%
- 前端 StrykerJS 配置:utils/stores 变异测试,dateFormat.ts 70.83%
- Jenkins 质量门禁:JaCoCo/PIT/E2E 统一检查,不达标阻断构建

W11: 持续运行与改进(阶段 6)
- 测试指标收集脚本 scripts/collect-test-metrics.py + HTML 看板生成器
- Flaky Test 治理 SOP:检测→隔离→根因分析→修复→验证闭环
- 测试资产定期评审流程:月度/季度/事件驱动三级机制
- 快速参考指南 docs/testing/quick-reference.md
- 累计 10 份测试文档,7 个里程碑全部达成
This commit was merged in pull request #54.
This commit is contained in:
2026-08-02 08:28:37 +08:00
parent dc68581c5e
commit 015cb0dc78
119 changed files with 21873 additions and 553 deletions
+5
View File
@@ -63,5 +63,10 @@
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -164,16 +164,24 @@ public class CoachCourseScheduler {
}
/**
* 清除统计缓存和团课缓存 —— 调度器触发时,如有课程状态变更则必须及时失效
* 清除统计缓存和团课缓存 —— 调度器触发时,如有课程状态变更则必须及时失效
*
* <p>注意:测试环境中 RedisUtil 可能被 Mock 返回 null,需做 null 安全处理。</p>
*/
private void invalidateCache() {
redisUtil.deleteByPattern("datacount:statistics:*").subscribe(
deleted -> logger.debug("调度器清除统计缓存,已删除 {} 条", deleted),
error -> logger.warn("调度器清除统计缓存失败: {}", error.getMessage())
);
redisUtil.deleteByPattern("group_course:*").subscribe(
deleted -> logger.debug("调度器清除团课缓存,已删除 {} 条", deleted),
error -> logger.warn("调度器清除团课缓存失败: {}", error.getMessage())
);
Mono<Long> statsMono = redisUtil.deleteByPattern("datacount:statistics:*");
if (statsMono != null) {
statsMono.subscribe(
deleted -> logger.debug("调度器清除统计缓存,已删除 {} 条", deleted),
error -> logger.warn("调度器清除统计缓存失败: {}", error.getMessage())
);
}
Mono<Long> courseMono = redisUtil.deleteByPattern("group_course:*");
if (courseMono != null) {
courseMono.subscribe(
deleted -> logger.debug("调度器清除团课缓存,已删除 {} 条", deleted),
error -> logger.warn("调度器清除团课缓存失败: {}", error.getMessage())
);
}
}
}
@@ -0,0 +1,125 @@
package cn.novalon.gym.manage.coach.handler;
import cn.novalon.gym.manage.coach.service.CoachCourseService;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import cn.novalon.gym.manage.sys.util.AuthUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.reactive.function.server.MockServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CoachCourseHandlerTest {
@Mock
private CoachCourseService coachCourseService;
@Mock
private AuthUtil authUtil;
private CoachCourseHandler coachCourseHandler;
private static final Long COACH_ID = 10001L;
private static final Long COURSE_ID = 1L;
@BeforeEach
void setUp() {
coachCourseHandler = new CoachCourseHandler(coachCourseService, authUtil);
}
// ==================== startCourse ====================
@Test
void startCourse_shouldReturnOkWhenSuccess() {
GroupCourseEntity course = mock(GroupCourseEntity.class);
when(course.getStatus()).thenReturn(3L);
when(course.getActualStartTime()).thenReturn(LocalDateTime.now());
when(authUtil.getMemberIdOrThrow(any())).thenReturn(COACH_ID);
when(coachCourseService.startCourse(COURSE_ID, COACH_ID)).thenReturn(Mono.just(course));
MockServerRequest request = MockServerRequest.builder()
.pathVariable("courseId", "1")
.build();
Mono<ServerResponse> result = coachCourseHandler.startCourse(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
verify(authUtil).getMemberIdOrThrow(request);
verify(coachCourseService).startCourse(COURSE_ID, COACH_ID);
}
@Test
void startCourse_shouldReturnBadRequestOnError() {
when(authUtil.getMemberIdOrThrow(any())).thenReturn(COACH_ID);
when(coachCourseService.startCourse(COURSE_ID, COACH_ID))
.thenReturn(Mono.error(new RuntimeException("当前课程状态不允许开课")));
MockServerRequest request = MockServerRequest.builder()
.pathVariable("courseId", "1")
.build();
Mono<ServerResponse> result = coachCourseHandler.startCourse(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
verify(authUtil).getMemberIdOrThrow(request);
verify(coachCourseService).startCourse(COURSE_ID, COACH_ID);
}
// ==================== endCourse ====================
@Test
void endCourse_shouldReturnOkWhenSuccess() {
GroupCourseEntity course = mock(GroupCourseEntity.class);
when(course.getStatus()).thenReturn(2L);
when(course.getActualEndTime()).thenReturn(LocalDateTime.now());
when(authUtil.getMemberIdOrThrow(any())).thenReturn(COACH_ID);
when(coachCourseService.endCourse(COURSE_ID, COACH_ID)).thenReturn(Mono.just(course));
MockServerRequest request = MockServerRequest.builder()
.pathVariable("courseId", "1")
.build();
Mono<ServerResponse> result = coachCourseHandler.endCourse(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK);
verify(authUtil).getMemberIdOrThrow(request);
verify(coachCourseService).endCourse(COURSE_ID, COACH_ID);
}
@Test
void endCourse_shouldReturnBadRequestOnError() {
when(authUtil.getMemberIdOrThrow(any())).thenReturn(COACH_ID);
when(coachCourseService.endCourse(COURSE_ID, COACH_ID))
.thenReturn(Mono.error(new RuntimeException("当前课程状态不允许结课")));
MockServerRequest request = MockServerRequest.builder()
.pathVariable("courseId", "1")
.build();
Mono<ServerResponse> result = coachCourseHandler.endCourse(request);
ServerResponse response = result.block();
assertThat(response.statusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
verify(authUtil).getMemberIdOrThrow(request);
verify(coachCourseService).endCourse(COURSE_ID, COACH_ID);
}
}
@@ -0,0 +1,470 @@
package cn.novalon.gym.manage.coach.scheduler;
import cn.novalon.gym.manage.coach.enums.ViolationReason;
import cn.novalon.gym.manage.coachconfig.domain.CoachTimeRule;
import cn.novalon.gym.manage.coachconfig.service.CoachTimeRuleService;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseBookingDao;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.r2dbc.core.DatabaseClient;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CoachCourseSchedulerTest {
@Mock
private GroupCourseDao groupCourseDao;
@Mock
private GroupCourseBookingDao groupCourseBookingDao;
@Mock
private DatabaseClient databaseClient;
@Mock
private RedisUtil redisUtil;
@Mock
private CoachTimeRuleService timeRuleService;
private CoachCourseScheduler scheduler;
private static final Long COURSE_ID = 100L;
private static final Long COACH_ID = 200L;
private static final int LATE_WINDOW = 30;
private static final int END_GRACE = 10;
@BeforeEach
void setUp() {
scheduler = new CoachCourseScheduler(
groupCourseDao, groupCourseBookingDao,
databaseClient, redisUtil, timeRuleService
);
}
// ==================== 辅助方法 ====================
/**
* 通过反射调用私有方法
*/
@SuppressWarnings("unchecked")
private <T> T invokePrivateMethod(String methodName, Class<?>[] paramTypes, Object... args) {
try {
Method method = CoachCourseScheduler.class.getDeclaredMethod(methodName, paramTypes);
method.setAccessible(true);
return (T) method.invoke(scheduler, args);
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException("反射调用方法 " + methodName + " 失败", e);
}
}
private GroupCourseEntity createCourse(Long id, Long coachId, LocalDateTime startTime,
LocalDateTime endTime, String status) {
GroupCourseEntity course = mock(GroupCourseEntity.class);
lenient().when(course.getId()).thenReturn(id);
lenient().when(course.getCoachId()).thenReturn(coachId);
lenient().when(course.getStartTime()).thenReturn(startTime);
lenient().when(course.getEndTime()).thenReturn(endTime);
lenient().when(course.getStatus()).thenReturn(Long.valueOf(status));
return course;
}
private CoachTimeRule createTimeRule(int lateWindow, int endGrace) {
CoachTimeRule rule = new CoachTimeRule();
rule.setLateWindow(lateWindow);
rule.setEndGrace(endGrace);
return rule;
}
private DatabaseClient.GenericExecuteSpec mockDatabaseClientInsertChain() {
DatabaseClient.GenericExecuteSpec spec = mock(DatabaseClient.GenericExecuteSpec.class);
when(databaseClient.sql(anyString())).thenReturn(spec);
when(spec.bind(anyString(), any())).thenReturn(spec);
when(spec.then()).thenReturn(Mono.empty());
return spec;
}
// ==================== processAbsentCourses ====================
@Test
void processAbsentCourses_shouldReturnZeroWhenNoAbsentCourses() {
// 准备:无缺席课程
LocalDateTime now = LocalDateTime.of(2026, 7, 31, 10, 0);
when(groupCourseDao.findByStatusAndStartTimeBefore(databaseClient, "0", now))
.thenReturn(Flux.empty());
// 执行
Mono<Long> result = invokePrivateMethod("processAbsentCourses",
new Class<?>[]{LocalDateTime.class}, now);
// 验证
StepVerifier.create(result)
.expectNext(0L)
.verifyComplete();
verify(groupCourseDao).findByStatusAndStartTimeBefore(databaseClient, "0", now);
verifyNoInteractions(timeRuleService);
}
@Test
void processAbsentCourses_shouldReturnZeroWhenCoursesNotTimedOut() {
// 准备:有课程但未超时(minutesSinceStart <= lateWindow
LocalDateTime startTime = LocalDateTime.of(2026, 7, 31, 10, 0);
LocalDateTime endTime = LocalDateTime.of(2026, 7, 31, 11, 0);
LocalDateTime now = LocalDateTime.of(2026, 7, 31, 10, 20); // 仅过20分钟,<= lateWindow(30)
GroupCourseEntity course = createCourse(COURSE_ID, COACH_ID, startTime, endTime, "0");
CoachTimeRule rule = createTimeRule(LATE_WINDOW, END_GRACE);
when(groupCourseDao.findByStatusAndStartTimeBefore(databaseClient, "0", now))
.thenReturn(Flux.just(course));
when(timeRuleService.matchRule(60)).thenReturn(Mono.just(rule));
// 执行
Mono<Long> result = invokePrivateMethod("processAbsentCourses",
new Class<?>[]{LocalDateTime.class}, now);
// 验证:filter 过滤掉,不触发 markAsCoachAbsent
StepVerifier.create(result)
.expectNext(0L)
.verifyComplete();
verify(groupCourseDao).findByStatusAndStartTimeBefore(databaseClient, "0", now);
verify(timeRuleService).matchRule(60);
verifyNoMoreInteractions(groupCourseDao, groupCourseBookingDao);
}
@Test
void processAbsentCourses_shouldMarkAbsentWhenCoursesTimedOut() {
// 准备:有课程且已超时(minutesSinceStart > lateWindow
LocalDateTime startTime = LocalDateTime.of(2026, 7, 31, 10, 0);
LocalDateTime endTime = LocalDateTime.of(2026, 7, 31, 11, 0);
LocalDateTime now = LocalDateTime.of(2026, 7, 31, 10, 40); // 已过40分钟,> lateWindow(30)
GroupCourseEntity course = createCourse(COURSE_ID, COACH_ID, startTime, endTime, "0");
CoachTimeRule rule = createTimeRule(LATE_WINDOW, END_GRACE);
when(groupCourseDao.findByStatusAndStartTimeBefore(databaseClient, "0", now))
.thenReturn(Flux.just(course));
when(timeRuleService.matchRule(60)).thenReturn(Mono.just(rule));
// mock DatabaseClient 链式调用
DatabaseClient.GenericExecuteSpec spec = mockDatabaseClientInsertChain();
when(groupCourseDao.updateToCoachAbsent(COURSE_ID, now, now)).thenReturn(Mono.just(1));
when(groupCourseBookingDao.updateStatusByCourseId(COURSE_ID, "0", "4")).thenReturn(Mono.just(1));
// 执行
Mono<Long> result = invokePrivateMethod("processAbsentCourses",
new Class<?>[]{LocalDateTime.class}, now);
// 验证
StepVerifier.create(result)
.expectNext(1L)
.verifyComplete();
verify(groupCourseDao).findByStatusAndStartTimeBefore(databaseClient, "0", now);
verify(timeRuleService).matchRule(60);
verify(databaseClient).sql(anyString());
verify(spec, atLeastOnce()).bind(anyString(), any());
verify(spec).then();
verify(groupCourseDao).updateToCoachAbsent(COURSE_ID, now, now);
verify(groupCourseBookingDao).updateStatusByCourseId(COURSE_ID, "0", "4");
}
// ==================== processAutoEndCourses ====================
@Test
void processAutoEndCourses_shouldReturnZeroWhenNoCoursesToAutoEnd() {
// 准备:无自动结课课程
LocalDateTime now = LocalDateTime.of(2026, 7, 31, 12, 0);
String[] expectedStatuses = {"3", "7"};
when(groupCourseDao.findByStatusInAndEndTimeBefore(eq(databaseClient), eq(expectedStatuses), eq(now)))
.thenReturn(Flux.empty());
// 执行
Mono<Long> result = invokePrivateMethod("processAutoEndCourses",
new Class<?>[]{LocalDateTime.class}, now);
// 验证
StepVerifier.create(result)
.expectNext(0L)
.verifyComplete();
verify(groupCourseDao).findByStatusInAndEndTimeBefore(eq(databaseClient), eq(expectedStatuses), eq(now));
verifyNoInteractions(timeRuleService);
}
@Test
void processAutoEndCourses_shouldReturnZeroWhenCoursesNotTimedOut() {
// 准备:有课程但未超时(minutesAfterEnd <= endGrace
LocalDateTime startTime = LocalDateTime.of(2026, 7, 31, 9, 0);
LocalDateTime endTime = LocalDateTime.of(2026, 7, 31, 10, 0);
LocalDateTime now = LocalDateTime.of(2026, 7, 31, 10, 5); // 仅过5分钟,<= endGrace(10)
GroupCourseEntity course = createCourse(COURSE_ID, COACH_ID, startTime, endTime, "3");
CoachTimeRule rule = createTimeRule(LATE_WINDOW, END_GRACE);
String[] expectedStatuses = {"3", "7"};
when(groupCourseDao.findByStatusInAndEndTimeBefore(eq(databaseClient), eq(expectedStatuses), eq(now)))
.thenReturn(Flux.just(course));
when(timeRuleService.matchRule(60)).thenReturn(Mono.just(rule));
// 执行
Mono<Long> result = invokePrivateMethod("processAutoEndCourses",
new Class<?>[]{LocalDateTime.class}, now);
// 验证:filter 过滤掉,不触发 markAsAutoEnded
StepVerifier.create(result)
.expectNext(0L)
.verifyComplete();
verify(groupCourseDao).findByStatusInAndEndTimeBefore(eq(databaseClient), eq(expectedStatuses), eq(now));
verify(timeRuleService).matchRule(60);
verifyNoMoreInteractions(groupCourseDao);
}
@Test
void processAutoEndCourses_shouldMarkAutoEndedWhenCoursesTimedOut() {
// 准备:有课程且已超时(minutesAfterEnd > endGrace
LocalDateTime startTime = LocalDateTime.of(2026, 7, 31, 9, 0);
LocalDateTime endTime = LocalDateTime.of(2026, 7, 31, 10, 0);
LocalDateTime now = LocalDateTime.of(2026, 7, 31, 10, 15); // 已过15分钟,> endGrace(10)
GroupCourseEntity course = createCourse(COURSE_ID, COACH_ID, startTime, endTime, "3");
CoachTimeRule rule = createTimeRule(LATE_WINDOW, END_GRACE);
String[] expectedStatuses = {"3", "7"};
when(groupCourseDao.findByStatusInAndEndTimeBefore(eq(databaseClient), eq(expectedStatuses), eq(now)))
.thenReturn(Flux.just(course));
when(timeRuleService.matchRule(60)).thenReturn(Mono.just(rule));
// mock DatabaseClient 链式调用
DatabaseClient.GenericExecuteSpec spec = mockDatabaseClientInsertChain();
when(groupCourseDao.updateToAutoEnded(COURSE_ID, now, now)).thenReturn(Mono.just(1));
// 执行
Mono<Long> result = invokePrivateMethod("processAutoEndCourses",
new Class<?>[]{LocalDateTime.class}, now);
// 验证
StepVerifier.create(result)
.expectNext(1L)
.verifyComplete();
verify(groupCourseDao).findByStatusInAndEndTimeBefore(eq(databaseClient), eq(expectedStatuses), eq(now));
verify(timeRuleService).matchRule(60);
verify(databaseClient).sql(anyString());
verify(spec, atLeastOnce()).bind(anyString(), any());
verify(spec).then();
verify(groupCourseDao).updateToAutoEnded(COURSE_ID, now, now);
verifyNoInteractions(groupCourseBookingDao);
}
// ==================== markAsCoachAbsent ====================
@Test
void markAsCoachAbsent_shouldInsertViolationAndUpdateCourseAndBooking() {
// 准备
LocalDateTime startTime = LocalDateTime.of(2026, 7, 31, 10, 0);
LocalDateTime endTime = LocalDateTime.of(2026, 7, 31, 11, 0);
LocalDateTime now = LocalDateTime.of(2026, 7, 31, 10, 40);
GroupCourseEntity course = createCourse(COURSE_ID, COACH_ID, startTime, endTime, "0");
// mock DatabaseClient 链式调用
DatabaseClient.GenericExecuteSpec spec = mockDatabaseClientInsertChain();
when(groupCourseDao.updateToCoachAbsent(COURSE_ID, now, now)).thenReturn(Mono.just(1));
when(groupCourseBookingDao.updateStatusByCourseId(COURSE_ID, "0", "4")).thenReturn(Mono.just(1));
// 执行
Mono<GroupCourseEntity> result = invokePrivateMethod("markAsCoachAbsent",
new Class<?>[]{GroupCourseEntity.class, LocalDateTime.class}, course, now);
// 验证
StepVerifier.create(result)
.expectNext(course)
.verifyComplete();
// 验证 insertViolation 链
verify(databaseClient).sql(contains("INSERT INTO coach_violation"));
verify(spec, atLeastOnce()).bind(anyString(), any());
verify(spec).then();
// 验证 updateToCoachAbsent
verify(groupCourseDao).updateToCoachAbsent(COURSE_ID, now, now);
// 验证 updateStatusByCourseId
verify(groupCourseBookingDao).updateStatusByCourseId(COURSE_ID, "0", "4");
}
// ==================== markAsAutoEnded ====================
@Test
void markAsAutoEnded_shouldInsertViolationAndUpdateCourse() {
// 准备
LocalDateTime startTime = LocalDateTime.of(2026, 7, 31, 9, 0);
LocalDateTime endTime = LocalDateTime.of(2026, 7, 31, 10, 0);
LocalDateTime now = LocalDateTime.of(2026, 7, 31, 10, 15);
GroupCourseEntity course = createCourse(COURSE_ID, COACH_ID, startTime, endTime, "3");
// mock DatabaseClient 链式调用
DatabaseClient.GenericExecuteSpec spec = mockDatabaseClientInsertChain();
when(groupCourseDao.updateToAutoEnded(COURSE_ID, now, now)).thenReturn(Mono.just(1));
// 执行
Mono<GroupCourseEntity> result = invokePrivateMethod("markAsAutoEnded",
new Class<?>[]{GroupCourseEntity.class, LocalDateTime.class}, course, now);
// 验证
StepVerifier.create(result)
.expectNext(course)
.verifyComplete();
// 验证 insertViolation 链
verify(databaseClient).sql(contains("INSERT INTO coach_violation"));
verify(spec, atLeastOnce()).bind(anyString(), any());
verify(spec).then();
// 验证 updateToAutoEnded
verify(groupCourseDao).updateToAutoEnded(COURSE_ID, now, now);
// 验证未调用 bookingDao
verifyNoInteractions(groupCourseBookingDao);
}
// ==================== checkAndProcessCourses ====================
@Test
void checkAndProcessCourses_shouldProcessNoCoursesWhenNoneExist() {
// 准备:无任何待处理课程
LocalDateTime now = LocalDateTime.now();
when(groupCourseDao.findByStatusAndStartTimeBefore(eq(databaseClient), eq("0"), any(LocalDateTime.class)))
.thenReturn(Flux.empty());
when(groupCourseDao.findByStatusInAndEndTimeBefore(eq(databaseClient), any(String[].class), any(LocalDateTime.class)))
.thenReturn(Flux.empty());
// 执行
scheduler.checkAndProcessCourses();
// 验证:processAbsentCourses 和 processAutoEndCourses 都被调用
verify(groupCourseDao).findByStatusAndStartTimeBefore(eq(databaseClient), eq("0"), any(LocalDateTime.class));
verify(groupCourseDao).findByStatusInAndEndTimeBefore(eq(databaseClient), any(String[].class), any(LocalDateTime.class));
// 验证未触发缓存清除(count=0,不进入 if 分支)
verifyNoInteractions(redisUtil);
}
@Test
void checkAndProcessCourses_shouldInvalidateCacheWhenCoursesProcessed() {
// 准备:有缺席课程需处理
LocalDateTime startTime = LocalDateTime.of(2026, 7, 31, 10, 0);
LocalDateTime endTime = LocalDateTime.of(2026, 7, 31, 11, 0);
LocalDateTime now = LocalDateTime.now();
// 由于不能精确控制 now,这里匹配任何时间
GroupCourseEntity absentCourse = createCourse(COURSE_ID, COACH_ID, startTime, endTime, "0");
CoachTimeRule rule = createTimeRule(LATE_WINDOW, END_GRACE);
when(groupCourseDao.findByStatusAndStartTimeBefore(eq(databaseClient), eq("0"), any(LocalDateTime.class)))
.thenReturn(Flux.just(absentCourse));
when(timeRuleService.matchRule(60)).thenReturn(Mono.just(rule));
// mock DatabaseClient 链式调用
DatabaseClient.GenericExecuteSpec spec = mockDatabaseClientInsertChain();
when(groupCourseDao.updateToCoachAbsent(eq(COURSE_ID), any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(Mono.just(1));
when(groupCourseBookingDao.updateStatusByCourseId(COURSE_ID, "0", "4"))
.thenReturn(Mono.just(1));
// 自动结课:无课程
when(groupCourseDao.findByStatusInAndEndTimeBefore(eq(databaseClient), any(String[].class), any(LocalDateTime.class)))
.thenReturn(Flux.empty());
// mock RedisUtil 返回非 null 的 Mono
when(redisUtil.deleteByPattern("datacount:statistics:*")).thenReturn(Mono.just(1L));
when(redisUtil.deleteByPattern("group_course:*")).thenReturn(Mono.just(1L));
// 执行
scheduler.checkAndProcessCourses();
// 验证缓存清除被调用
verify(redisUtil).deleteByPattern("datacount:statistics:*");
verify(redisUtil).deleteByPattern("group_course:*");
}
@Test
void checkAndProcessCourses_shouldHandleRedisUtilReturningNull() {
// 准备:测试 RedisUtil 返回 null 的边界情况
LocalDateTime startTime = LocalDateTime.of(2026, 7, 31, 10, 0);
LocalDateTime endTime = LocalDateTime.of(2026, 7, 31, 11, 0);
GroupCourseEntity absentCourse = createCourse(COURSE_ID, COACH_ID, startTime, endTime, "0");
CoachTimeRule rule = createTimeRule(LATE_WINDOW, END_GRACE);
when(groupCourseDao.findByStatusAndStartTimeBefore(eq(databaseClient), eq("0"), any(LocalDateTime.class)))
.thenReturn(Flux.just(absentCourse));
when(timeRuleService.matchRule(60)).thenReturn(Mono.just(rule));
// mock DatabaseClient 链式调用
DatabaseClient.GenericExecuteSpec spec = mockDatabaseClientInsertChain();
when(groupCourseDao.updateToCoachAbsent(anyLong(), any(LocalDateTime.class), any(LocalDateTime.class)))
.thenReturn(Mono.just(1));
when(groupCourseBookingDao.updateStatusByCourseId(anyLong(), anyString(), anyString()))
.thenReturn(Mono.just(1));
// 自动结课:无课程
when(groupCourseDao.findByStatusInAndEndTimeBefore(eq(databaseClient), any(String[].class), any(LocalDateTime.class)))
.thenReturn(Flux.empty());
// RedisUtil.deleteByPattern 返回 null(模拟 null 安全检查)
when(redisUtil.deleteByPattern("datacount:statistics:*")).thenReturn(null);
when(redisUtil.deleteByPattern("group_course:*")).thenReturn(null);
// 执行:不应抛出 NPE
scheduler.checkAndProcessCourses();
// 验证:RedisUtil 被调用(即使返回 null
verify(redisUtil).deleteByPattern("datacount:statistics:*");
verify(redisUtil).deleteByPattern("group_course:*");
}
@Test
void checkAndProcessCourses_shouldHandleErrorInAbsentProcessing() {
// 准备:缺席处理抛出异常
when(groupCourseDao.findByStatusAndStartTimeBefore(eq(databaseClient), eq("0"), any(LocalDateTime.class)))
.thenReturn(Flux.error(new RuntimeException("数据库查询失败")));
when(groupCourseDao.findByStatusInAndEndTimeBefore(eq(databaseClient), any(String[].class), any(LocalDateTime.class)))
.thenReturn(Flux.empty());
// 执行:不应抛出异常(subscribe 中有 error handler
scheduler.checkAndProcessCourses();
// 验证
verify(groupCourseDao).findByStatusAndStartTimeBefore(eq(databaseClient), eq("0"), any(LocalDateTime.class));
verify(groupCourseDao).findByStatusInAndEndTimeBefore(eq(databaseClient), any(String[].class), any(LocalDateTime.class));
verifyNoInteractions(redisUtil);
}
}
@@ -0,0 +1,803 @@
package cn.novalon.gym.manage.coach.service;
import cn.novalon.gym.manage.coach.dao.CoachViolationDao;
import cn.novalon.gym.manage.coach.enums.ViolationReason;
import cn.novalon.gym.manage.coachconfig.domain.CoachTimeRule;
import cn.novalon.gym.manage.coachconfig.service.CoachTimeRuleService;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.common.util.StatusConstants;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseBookingDao;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
import cn.novalon.gym.manage.groupcourse.enums.CourseStatus;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseBookingRepository;
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
import cn.novalon.gym.manage.sys.core.domain.SysRole;
import cn.novalon.gym.manage.sys.core.domain.SysUser;
import cn.novalon.gym.manage.sys.core.domain.UserRole;
import cn.novalon.gym.manage.sys.core.repository.ISysRoleRepository;
import cn.novalon.gym.manage.sys.core.repository.ISysUserRepository;
import cn.novalon.gym.manage.sys.core.repository.IUserRoleRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.Sort;
import org.springframework.r2dbc.core.DatabaseClient;
import org.springframework.r2dbc.core.FetchSpec;
import org.springframework.security.crypto.password.PasswordEncoder;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import java.time.LocalDateTime;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class CoachCourseServiceTest {
@Mock
private ISysUserRepository userRepository;
@Mock
private ISysRoleRepository roleRepository;
@Mock
private IUserRoleRepository userRoleRepository;
@Mock
private IGroupCourseRepository groupCourseRepository;
@Mock
private IGroupCourseBookingRepository bookingRepository;
@Mock
private GroupCourseDao groupCourseDao;
@Mock
private GroupCourseBookingDao groupCourseBookingDao;
@Mock
private CoachViolationDao violationDao;
@Mock
private DatabaseClient databaseClient;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private RedisUtil redisUtil;
@Mock
private CoachTimeRuleService timeRuleService;
private CoachCourseService coachCourseService;
private static final Long COACH_ROLE_ID = 100L;
private static final Long COACH_USER_ID = 10001L;
private static final Long COURSE_ID = 1L;
private static final Long OTHER_COACH_ID = 20001L;
@BeforeEach
void setUp() {
coachCourseService = new CoachCourseService(
userRepository, roleRepository, userRoleRepository,
groupCourseRepository, bookingRepository,
groupCourseDao, groupCourseBookingDao,
violationDao, databaseClient,
passwordEncoder, redisUtil, timeRuleService
);
}
// ==================== getCoachRoleId ====================
@Test
void getCoachRoleId_shouldReturnRoleIdWhenFound() {
SysRole role = new SysRole();
role.setId(COACH_ROLE_ID);
role.setRoleName("教练");
when(roleRepository.findByRoleName("教练")).thenReturn(Mono.just(role));
StepVerifier.create(coachCourseService.getCoachRoleId())
.expectNext(COACH_ROLE_ID)
.verifyComplete();
verify(roleRepository).findByRoleName("教练");
}
@Test
void getCoachRoleId_shouldThrowWhenRoleNotFound() {
when(roleRepository.findByRoleName("教练")).thenReturn(Mono.empty());
StepVerifier.create(coachCourseService.getCoachRoleId())
.expectErrorMatches(e -> e instanceof RuntimeException
&& e.getMessage().contains("教练角色未找到"))
.verify();
verify(roleRepository).findByRoleName("教练");
}
// ==================== getAllCoaches ====================
@Test
void getAllCoaches_shouldReturnCoachList() {
SysRole role = new SysRole();
role.setId(COACH_ROLE_ID);
when(roleRepository.findByRoleName("教练")).thenReturn(Mono.just(role));
UserRole userRole1 = new UserRole();
userRole1.setUserId(COACH_USER_ID);
userRole1.setRoleId(COACH_ROLE_ID);
UserRole userRole2 = new UserRole();
userRole2.setUserId(10002L);
userRole2.setRoleId(COACH_ROLE_ID);
when(userRoleRepository.findByRoleId(COACH_ROLE_ID)).thenReturn(Flux.just(userRole1, userRole2));
SysUser coach1 = new SysUser();
coach1.setId(COACH_USER_ID);
coach1.setUsername("coach1");
coach1.setNickname("教练1");
coach1.setDeletedAt(null);
SysUser coach2 = new SysUser();
coach2.setId(10002L);
coach2.setUsername("coach2");
coach2.setNickname("教练2");
coach2.setDeletedAt(null);
when(userRepository.findById(COACH_USER_ID)).thenReturn(Mono.just(coach1));
when(userRepository.findById(10002L)).thenReturn(Mono.just(coach2));
StepVerifier.create(coachCourseService.getAllCoaches())
.expectNext(coach1, coach2)
.verifyComplete();
verify(roleRepository).findByRoleName("教练");
verify(userRoleRepository).findByRoleId(COACH_ROLE_ID);
verify(userRepository).findById(COACH_USER_ID);
verify(userRepository).findById(10002L);
}
@Test
void getAllCoaches_shouldReturnEmptyWhenNoCoachRole() {
when(roleRepository.findByRoleName("教练")).thenReturn(Mono.empty());
// getCoachRoleId() 抛出异常,getAllCoaches 会传播该错误
// 但实际业务中教练角色应当存在,此场景属于配置异常
StepVerifier.create(coachCourseService.getAllCoaches())
.expectErrorMatches(e -> e instanceof RuntimeException
&& e.getMessage().contains("教练角色未找到"))
.verify();
}
@Test
void getAllCoaches_shouldReturnEmptyWhenNoUserRoles() {
SysRole role = new SysRole();
role.setId(COACH_ROLE_ID);
when(roleRepository.findByRoleName("教练")).thenReturn(Mono.just(role));
when(userRoleRepository.findByRoleId(COACH_ROLE_ID)).thenReturn(Flux.empty());
StepVerifier.create(coachCourseService.getAllCoaches())
.verifyComplete();
}
@Test
void getAllCoaches_shouldFilterDeletedUsers() {
SysRole role = new SysRole();
role.setId(COACH_ROLE_ID);
when(roleRepository.findByRoleName("教练")).thenReturn(Mono.just(role));
UserRole userRole = new UserRole();
userRole.setUserId(COACH_USER_ID);
when(userRoleRepository.findByRoleId(COACH_ROLE_ID)).thenReturn(Flux.just(userRole));
SysUser coach = new SysUser();
coach.setId(COACH_USER_ID);
coach.setDeletedAt(LocalDateTime.now()); // deleted user
when(userRepository.findById(COACH_USER_ID)).thenReturn(Mono.just(coach));
StepVerifier.create(coachCourseService.getAllCoaches())
.verifyComplete();
}
// ==================== createCoach ====================
@Test
void createCoach_shouldCreateCoachSuccessfully() {
SysRole role = new SysRole();
role.setId(COACH_ROLE_ID);
when(roleRepository.findByRoleName("教练")).thenReturn(Mono.just(role));
String username = "newCoach";
String password = "123456";
String nickname = "新教练";
String email = "coach@test.com";
String phone = "13800138000";
String encodedPassword = "encoded_password";
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
SysUser savedUser = new SysUser();
savedUser.setId(COACH_USER_ID);
savedUser.setUsername(username);
savedUser.setPassword(encodedPassword);
savedUser.setNickname(nickname);
savedUser.setEmail(email);
savedUser.setPhone(phone);
savedUser.setStatus(StatusConstants.ENABLED);
when(userRepository.save(any(SysUser.class))).thenReturn(Mono.just(savedUser));
UserRole savedUserRole = new UserRole();
savedUserRole.setUserId(COACH_USER_ID);
savedUserRole.setRoleId(COACH_ROLE_ID);
when(userRoleRepository.save(any(UserRole.class))).thenReturn(Mono.just(savedUserRole));
StepVerifier.create(coachCourseService.createCoach(username, password, nickname, email, phone))
.assertNext(user -> {
assertThat(user.getId()).isEqualTo(COACH_USER_ID);
assertThat(user.getUsername()).isEqualTo(username);
assertThat(user.getNickname()).isEqualTo(nickname);
assertThat(user.getEmail()).isEqualTo(email);
assertThat(user.getPhone()).isEqualTo(phone);
assertThat(user.getStatus()).isEqualTo(StatusConstants.ENABLED);
})
.verifyComplete();
verify(roleRepository).findByRoleName("教练");
verify(passwordEncoder).encode(password);
verify(userRepository).save(any(SysUser.class));
verify(userRoleRepository).save(any(UserRole.class));
ArgumentCaptor<SysUser> userCaptor = ArgumentCaptor.forClass(SysUser.class);
verify(userRepository).save(userCaptor.capture());
SysUser capturedUser = userCaptor.getValue();
assertThat(capturedUser.getPassword()).isEqualTo(encodedPassword);
ArgumentCaptor<UserRole> userRoleCaptor = ArgumentCaptor.forClass(UserRole.class);
verify(userRoleRepository).save(userRoleCaptor.capture());
assertThat(userRoleCaptor.getValue().getUserId()).isEqualTo(COACH_USER_ID);
assertThat(userRoleCaptor.getValue().getRoleId()).isEqualTo(COACH_ROLE_ID);
}
// ==================== updateCoach ====================
@Test
void updateCoach_shouldUpdateSuccessfully() {
SysUser existingUser = new SysUser();
existingUser.setId(COACH_USER_ID);
existingUser.setUsername("oldCoach");
existingUser.setNickname("旧教练");
existingUser.setEmail("old@test.com");
existingUser.setPhone("13900000000");
when(userRepository.findById(COACH_USER_ID)).thenReturn(Mono.just(existingUser));
SysUser updatedUser = new SysUser();
updatedUser.setId(COACH_USER_ID);
updatedUser.setNickname("新教练");
updatedUser.setEmail("new@test.com");
updatedUser.setPhone("13800138000");
when(userRepository.update(any(SysUser.class))).thenReturn(Mono.just(updatedUser));
StepVerifier.create(coachCourseService.updateCoach(COACH_USER_ID, "新教练", "new@test.com", "13800138000"))
.assertNext(user -> {
assertThat(user.getNickname()).isEqualTo("新教练");
assertThat(user.getEmail()).isEqualTo("new@test.com");
assertThat(user.getPhone()).isEqualTo("13800138000");
})
.verifyComplete();
verify(userRepository).findById(COACH_USER_ID);
verify(userRepository).update(any(SysUser.class));
}
@Test
void updateCoach_shouldThrowWhenCoachNotFound() {
when(userRepository.findById(COACH_USER_ID)).thenReturn(Mono.empty());
StepVerifier.create(coachCourseService.updateCoach(COACH_USER_ID, "nick", "email", "phone"))
.expectErrorMatches(e -> e instanceof RuntimeException
&& e.getMessage().contains("教练不存在"))
.verify();
verify(userRepository).findById(COACH_USER_ID);
verify(userRepository, never()).update(any());
}
// ==================== disableCoach ====================
@Test
void disableCoach_shouldDisableSuccessfully() {
SysUser user = new SysUser();
user.setId(COACH_USER_ID);
user.setStatus(StatusConstants.ENABLED);
when(userRepository.findById(COACH_USER_ID)).thenReturn(Mono.just(user));
when(groupCourseRepository.countByCoachIdAndStatus(COACH_USER_ID, 3L)).thenReturn(Mono.just(0L));
when(groupCourseRepository.cancelCoursesByCoachIdExceptStatus(COACH_USER_ID, 3L)).thenReturn(Mono.just(1));
SysUser updatedUser = new SysUser();
updatedUser.setId(COACH_USER_ID);
updatedUser.setStatus(StatusConstants.DISABLED);
when(userRepository.update(any(SysUser.class))).thenReturn(Mono.just(updatedUser));
when(redisUtil.deleteByPattern("datacount:statistics:*")).thenReturn(Mono.just(1L));
when(redisUtil.deleteByPattern("group_course:*")).thenReturn(Mono.just(1L));
StepVerifier.create(coachCourseService.disableCoach(COACH_USER_ID))
.verifyComplete();
verify(userRepository).findById(COACH_USER_ID);
verify(groupCourseRepository).countByCoachIdAndStatus(COACH_USER_ID, 3L);
verify(groupCourseRepository).cancelCoursesByCoachIdExceptStatus(COACH_USER_ID, 3L);
verify(userRepository).update(any(SysUser.class));
verify(redisUtil).deleteByPattern("datacount:statistics:*");
verify(redisUtil).deleteByPattern("group_course:*");
}
@Test
void disableCoach_shouldThrowWhenCoachNotFound() {
when(userRepository.findById(COACH_USER_ID)).thenReturn(Mono.empty());
// invalidateStatisticsCache() 会在 .then() 参数求值时被调用,需要 stub
lenient().when(redisUtil.deleteByPattern(anyString())).thenReturn(Mono.just(1L));
StepVerifier.create(coachCourseService.disableCoach(COACH_USER_ID))
.expectErrorMatches(e -> e instanceof RuntimeException
&& e.getMessage().contains("教练不存在"))
.verify();
}
@Test
void disableCoach_shouldThrowWhenHasInProgressCourses() {
SysUser user = new SysUser();
user.setId(COACH_USER_ID);
user.setStatus(StatusConstants.ENABLED);
when(userRepository.findById(COACH_USER_ID)).thenReturn(Mono.just(user));
when(groupCourseRepository.countByCoachIdAndStatus(COACH_USER_ID, 3L)).thenReturn(Mono.just(2L));
// invalidateStatisticsCache() 会在 .then() 参数求值时被调用,需要 stub
lenient().when(redisUtil.deleteByPattern(anyString())).thenReturn(Mono.just(1L));
StepVerifier.create(coachCourseService.disableCoach(COACH_USER_ID))
.expectErrorMatches(e -> e instanceof RuntimeException
&& e.getMessage().contains("正在进行中的团课"))
.verify();
verify(userRepository).findById(COACH_USER_ID);
verify(groupCourseRepository).countByCoachIdAndStatus(COACH_USER_ID, 3L);
verify(groupCourseRepository, never()).cancelCoursesByCoachIdExceptStatus(any(), anyLong());
verify(userRepository, never()).update(any());
}
// ==================== getCoachCourses ====================
@Test
void getCoachCourses_shouldReturnCoursesWithBookingCount() {
GroupCourse course1 = new GroupCourse();
course1.setId(1L);
course1.setCourseName("瑜伽课");
course1.setCoachId(COACH_USER_ID);
GroupCourse course2 = new GroupCourse();
course2.setId(2L);
course2.setCourseName("动感单车");
course2.setCoachId(COACH_USER_ID);
when(groupCourseRepository.findByCoachId(eq(COACH_USER_ID), any(Sort.class)))
.thenReturn(Flux.just(course1, course2));
when(bookingRepository.countValidBookings(1L)).thenReturn(Mono.just(3L));
when(bookingRepository.countValidBookings(2L)).thenReturn(Mono.just(5L));
StepVerifier.create(coachCourseService.getCoachCourses(COACH_USER_ID))
.assertNext(course -> {
assertThat(course.getId()).isEqualTo(1L);
assertThat(course.getCurrentMembers()).isEqualTo(3);
})
.assertNext(course -> {
assertThat(course.getId()).isEqualTo(2L);
assertThat(course.getCurrentMembers()).isEqualTo(5);
})
.verifyComplete();
verify(groupCourseRepository).findByCoachId(eq(COACH_USER_ID), any(Sort.class));
verify(bookingRepository).countValidBookings(1L);
verify(bookingRepository).countValidBookings(2L);
}
@Test
void getCoachCourses_shouldReturnEmptyWhenNoCourses() {
when(groupCourseRepository.findByCoachId(eq(COACH_USER_ID), any(Sort.class)))
.thenReturn(Flux.empty());
StepVerifier.create(coachCourseService.getCoachCourses(COACH_USER_ID))
.verifyComplete();
}
// ==================== startCourse ====================
private GroupCourseEntity createCourseEntity(Long id, Long coachId, Long status,
LocalDateTime startTime, LocalDateTime endTime) {
GroupCourseEntity course = new GroupCourseEntity();
course.setId(id);
course.setCoachId(coachId);
course.setStatus(status);
course.setStartTime(startTime);
course.setEndTime(endTime);
return course;
}
private CoachTimeRule createTimeRule(int normalWindow, int lateWindow, int endGrace) {
CoachTimeRule rule = new CoachTimeRule();
rule.setNormalWindow(normalWindow);
rule.setLateWindow(lateWindow);
rule.setEndGrace(endGrace);
return rule;
}
@Test
void startCourse_shouldStartNormally() {
LocalDateTime now = LocalDateTime.now();
GroupCourseEntity course = createCourseEntity(
COURSE_ID, COACH_USER_ID, CourseStatus.NORMAL.getValue(),
now.minusMinutes(5), now.plusMinutes(55));
GroupCourseEntity updatedCourse = createCourseEntity(
COURSE_ID, COACH_USER_ID, CourseStatus.IN_PROGRESS.getValue(),
now.minusMinutes(5), now.plusMinutes(55));
updatedCourse.setActualStartTime(now);
// 第一次调用返回原始课程,第二次调用(doStartCourse 内)返回更新后的课程
when(groupCourseDao.findByIdIsAndDeletedAtIsNull(COURSE_ID))
.thenReturn(Mono.just(course), Mono.just(updatedCourse));
CoachTimeRule rule = createTimeRule(10, 30, 10);
when(timeRuleService.matchRule(60)).thenReturn(Mono.just(rule));
when(groupCourseDao.updateStartInfo(eq(COURSE_ID), eq("3"), any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(Mono.just(1));
when(redisUtil.deleteByPattern("datacount:statistics:*")).thenReturn(Mono.just(1L));
when(redisUtil.deleteByPattern("group_course:*")).thenReturn(Mono.just(1L));
StepVerifier.create(coachCourseService.startCourse(COURSE_ID, COACH_USER_ID))
.assertNext(entity -> {
assertThat(entity.getStatus()).isEqualTo(CourseStatus.IN_PROGRESS.getValue());
assertThat(entity.getActualStartTime()).isNotNull();
})
.verifyComplete();
verify(groupCourseDao, times(2)).findByIdIsAndDeletedAtIsNull(COURSE_ID);
verify(timeRuleService).matchRule(60);
verify(groupCourseDao).updateStartInfo(eq(COURSE_ID), eq("3"), any(LocalDateTime.class), any(LocalDateTime.class));
}
@Test
void startCourse_shouldStartLateWithViolation() {
LocalDateTime now = LocalDateTime.now();
// 课程开始时间在 normalWindow(10) 之后、lateWindow(30) 之内
GroupCourseEntity course = createCourseEntity(
COURSE_ID, COACH_USER_ID, CourseStatus.NORMAL.getValue(),
now.minusMinutes(15), now.plusMinutes(45));
GroupCourseEntity updatedCourse = createCourseEntity(
COURSE_ID, COACH_USER_ID, CourseStatus.COACH_LATE.getValue(),
now.minusMinutes(15), now.plusMinutes(45));
updatedCourse.setActualStartTime(now);
when(groupCourseDao.findByIdIsAndDeletedAtIsNull(COURSE_ID))
.thenReturn(Mono.just(course), Mono.just(updatedCourse));
CoachTimeRule rule = createTimeRule(10, 30, 10);
when(timeRuleService.matchRule(60)).thenReturn(Mono.just(rule));
// mock recordViolation via DatabaseClient
DatabaseClient.GenericExecuteSpec executeSpec = mock(DatabaseClient.GenericExecuteSpec.class, Mockito.RETURNS_SELF);
when(databaseClient.sql(anyString())).thenReturn(executeSpec);
when(executeSpec.then()).thenReturn(Mono.empty());
when(groupCourseDao.updateStartInfo(eq(COURSE_ID), eq("7"), any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(Mono.just(1));
when(redisUtil.deleteByPattern("datacount:statistics:*")).thenReturn(Mono.just(1L));
when(redisUtil.deleteByPattern("group_course:*")).thenReturn(Mono.just(1L));
StepVerifier.create(coachCourseService.startCourse(COURSE_ID, COACH_USER_ID))
.assertNext(entity -> {
assertThat(entity.getStatus()).isEqualTo(CourseStatus.COACH_LATE.getValue());
assertThat(entity.getActualStartTime()).isNotNull();
})
.verifyComplete();
verify(databaseClient).sql(anyString());
verify(groupCourseDao).updateStartInfo(eq(COURSE_ID), eq("7"), any(LocalDateTime.class), any(LocalDateTime.class));
}
@Test
void startCourse_shouldThrowWhenBeforeStartTime() {
LocalDateTime now = LocalDateTime.now();
// 课程开始时间在未来
GroupCourseEntity course = createCourseEntity(
COURSE_ID, COACH_USER_ID, CourseStatus.NORMAL.getValue(),
now.plusMinutes(10), now.plusMinutes(70));
when(groupCourseDao.findByIdIsAndDeletedAtIsNull(COURSE_ID)).thenReturn(Mono.just(course));
CoachTimeRule rule = createTimeRule(10, 30, 10);
when(timeRuleService.matchRule(60)).thenReturn(Mono.just(rule));
StepVerifier.create(coachCourseService.startCourse(COURSE_ID, COACH_USER_ID))
.expectErrorMatches(e -> e instanceof RuntimeException
&& e.getMessage().contains("课程尚未到开课时间"))
.verify();
}
@Test
void startCourse_shouldThrowWhenPastLateWindow() {
LocalDateTime now = LocalDateTime.now();
// 课程开始时间超过 lateWindow(30)
GroupCourseEntity course = createCourseEntity(
COURSE_ID, COACH_USER_ID, CourseStatus.NORMAL.getValue(),
now.minusMinutes(35), now.plusMinutes(25));
when(groupCourseDao.findByIdIsAndDeletedAtIsNull(COURSE_ID)).thenReturn(Mono.just(course));
CoachTimeRule rule = createTimeRule(10, 30, 10);
when(timeRuleService.matchRule(60)).thenReturn(Mono.just(rule));
StepVerifier.create(coachCourseService.startCourse(COURSE_ID, COACH_USER_ID))
.expectErrorMatches(e -> e instanceof RuntimeException
&& e.getMessage().contains("已超过开课时间"))
.verify();
}
@Test
void startCourse_shouldThrowWhenNotCoach() {
LocalDateTime now = LocalDateTime.now();
GroupCourseEntity course = createCourseEntity(
COURSE_ID, OTHER_COACH_ID, CourseStatus.NORMAL.getValue(),
now.minusMinutes(5), now.plusMinutes(55));
when(groupCourseDao.findByIdIsAndDeletedAtIsNull(COURSE_ID)).thenReturn(Mono.just(course));
StepVerifier.create(coachCourseService.startCourse(COURSE_ID, COACH_USER_ID))
.expectErrorMatches(e -> e instanceof RuntimeException
&& e.getMessage().contains("您不是该课程的教练"))
.verify();
}
@Test
void startCourse_shouldThrowWhenCourseNotFound() {
when(groupCourseDao.findByIdIsAndDeletedAtIsNull(COURSE_ID)).thenReturn(Mono.empty());
StepVerifier.create(coachCourseService.startCourse(COURSE_ID, COACH_USER_ID))
.expectErrorMatches(e -> e instanceof RuntimeException
&& e.getMessage().contains("团课不存在"))
.verify();
}
@Test
void startCourse_shouldThrowWhenStatusNotNormal() {
LocalDateTime now = LocalDateTime.now();
GroupCourseEntity course = createCourseEntity(
COURSE_ID, COACH_USER_ID, CourseStatus.IN_PROGRESS.getValue(),
now.minusMinutes(5), now.plusMinutes(55));
when(groupCourseDao.findByIdIsAndDeletedAtIsNull(COURSE_ID)).thenReturn(Mono.just(course));
StepVerifier.create(coachCourseService.startCourse(COURSE_ID, COACH_USER_ID))
.expectErrorMatches(e -> e instanceof RuntimeException
&& e.getMessage().contains("当前课程状态不允许开课"))
.verify();
}
// ==================== endCourse ====================
@Test
void endCourse_shouldEndSuccessfully() {
LocalDateTime now = LocalDateTime.now();
GroupCourseEntity course = createCourseEntity(
COURSE_ID, COACH_USER_ID, CourseStatus.IN_PROGRESS.getValue(),
now.minusMinutes(60), now.minusMinutes(5));
GroupCourseEntity updatedCourse = createCourseEntity(
COURSE_ID, COACH_USER_ID, CourseStatus.ENDED.getValue(),
now.minusMinutes(60), now.minusMinutes(5));
updatedCourse.setActualEndTime(now);
when(groupCourseDao.findByIdIsAndDeletedAtIsNull(COURSE_ID))
.thenReturn(Mono.just(course), Mono.just(updatedCourse));
CoachTimeRule rule = createTimeRule(10, 30, 10);
when(timeRuleService.matchRule(55)).thenReturn(Mono.just(rule));
when(groupCourseDao.updateEndInfo(eq(COURSE_ID), eq("2"), any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(Mono.just(1));
when(redisUtil.deleteByPattern("datacount:statistics:*")).thenReturn(Mono.just(1L));
when(redisUtil.deleteByPattern("group_course:*")).thenReturn(Mono.just(1L));
StepVerifier.create(coachCourseService.endCourse(COURSE_ID, COACH_USER_ID))
.assertNext(entity -> {
assertThat(entity.getStatus()).isEqualTo(CourseStatus.ENDED.getValue());
assertThat(entity.getActualEndTime()).isNotNull();
})
.verifyComplete();
verify(groupCourseDao, times(2)).findByIdIsAndDeletedAtIsNull(COURSE_ID);
verify(timeRuleService).matchRule(55);
verify(groupCourseDao).updateEndInfo(eq(COURSE_ID), eq("2"), any(LocalDateTime.class), any(LocalDateTime.class));
}
@Test
void endCourse_shouldThrowWhenStatusNotInProgressOrLate() {
LocalDateTime now = LocalDateTime.now();
GroupCourseEntity course = createCourseEntity(
COURSE_ID, COACH_USER_ID, CourseStatus.NORMAL.getValue(),
now.minusMinutes(60), now.minusMinutes(5));
when(groupCourseDao.findByIdIsAndDeletedAtIsNull(COURSE_ID)).thenReturn(Mono.just(course));
StepVerifier.create(coachCourseService.endCourse(COURSE_ID, COACH_USER_ID))
.expectErrorMatches(e -> e instanceof RuntimeException
&& e.getMessage().contains("当前课程状态不允许结课"))
.verify();
}
@Test
void endCourse_shouldThrowWhenPastEndGrace() {
LocalDateTime now = LocalDateTime.now();
// 课程结束时间已超过 endGrace(10) 分钟
GroupCourseEntity course = createCourseEntity(
COURSE_ID, COACH_USER_ID, CourseStatus.IN_PROGRESS.getValue(),
now.minusMinutes(120), now.minusMinutes(15));
when(groupCourseDao.findByIdIsAndDeletedAtIsNull(COURSE_ID)).thenReturn(Mono.just(course));
CoachTimeRule rule = createTimeRule(10, 30, 10);
when(timeRuleService.matchRule(105)).thenReturn(Mono.just(rule));
StepVerifier.create(coachCourseService.endCourse(COURSE_ID, COACH_USER_ID))
.expectErrorMatches(e -> e instanceof RuntimeException
&& e.getMessage().contains("已超过结课时间"))
.verify();
}
@Test
void endCourse_shouldThrowWhenNotCoach() {
LocalDateTime now = LocalDateTime.now();
GroupCourseEntity course = createCourseEntity(
COURSE_ID, OTHER_COACH_ID, CourseStatus.IN_PROGRESS.getValue(),
now.minusMinutes(60), now.minusMinutes(5));
when(groupCourseDao.findByIdIsAndDeletedAtIsNull(COURSE_ID)).thenReturn(Mono.just(course));
StepVerifier.create(coachCourseService.endCourse(COURSE_ID, COACH_USER_ID))
.expectErrorMatches(e -> e instanceof RuntimeException
&& e.getMessage().contains("您不是该课程的教练"))
.verify();
}
@Test
void endCourse_shouldThrowWhenCourseNotFound() {
when(groupCourseDao.findByIdIsAndDeletedAtIsNull(COURSE_ID)).thenReturn(Mono.empty());
StepVerifier.create(coachCourseService.endCourse(COURSE_ID, COACH_USER_ID))
.expectErrorMatches(e -> e instanceof RuntimeException
&& e.getMessage().contains("团课不存在"))
.verify();
}
@Test
void endCourse_shouldEndWhenStatusCoachLate() {
LocalDateTime now = LocalDateTime.now();
GroupCourseEntity course = createCourseEntity(
COURSE_ID, COACH_USER_ID, CourseStatus.COACH_LATE.getValue(),
now.minusMinutes(60), now.minusMinutes(5));
GroupCourseEntity updatedCourse = createCourseEntity(
COURSE_ID, COACH_USER_ID, CourseStatus.ENDED.getValue(),
now.minusMinutes(60), now.minusMinutes(5));
updatedCourse.setActualEndTime(now);
when(groupCourseDao.findByIdIsAndDeletedAtIsNull(COURSE_ID))
.thenReturn(Mono.just(course), Mono.just(updatedCourse));
CoachTimeRule rule = createTimeRule(10, 30, 10);
when(timeRuleService.matchRule(55)).thenReturn(Mono.just(rule));
when(groupCourseDao.updateEndInfo(eq(COURSE_ID), eq("2"), any(LocalDateTime.class), any(LocalDateTime.class))).thenReturn(Mono.just(1));
when(redisUtil.deleteByPattern("datacount:statistics:*")).thenReturn(Mono.just(1L));
when(redisUtil.deleteByPattern("group_course:*")).thenReturn(Mono.just(1L));
StepVerifier.create(coachCourseService.endCourse(COURSE_ID, COACH_USER_ID))
.assertNext(entity -> {
assertThat(entity.getStatus()).isEqualTo(CourseStatus.ENDED.getValue());
})
.verifyComplete();
}
// ==================== recordViolation ====================
@Test
void recordViolation_shouldRecordSuccessfully() {
LocalDateTime now = LocalDateTime.now();
DatabaseClient.GenericExecuteSpec executeSpec = mock(DatabaseClient.GenericExecuteSpec.class, Mockito.RETURNS_SELF);
when(databaseClient.sql(anyString())).thenReturn(executeSpec);
when(executeSpec.then()).thenReturn(Mono.empty());
StepVerifier.create(coachCourseService.recordViolation(
COACH_USER_ID, COURSE_ID, now, ViolationReason.COACH_LATE))
.verifyComplete();
verify(databaseClient).sql(anyString());
verify(executeSpec).bind("coachId", COACH_USER_ID);
verify(executeSpec).bind("courseId", COURSE_ID);
verify(executeSpec).bind("violationTime", now);
verify(executeSpec).bind("reason", ViolationReason.COACH_LATE.getValue());
}
// ==================== getViolationCounts ====================
@Test
@SuppressWarnings("unchecked")
void getViolationCounts_shouldReturnCounts() {
DatabaseClient.GenericExecuteSpec executeSpec = mock(DatabaseClient.GenericExecuteSpec.class, Mockito.RETURNS_SELF);
FetchSpec<Map<String, Object>> fetchSpec = mock(FetchSpec.class);
when(databaseClient.sql(anyString())).thenReturn(executeSpec);
when(executeSpec.fetch()).thenReturn(fetchSpec);
when(fetchSpec.all()).thenReturn(Flux.just(
Map.of("coach_id", COACH_USER_ID, "count", 3L),
Map.of("coach_id", 10002L, "count", 1L)
));
StepVerifier.create(coachCourseService.getViolationCounts())
.expectNextMatches(map -> map.get("coach_id").equals(COACH_USER_ID) && map.get("count").equals(3L))
.expectNextMatches(map -> map.get("coach_id").equals(10002L) && map.get("count").equals(1L))
.verifyComplete();
verify(databaseClient).sql(anyString());
}
@Test
@SuppressWarnings("unchecked")
void getViolationCounts_shouldReturnEmptyWhenNoViolations() {
DatabaseClient.GenericExecuteSpec executeSpec = mock(DatabaseClient.GenericExecuteSpec.class, Mockito.RETURNS_SELF);
FetchSpec<Map<String, Object>> fetchSpec = mock(FetchSpec.class);
when(databaseClient.sql(anyString())).thenReturn(executeSpec);
when(executeSpec.fetch()).thenReturn(fetchSpec);
when(fetchSpec.all()).thenReturn(Flux.empty());
StepVerifier.create(coachCourseService.getViolationCounts())
.verifyComplete();
}
// ==================== getCoachViolations ====================
@Test
@SuppressWarnings("unchecked")
void getCoachViolations_shouldReturnViolations() {
DatabaseClient.GenericExecuteSpec executeSpec = mock(DatabaseClient.GenericExecuteSpec.class, Mockito.RETURNS_SELF);
FetchSpec<Map<String, Object>> fetchSpec = mock(FetchSpec.class);
when(databaseClient.sql(anyString())).thenReturn(executeSpec);
when(executeSpec.fetch()).thenReturn(fetchSpec);
when(fetchSpec.all()).thenReturn(Flux.just(
Map.of("id", 1L, "coach_id", COACH_USER_ID, "violation_reason", "COACH_LATE", "course_name", "瑜伽课"),
Map.of("id", 2L, "coach_id", COACH_USER_ID, "violation_reason", "COACH_ABSENT", "course_name", "动感单车")
));
StepVerifier.create(coachCourseService.getCoachViolations(COACH_USER_ID))
.expectNextCount(2)
.verifyComplete();
verify(databaseClient).sql(anyString());
verify(executeSpec).bind("coachId", COACH_USER_ID);
}
@Test
@SuppressWarnings("unchecked")
void getCoachViolations_shouldReturnEmptyWhenNoViolations() {
DatabaseClient.GenericExecuteSpec executeSpec = mock(DatabaseClient.GenericExecuteSpec.class, Mockito.RETURNS_SELF);
FetchSpec<Map<String, Object>> fetchSpec = mock(FetchSpec.class);
when(databaseClient.sql(anyString())).thenReturn(executeSpec);
when(executeSpec.fetch()).thenReturn(fetchSpec);
when(fetchSpec.all()).thenReturn(Flux.empty());
StepVerifier.create(coachCourseService.getCoachViolations(COACH_USER_ID))
.verifyComplete();
}
}