package com.tcm.training.admin.live.service; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.tcm.training.admin.live.dto.LiveSessionQuestionRequest; import com.tcm.training.admin.live.dto.LiveSessionUpsertRequest; import com.tcm.training.admin.live.entity.LiveCourse; import com.tcm.training.admin.live.entity.LiveSession; import com.tcm.training.admin.live.entity.LiveSessionQuestion; import com.tcm.training.admin.live.mapper.LiveCourseMapper; import com.tcm.training.admin.live.mapper.LiveReserveMapper; import com.tcm.training.admin.live.mapper.LiveSessionMapper; import com.tcm.training.admin.live.mapper.LiveSessionQuestionMapper; import com.tcm.training.admin.live.vo.LiveSessionVO; import com.tcm.training.admin.live.vo.LiveSessionStatusVO; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.junit.jupiter.api.BeforeAll; 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; import org.mockito.junit.jupiter.MockitoExtension; import java.time.LocalDateTime; import java.util.Arrays; import java.util.Collections; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** * 直播场次服务测试。 */ @ExtendWith(MockitoExtension.class) class LiveSessionAppServiceTest { @BeforeAll static void initLambdaCache() { MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); TableInfoHelper.initTableInfo(assistant, LiveCourse.class); TableInfoHelper.initTableInfo(assistant, LiveSession.class); TableInfoHelper.initTableInfo(assistant, LiveSessionQuestion.class); } @Mock private LiveCourseMapper liveCourseMapper; @Mock private LiveSessionMapper liveSessionMapper; @Mock private LiveSessionQuestionMapper liveSessionQuestionMapper; @Mock private LiveReserveMapper liveReserveMapper; private LiveSessionAppService liveSessionAppService; @Captor private ArgumentCaptor questionCaptor; @Mock private LiveRemindDispatchService liveRemindDispatchService; /** * 每个用例前重建被测服务。 */ @BeforeEach void setUp() { liveSessionAppService = new LiveSessionAppService( liveCourseMapper, liveSessionMapper, liveSessionQuestionMapper, liveReserveMapper, liveRemindDispatchService ); } /** * 编辑场次题目时应先物理删除旧题,避免逻辑删除记录触发唯一索引冲突。 */ @Test void updateSessionShouldHardDeleteQuestionsBeforeReinsert() { LiveCourse course = new LiveCourse(); course.setId(9000L); LiveSession session = new LiveSession(); session.setId(90008L); session.setLiveCourseId(9000L); session.setLiveStatus(0); session.setStartTime(LocalDateTime.now().plusDays(1)); when(liveCourseMapper.selectById(9000L)).thenReturn(course); when(liveSessionMapper.selectById(90008L)).thenReturn(session); when(liveSessionQuestionMapper.selectList(any())).thenReturn(Collections.emptyList()); LiveSessionUpsertRequest request = new LiveSessionUpsertRequest(); request.setStartTime(LocalDateTime.now().plusDays(2)); request.setVideoUrl("https://cdn.example/live.m3u8"); request.setDurationSeconds(1800); request.setBaseReserveCount(12); request.setBaseWatchCount(8); request.setQuestions(Arrays.asList( buildQuestion(1, "题目一"), buildQuestion(2, "题目二"), buildQuestion(3, "题目三") )); liveSessionAppService.updateSession(9000L, 90008L, request); verify(liveSessionQuestionMapper).hardDeleteBySessionId(90008L); verify(liveSessionQuestionMapper, never()).delete(any()); verify(liveSessionMapper).updateById(org.mockito.ArgumentMatchers.argThat(updatedSession -> updatedSession != null && Long.valueOf(90008L).equals(updatedSession.getId()) && Integer.valueOf(1800).equals(updatedSession.getDurationSeconds()))); verify(liveSessionQuestionMapper, org.mockito.Mockito.times(3)).insert(questionCaptor.capture()); assertEquals(3, questionCaptor.getAllValues().size()); assertEquals(Long.valueOf(90008L), questionCaptor.getAllValues().get(0).getSessionId()); assertEquals(Integer.valueOf(1), questionCaptor.getAllValues().get(0).getSort()); assertTrue(questionCaptor.getAllValues().stream().allMatch(item -> Long.valueOf(90008L).equals(item.getSessionId()))); } /** * 查询场次列表时应回传视频时长,供管理后台回显。 */ @Test void pageSessionsShouldIncludeDurationSeconds() { LiveCourse course = new LiveCourse(); course.setId(9000L); LiveSession session = new LiveSession(); session.setId(90008L); session.setLiveCourseId(9000L); session.setStartTime(LocalDateTime.of(2026, 4, 9, 19, 30)); session.setVideoUrl("https://cdn.example/live.mp4"); session.setDurationSeconds(2400); session.setLiveStatus(0); session.setBaseReserveCount(10); session.setBaseWatchCount(5); com.baomidou.mybatisplus.extension.plugins.pagination.Page page = new com.baomidou.mybatisplus.extension.plugins.pagination.Page(1, 10); page.setRecords(Collections.singletonList(session)); page.setTotal(1); when(liveCourseMapper.selectById(9000L)).thenReturn(course); when(liveSessionMapper.selectPage(any(), any())).thenReturn(page); when(liveSessionQuestionMapper.selectList(any())).thenReturn(Collections.emptyList()); LiveSessionVO sessionVO = liveSessionAppService.pageSessions(9000L, 1, 10, null, null, null).getList().get(0); assertEquals(Integer.valueOf(2400), sessionVO.getDurationSeconds()); } /** * 手动开播时应复用公共状态迁移逻辑,并返回数据库中的最新开播时间。 */ @Test void startShouldMarkPendingSessionAsLiving() { LiveCourse course = new LiveCourse(); course.setId(9000L); LiveSession session = new LiveSession(); session.setId(90008L); session.setLiveCourseId(9000L); session.setLiveStatus(0); LiveSession startedSession = new LiveSession(); startedSession.setId(90008L); startedSession.setLiveCourseId(9000L); startedSession.setLiveStatus(1); startedSession.setActualStartTime(LocalDateTime.of(2026, 4, 9, 10, 0)); when(liveCourseMapper.selectById(9000L)).thenReturn(course); when(liveSessionMapper.selectById(90008L)).thenReturn(session, startedSession); when(liveSessionMapper.markLivingIfPending(org.mockito.ArgumentMatchers.eq(90008L), any(LocalDateTime.class))).thenReturn(1); LiveSessionStatusVO statusVO = liveSessionAppService.start(9000L, 90008L); assertEquals(Integer.valueOf(1), statusVO.getLiveStatus()); assertEquals(LocalDateTime.of(2026, 4, 9, 10, 0), statusVO.getActualStartTime()); } /** * 自动开播在状态已变化时应静默跳过,避免定时任务重复报错。 */ @Test void autoStartShouldReturnNullWhenStateTransitionFails() { LiveSession session = new LiveSession(); session.setId(90008L); session.setLiveCourseId(9000L); session.setLiveStatus(1); when(liveSessionMapper.selectById(90008L)).thenReturn(session); when(liveSessionMapper.markLivingIfPending(org.mockito.ArgumentMatchers.eq(90008L), any(LocalDateTime.class))).thenReturn(0); assertNull(liveSessionAppService.autoStart(90008L, LocalDateTime.of(2026, 4, 9, 10, 5))); } /** * 自动开播后应以后置查询结果为准,避免覆盖数据库中已有的实际开播时间。 */ @Test void autoStartShouldUsePersistedActualStartTime() { LiveSession session = new LiveSession(); session.setId(90008L); session.setLiveCourseId(9000L); session.setLiveStatus(0); LiveSession startedSession = new LiveSession(); startedSession.setId(90008L); startedSession.setLiveCourseId(9000L); startedSession.setLiveStatus(1); startedSession.setActualStartTime(LocalDateTime.of(2026, 4, 9, 9, 55)); when(liveSessionMapper.selectById(90008L)).thenReturn(session, startedSession); when(liveSessionMapper.markLivingIfPending(org.mockito.ArgumentMatchers.eq(90008L), any(LocalDateTime.class))).thenReturn(1); assertEquals( LocalDateTime.of(2026, 4, 9, 9, 55), liveSessionAppService.autoStart(90008L, LocalDateTime.of(2026, 4, 9, 10, 0)).getActualStartTime() ); } /** * 仅后续直播中场次能替换当前直播,已结束的历史记录不能挤掉它。 */ @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 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, latestSession)); 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()) && Integer.valueOf(2).equals(session.getLiveStatus()) && LocalDateTime.of(2026, 4, 9, 10, 35).equals(session.getActualEndTime()))); } /** * 模拟 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 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) { LiveSessionQuestionRequest questionRequest = new LiveSessionQuestionRequest(); questionRequest.setSort(sort); questionRequest.setStem(stem); questionRequest.setOptionA("A"); questionRequest.setOptionB("B"); questionRequest.setOptionC("C"); questionRequest.setCorrectOption("A"); return questionRequest; } }