zjk
2026-09-29 54dcf0a82e7490fa58b1c056d5c0b9cb739c2931
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
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;
    }
}