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<LiveSessionQuestion> 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.<LiveSessionQuestion>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<LiveSession> page =
|
new com.baomidou.mybatisplus.extension.plugins.pagination.Page<LiveSession>(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.<LiveSessionQuestion>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<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) {
|
LiveSessionQuestionRequest questionRequest = new LiveSessionQuestionRequest();
|
questionRequest.setSort(sort);
|
questionRequest.setStem(stem);
|
questionRequest.setOptionA("A");
|
questionRequest.setOptionB("B");
|
questionRequest.setOptionC("C");
|
questionRequest.setCorrectOption("A");
|
return questionRequest;
|
}
|
}
|