| | |
| | | import org.junit.jupiter.api.BeforeEach; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.junit.jupiter.api.extension.ExtendWith; |
| | | import org.junit.jupiter.params.ParameterizedTest; |
| | | import org.junit.jupiter.params.provider.ValueSource; |
| | | import org.mockito.ArgumentCaptor; |
| | | import org.mockito.Captor; |
| | | import org.mockito.Mock; |
| | |
| | | |
| | | @Captor |
| | | private ArgumentCaptor<LiveSessionQuestion> questionCaptor; |
| | | @Mock |
| | | private LiveRemindDispatchService liveRemindDispatchService; |
| | | |
| | | /** |
| | | * 每个用例前重建被测服务。 |
| | |
| | | liveCourseMapper, |
| | | liveSessionMapper, |
| | | liveSessionQuestionMapper, |
| | | liveReserveMapper |
| | | liveReserveMapper, |
| | | liveRemindDispatchService |
| | | ); |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | /** |
| | | * 同课程时间线已推进到后续场次时,应将历史残留的直播中场次收敛为已结束。 |
| | | * 仅后续直播中场次能替换当前直播,已结束的历史记录不能挤掉它。 |
| | | */ |
| | | @Test |
| | | void reconcileCourseLivingSessionsShouldCloseHistoricalLivingSession() { |
| | | @ParameterizedTest |
| | | @ValueSource(ints = {1, 2}) |
| | | void reconcileCourseLivingSessionsShouldOnlyCloseForNewerLivingSession(int latestStatus) { |
| | | LiveSession staleLivingSession = new LiveSession(); |
| | | staleLivingSession.setId(90001L); |
| | | staleLivingSession.setLiveCourseId(9000L); |
| | | staleLivingSession.setLiveStatus(1); |
| | | staleLivingSession.setStartTime(LocalDateTime.of(2026, 4, 8, 10, 0)); |
| | | |
| | | LiveSession latestEndedSession = new LiveSession(); |
| | | latestEndedSession.setId(90002L); |
| | | latestEndedSession.setLiveCourseId(9000L); |
| | | latestEndedSession.setLiveStatus(2); |
| | | latestEndedSession.setStartTime(LocalDateTime.of(2026, 4, 9, 10, 0)); |
| | | latestEndedSession.setActualEndTime(LocalDateTime.of(2026, 4, 9, 10, 30)); |
| | | LiveSession latestSession = new LiveSession(); |
| | | latestSession.setId(90002L); |
| | | latestSession.setLiveCourseId(9000L); |
| | | latestSession.setLiveStatus(latestStatus); |
| | | latestSession.setStartTime(LocalDateTime.of(2026, 4, 9, 10, 0)); |
| | | if (latestStatus == 2) { |
| | | latestSession.setActualEndTime(LocalDateTime.of(2026, 4, 9, 10, 30)); |
| | | } |
| | | |
| | | when(liveSessionMapper.selectList(any())).thenReturn(Arrays.asList(staleLivingSession, latestEndedSession)); |
| | | when(liveSessionMapper.selectList(any())).thenReturn(Arrays.asList(staleLivingSession, latestSession)); |
| | | |
| | | assertEquals(1, liveSessionAppService.reconcileCourseLivingSessions(9000L, LocalDateTime.of(2026, 4, 9, 10, 35))); |
| | | int repairedCount = liveSessionAppService.reconcileCourseLivingSessions( |
| | | 9000L, LocalDateTime.of(2026, 4, 9, 10, 35)); |
| | | if (latestStatus == 2) { |
| | | assertEquals(0, repairedCount); |
| | | verify(liveSessionMapper, never()).updateById(any(LiveSession.class)); |
| | | return; |
| | | } |
| | | assertEquals(1, repairedCount); |
| | | verify(liveSessionMapper).updateById(org.mockito.ArgumentMatchers.argThat(session -> |
| | | session != null |
| | | && Long.valueOf(90001L).equals(session.getId()) |
| | |
| | | } |
| | | |
| | | /** |
| | | * 模拟 DATETIME 按秒四舍五入,覆盖半秒边界及跨秒、跨日进位。 |
| | | */ |
| | | @ParameterizedTest |
| | | @ValueSource(ints = {0, 100000000, 499999999, 500000000, 800000000, 999999999}) |
| | | void autoStartShouldRemainLivingWhenDatabaseRoundsTime(int nanos) { |
| | | LiveSession session = mockPendingSessionWithDatabaseRounding(); |
| | | LocalDateTime startTime = LocalDateTime.of(2026, 9, 22, 23, 59, 59, nanos); |
| | | session.setStartTime(startTime.withNano(0)); |
| | | |
| | | LiveSessionStatusVO result = liveSessionAppService.autoStart(session.getId(), startTime); |
| | | |
| | | assertEquals(Integer.valueOf(1), result.getLiveStatus()); |
| | | assertEquals(startTime.withNano(0), result.getActualStartTime()); |
| | | assertNull(result.getActualEndTime()); |
| | | verify(liveSessionMapper, never()).updateById(any(LiveSession.class)); |
| | | verify(liveSessionMapper).markLivingIfPending(session.getId(), startTime.withNano(0)); |
| | | } |
| | | |
| | | /** |
| | | * 人工提前开播也应经过相同的秒级归一化,而不是被未来的计划时间误结束。 |
| | | */ |
| | | @Test |
| | | void manualStartShouldUseSecondPrecisionBeforePlannedTime() { |
| | | LiveSession session = mockPendingSessionWithDatabaseRounding(); |
| | | session.setStartTime(LocalDateTime.now().plusHours(1)); |
| | | LiveCourse course = new LiveCourse(); |
| | | course.setId(session.getLiveCourseId()); |
| | | when(liveCourseMapper.selectById(course.getId())).thenReturn(course); |
| | | |
| | | LiveSessionStatusVO result = liveSessionAppService.start(course.getId(), session.getId()); |
| | | |
| | | ArgumentCaptor<LocalDateTime> timeCaptor = ArgumentCaptor.forClass(LocalDateTime.class); |
| | | verify(liveSessionMapper).markLivingIfPending( |
| | | org.mockito.ArgumentMatchers.eq(session.getId()), timeCaptor.capture()); |
| | | assertEquals(0, timeCaptor.getValue().getNano()); |
| | | assertEquals(Integer.valueOf(1), result.getLiveStatus()); |
| | | assertNull(result.getActualEndTime()); |
| | | verify(liveSessionMapper, never()).updateById(any(LiveSession.class)); |
| | | } |
| | | |
| | | private LiveSession mockPendingSessionWithDatabaseRounding() { |
| | | LiveSession session = new LiveSession(); |
| | | session.setId(90008L); |
| | | session.setLiveCourseId(9000L); |
| | | session.setLiveStatus(0); |
| | | session.setDurationSeconds(385); |
| | | when(liveSessionMapper.selectById(session.getId())).thenReturn(session); |
| | | when(liveSessionMapper.selectList(any())).thenReturn(Collections.singletonList(session)); |
| | | when(liveSessionMapper.markLivingIfPending( |
| | | org.mockito.ArgumentMatchers.eq(session.getId()), any(LocalDateTime.class))) |
| | | .thenAnswer(invocation -> { |
| | | LocalDateTime input = invocation.getArgument(1); |
| | | // 保留与 MySQL 秒精度写入一致的进位模型,不依赖真实数据库。 |
| | | LocalDateTime persisted = input.withNano(0); |
| | | if (input.getNano() >= 500000000) { |
| | | persisted = persisted.plusSeconds(1); |
| | | } |
| | | session.setActualStartTime(persisted); |
| | | session.setLiveStatus(1); |
| | | return 1; |
| | | }); |
| | | return session; |
| | | } |
| | | |
| | | /** |
| | | * 构造题目请求。 |
| | | */ |
| | | private LiveSessionQuestionRequest buildQuestion(int sort, String stem) { |