完成自动化测试套件实施(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:
+17
-9
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+125
@@ -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);
|
||||
}
|
||||
}
|
||||
+470
@@ -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);
|
||||
}
|
||||
}
|
||||
+803
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user