map = new HashMap<>();
+ map.put("qrContent", QR_CONTENT);
+ map.put("isUsed", isUsed);
+ map.put("memberId", MEMBER_ID);
+ return map;
+ }
+
+ private SignInRecord createMockSignInRecord() {
+ return SignInRecord.builder()
+ .id(1L)
+ .memberId(MEMBER_ID)
+ .memberCardId(1L)
+ .signInTime(LocalDateTime.now())
+ .signInType("QR_CODE")
+ .signInStatus("SUCCESS")
+ .source("MINI_PROGRAM")
+ .isDelete(false)
+ .build();
+ }
+
+ private GroupCourseBooking createValidBooking() {
+ GroupCourseBooking booking = new GroupCourseBooking();
+ booking.setId(100L);
+ booking.setMemberId(MEMBER_ID);
+ booking.setCourseId(200L);
+ booking.setStatus("0");
+ booking.setCourseStartTime(LocalDateTime.now().plusMinutes(5));
+ booking.setCourseEndTime(LocalDateTime.now().plusMinutes(65));
+ return booking;
+ }
+}
\ No newline at end of file
diff --git a/gym-manage-api/gym-coach/pom.xml b/gym-manage-api/gym-coach/pom.xml
index 0e04e11..940ee46 100644
--- a/gym-manage-api/gym-coach/pom.xml
+++ b/gym-manage-api/gym-coach/pom.xml
@@ -63,5 +63,10 @@
lombok
provided
+
+ io.projectreactor
+ reactor-test
+ test
+
diff --git a/gym-manage-api/gym-coach/src/main/java/cn/novalon/gym/manage/coach/scheduler/CoachCourseScheduler.java b/gym-manage-api/gym-coach/src/main/java/cn/novalon/gym/manage/coach/scheduler/CoachCourseScheduler.java
index 0eff9cb..09250c9 100644
--- a/gym-manage-api/gym-coach/src/main/java/cn/novalon/gym/manage/coach/scheduler/CoachCourseScheduler.java
+++ b/gym-manage-api/gym-coach/src/main/java/cn/novalon/gym/manage/coach/scheduler/CoachCourseScheduler.java
@@ -164,16 +164,24 @@ public class CoachCourseScheduler {
}
/**
- * 清除统计缓存和团课缓存 —— 调度器触发时,如有课程状态变更则必须及时失效
+ * 清除统计缓存和团课缓存 —— 调度器触发时,如有课程状态变更则必须及时失效。
+ *
+ * 注意:测试环境中 RedisUtil 可能被 Mock 返回 null,需做 null 安全处理。
*/
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 statsMono = redisUtil.deleteByPattern("datacount:statistics:*");
+ if (statsMono != null) {
+ statsMono.subscribe(
+ deleted -> logger.debug("调度器清除统计缓存,已删除 {} 条", deleted),
+ error -> logger.warn("调度器清除统计缓存失败: {}", error.getMessage())
+ );
+ }
+ Mono courseMono = redisUtil.deleteByPattern("group_course:*");
+ if (courseMono != null) {
+ courseMono.subscribe(
+ deleted -> logger.debug("调度器清除团课缓存,已删除 {} 条", deleted),
+ error -> logger.warn("调度器清除团课缓存失败: {}", error.getMessage())
+ );
+ }
}
}
diff --git a/gym-manage-api/gym-coach/src/test/java/cn/novalon/gym/manage/coach/handler/CoachCourseHandlerTest.java b/gym-manage-api/gym-coach/src/test/java/cn/novalon/gym/manage/coach/handler/CoachCourseHandlerTest.java
new file mode 100644
index 0000000..4974bb5
--- /dev/null
+++ b/gym-manage-api/gym-coach/src/test/java/cn/novalon/gym/manage/coach/handler/CoachCourseHandlerTest.java
@@ -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 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 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 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 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);
+ }
+}
\ No newline at end of file
diff --git a/gym-manage-api/gym-coach/src/test/java/cn/novalon/gym/manage/coach/scheduler/CoachCourseSchedulerTest.java b/gym-manage-api/gym-coach/src/test/java/cn/novalon/gym/manage/coach/scheduler/CoachCourseSchedulerTest.java
new file mode 100644
index 0000000..00d5e74
--- /dev/null
+++ b/gym-manage-api/gym-coach/src/test/java/cn/novalon/gym/manage/coach/scheduler/CoachCourseSchedulerTest.java
@@ -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 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 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 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 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 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 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 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 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 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);
+ }
+}
\ No newline at end of file
diff --git a/gym-manage-api/gym-coach/src/test/java/cn/novalon/gym/manage/coach/service/CoachCourseServiceTest.java b/gym-manage-api/gym-coach/src/test/java/cn/novalon/gym/manage/coach/service/CoachCourseServiceTest.java
new file mode 100644
index 0000000..ffcc8c1
--- /dev/null
+++ b/gym-manage-api/gym-coach/src/test/java/cn/novalon/gym/manage/coach/service/CoachCourseServiceTest.java
@@ -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 userCaptor = ArgumentCaptor.forClass(SysUser.class);
+ verify(userRepository).save(userCaptor.capture());
+ SysUser capturedUser = userCaptor.getValue();
+ assertThat(capturedUser.getPassword()).isEqualTo(encodedPassword);
+
+ ArgumentCaptor 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