Files
gongxue-base/apps/server/src/attendance/attendance.lesson-session.spec.ts

712 lines
25 KiB
TypeScript

import { BadRequestException, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { AttendanceService } from './attendance.service';
import { AttendanceSession } from '../entities/attendance-session.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
const createService = () => {
const attendanceRepo = {
create: jest.fn((value: Record<string, unknown>) => value),
find: jest.fn(),
findOne: jest.fn(),
save: jest.fn(async (records: unknown) => records),
remove: jest.fn(async (record: unknown) => record),
count: jest.fn(),
};
const dingRawRepo = { find: jest.fn() };
const scheduleRepo = { findOne: jest.fn() };
const classStudentRepo = { find: jest.fn() };
const sessionRepo = {
findOne: jest.fn(),
create: jest.fn((value: Record<string, unknown>) => ({ id: 90, ...value })),
save: jest.fn(async (value: unknown) => value),
};
const attendanceDeviceRepo = { find: jest.fn().mockResolvedValue([]) };
const dataSource = {
transaction: jest.fn(
async (cb: (manager: { getRepository: jest.Mock }) => Promise<unknown>) => {
const managerGetRepo = jest.fn((entity: { name: string }) => {
if (entity.name === AttendanceSession.name) return sessionRepo;
if (entity.name === AttendanceRecord.name) return attendanceRepo;
return {};
});
return cb({ getRepository: managerGetRepo });
},
),
};
const service = new AttendanceService(
attendanceRepo as never,
dingRawRepo as never,
{} as never,
{} as never,
scheduleRepo as never,
classStudentRepo as never,
{} as never,
{} as never,
sessionRepo as never,
attendanceDeviceRepo as never,
{} as never,
dataSource as unknown as DataSource,
);
return {
service,
attendanceRepo,
dingRawRepo,
scheduleRepo,
classStudentRepo,
sessionRepo,
attendanceDeviceRepo,
dataSource,
};
};
const endedSchedule = {
id: 4,
classId: 8,
weekDay: 6,
startTime: '09:00',
endTime: '10:00',
startDate: '2026-07-01',
endDate: '2026-07-31',
subject: '\u6570\u5B66',
status: 'active',
scheduleType: 'INTERNAL',
attendanceAdvanceMinutes: 30,
};
describe('AttendanceService \u2014 DingTalk course attendance', () => {
it('creates one course session from DingTalk results after the lesson', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
{ studentId: 3, student: { id: 3, name: '\u738B\u4E94' } },
{ studentId: 4, student: { id: 4, name: '\u8D75\u516D' } },
]);
dingRawRepo.find.mockResolvedValue([
{
matchedStudentId: 1,
attendanceType: 'OnDuty',
timeResult: 'Normal',
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
},
{
matchedStudentId: 2,
attendanceType: 'OnDuty',
timeResult: 'Late',
checkInTime: new Date('2026-07-11T09:05:00+08:00'),
},
{ matchedStudentId: 3, attendanceType: 'OnDuty', timeResult: 'NotSigned' },
]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
expect(sessionRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'in_progress',
}),
);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({
studentId: 1,
status: 'present',
source: 'dingtalk',
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
punchTime: new Date('2026-07-11T08:55:00+08:00'),
}),
expect.objectContaining({ studentId: 2, status: 'present', source: 'dingtalk' }),
expect.objectContaining({ studentId: 3, status: 'pending', source: 'dingtalk' }),
expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }),
]);
expect(result.records).toHaveLength(4);
});
it('finalizes missing punches as absent and completes the session', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
dingRawRepo.find.mockResolvedValue([]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, status: 'absent' }),
]);
expect(sessionRepo.save).toHaveBeenLastCalledWith(
expect.objectContaining({ status: 'completed', completedBy: 21 }),
);
expect(result.session.status).toBe('completed');
});
it('returns student relations after the first pull', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
dingRawRepo.find.mockResolvedValue([]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ student: { id: 1, name: '张三' } }),
]);
expect(result.records[0].student.name).toBe('张三');
});
it('uses the DingTalk punch nearest to this lesson start when a student has multiple shifts', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
]);
dingRawRepo.find.mockResolvedValue([
{
matchedStudentId: 1,
attendanceType: 'OnDuty',
timeResult: 'Late',
checkInTime: new Date('2026-07-11T02:00:00+08:00'),
},
{
matchedStudentId: 1,
attendanceType: 'OnDuty',
timeResult: 'Normal',
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
},
]);
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, status: 'present' }),
]);
});
it('counts both OnDuty and OffDuty punches only inside the configured window', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue({ ...endedSchedule, attendanceAdvanceMinutes: 20 });
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
{ studentId: 2, student: { id: 2, name: '李四' } },
{ studentId: 3, student: { id: 3, name: '王五' } },
]);
dingRawRepo.find.mockResolvedValue([
{
matchedStudentId: 1,
attendanceType: 'OffDuty',
checkOutTime: new Date('2026-07-11T08:40:00+08:00'),
},
{
matchedStudentId: 2,
attendanceType: 'OffDuty',
checkOutTime: new Date('2026-07-11T10:00:00+08:00'),
},
{
matchedStudentId: 3,
attendanceType: 'OnDuty',
checkInTime: new Date('2026-07-11T08:39:59+08:00'),
},
{
matchedStudentId: 3,
attendanceType: 'OffDuty',
checkOutTime: new Date('2026-07-11T10:00:01+08:00'),
},
]);
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, status: 'present' }),
expect.objectContaining({ studentId: 2, status: 'present' }),
expect.objectContaining({ studentId: 3, status: 'absent' }),
]);
});
it('expands import dates when the pre-class window crosses midnight', () => {
const { service } = createService();
expect(
service.getLessonAttendanceImportDateRange(
{ startTime: '00:15', endTime: '01:00', attendanceAdvanceMinutes: 30 },
'2026-07-11',
),
).toEqual({ startDate: '2026-07-10', endDate: '2026-07-11' });
});
it('creates local attendance after the lesson starts', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
const now = new Date();
const weekDay = now.getDay() === 0 ? 7 : now.getDay();
scheduleRepo.findOne.mockResolvedValue({
...endedSchedule,
weekDay,
startTime: '00:00',
endTime: '23:59',
startDate: '2026-01-01',
endDate: '2026-12-31',
});
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
dingRawRepo.find.mockResolvedValue([]);
const today = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
].join('-');
const result = await service.createLessonAttendanceFromDingTalk(4, today, 21);
expect(attendanceRepo.save).toHaveBeenCalled();
expect(result.records).toHaveLength(1);
});
it('refreshes a completed session when DingTalk punches arrive late', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'completed',
});
attendanceRepo.find.mockResolvedValue([
{ id: 1, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'dingtalk' },
]);
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
dingRawRepo.find.mockResolvedValue([
{
matchedStudentId: 1,
attendanceType: 'OnDuty',
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
},
]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, status: 'present', remark: null }),
]);
expect(sessionRepo.save).not.toHaveBeenCalled();
expect(result.session.status).toBe('completed');
});
it('refreshes an in_progress session from latest DingTalk data', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'in_progress',
});
attendanceRepo.find.mockResolvedValue([
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'dingtalk' },
{ id: 102, studentId: 2, attendanceSessionId: 90, status: 'present', source: 'dingtalk' },
]);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
]);
dingRawRepo.find.mockResolvedValue([
{
matchedStudentId: 1,
attendanceType: 'OnDuty',
timeResult: 'Normal',
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
},
{
matchedStudentId: 2,
attendanceType: 'OnDuty',
timeResult: 'Late',
checkInTime: new Date('2026-07-11T09:05:00+08:00'),
},
]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
expect(sessionRepo.create).not.toHaveBeenCalled();
expect(attendanceRepo.save).toHaveBeenCalled();
const callArgs = (attendanceRepo.save as jest.Mock).mock.calls[0];
const savedRecords = callArgs[0] as Array<{ studentId: number; status: string }>;
expect(savedRecords).toEqual(
expect.arrayContaining([
expect.objectContaining({ studentId: 1, status: 'present' }),
expect.objectContaining({ studentId: 2, status: 'present' }),
]),
);
expect(result.records).toHaveLength(2);
});
it('restores students missing from an existing empty session', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'in_progress',
});
attendanceRepo.find.mockResolvedValue([]);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
{ studentId: 2, student: { id: 2, name: '李四' } },
]);
dingRawRepo.find.mockResolvedValue([]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, student: { id: 1, name: '张三' } }),
expect.objectContaining({ studentId: 2, student: { id: 2, name: '李四' } }),
]);
expect(result.records).toHaveLength(2);
});
it('preserves manually corrected records when refreshing an in_progress session', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'in_progress',
});
attendanceRepo.find.mockResolvedValue([
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'leave', source: 'manual' },
{ id: 102, studentId: 2, attendanceSessionId: 90, status: 'present', source: 'dingtalk' },
]);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
]);
dingRawRepo.find.mockResolvedValue([
{
matchedStudentId: 1,
attendanceType: 'OnDuty',
timeResult: 'Late',
checkInTime: new Date('2026-07-11T09:05:00+08:00'),
},
{
matchedStudentId: 2,
attendanceType: 'OnDuty',
timeResult: 'Late',
checkInTime: new Date('2026-07-11T09:05:00+08:00'),
},
]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
const callArgs = (attendanceRepo.save as jest.Mock).mock.calls[0];
const savedRecords = callArgs[0] as Array<{ studentId: number; status: string }>;
expect(savedRecords).toEqual(
expect.arrayContaining([
expect.objectContaining({ studentId: 1, status: 'leave' }),
expect.objectContaining({ studentId: 2, status: 'present' }),
]),
);
expect(result.records).toHaveLength(2);
});
it('final settlement overrides interim manual status using the final punch result', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'in_progress',
});
attendanceRepo.find.mockResolvedValue([
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'present', source: 'manual' },
]);
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
dingRawRepo.find.mockResolvedValue([]);
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, status: 'absent' }),
]);
});
it('rejects completion when pending records exist', async () => {
const { service, sessionRepo, attendanceRepo } = createService();
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
status: 'in_progress',
});
attendanceRepo.count.mockResolvedValue(1);
await expect(service.completeLessonAttendance(90, 21)).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('completes a pulled attendance session after teacher review', async () => {
const { service, sessionRepo, attendanceRepo } = createService();
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
status: 'in_progress',
});
attendanceRepo.count.mockResolvedValue(0);
attendanceRepo.find.mockResolvedValue([
{ id: 1, status: 'present' },
{ id: 2, status: 'absent' },
]);
const result = await service.completeLessonAttendance(90, 21);
expect(sessionRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ id: 90, status: 'completed', completedBy: 21 }),
);
expect(result.session.status).toBe('completed');
});
it('throws when querying a missing schedule attendance session', async () => {
const { service, scheduleRepo } = createService();
scheduleRepo.findOne.mockResolvedValue(null);
await expect(service.getLessonAttendance(999, '2026-07-11')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('update() marks record source as manual so refresh preserves the correction', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'in_progress',
});
// Initial state: dingtalk-sourced record
attendanceRepo.findOne.mockResolvedValue({
id: 101,
studentId: 1,
attendanceSessionId: 90,
status: 'present',
source: 'dingtalk',
});
attendanceRepo.find.mockResolvedValue([
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'manual' },
{ id: 102, studentId: 2, attendanceSessionId: 90, status: 'present', source: 'dingtalk' },
]);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
{ studentId: 2, student: { id: 2, name: '李四' } },
]);
dingRawRepo.find.mockResolvedValue([
{
matchedStudentId: 1,
attendanceType: 'OnDuty',
timeResult: 'Normal',
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
},
{
matchedStudentId: 2,
attendanceType: 'OnDuty',
timeResult: 'Normal',
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
},
]);
// Step 1: update the record to absent via generic update()
const updated = await service.update(101, { status: 'absent' });
expect(updated.source).toBe('manual');
// Step 2: refresh in_progress session — manual record status must stay absent
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
const savedRecords = (attendanceRepo.save as jest.Mock).mock.calls[
(attendanceRepo.save as jest.Mock).mock.calls.length - 1
][0] as Array<{ studentId: number; status: string }>;
expect(savedRecords).toEqual(
expect.arrayContaining([
expect.objectContaining({ studentId: 1, status: 'absent' }),
expect.objectContaining({ studentId: 2, status: 'present' }),
]),
);
});
it('recovers from concurrent unique constraint on first session creation', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne
.mockResolvedValueOnce(null) // first check: no existing session
.mockResolvedValueOnce({
// recovery: the winning session
id: 77,
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'in_progress',
});
// Simulate unique constraint on save
sessionRepo.save.mockRejectedValueOnce(
Object.assign(new Error('UNIQUE constraint failed'), {
code: 'SQLITE_CONSTRAINT',
errno: undefined,
}),
);
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
dingRawRepo.find.mockResolvedValue([]);
attendanceRepo.find.mockResolvedValue([
{ id: 201, studentId: 1, attendanceSessionId: 77, status: 'present', source: 'dingtalk' },
]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
expect(result.session.id).toBe(77);
expect(result.records).toHaveLength(1);
});
it('completeLessonAttendance runs inside a transaction', async () => {
const { service, sessionRepo, attendanceRepo, dataSource } = createService();
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
status: 'in_progress',
});
attendanceRepo.count.mockResolvedValue(0);
attendanceRepo.find.mockResolvedValue([{ id: 1, status: 'present' }]);
await service.completeLessonAttendance(90, 21);
expect(dataSource.transaction).toHaveBeenCalled();
});
it('update() throws when the parent session is completed', async () => {
const { service, attendanceRepo, sessionRepo } = createService();
attendanceRepo.findOne.mockResolvedValue({
id: 101,
attendanceSessionId: 90,
status: 'present',
});
sessionRepo.findOne.mockResolvedValue({
id: 90,
status: 'completed',
});
await expect(service.update(101, { status: 'absent' })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('remove() throws when the parent session is completed', async () => {
const { service, attendanceRepo, sessionRepo } = createService();
attendanceRepo.findOne.mockResolvedValue({
id: 101,
attendanceSessionId: 90,
});
sessionRepo.findOne.mockResolvedValue({
id: 90,
status: 'completed',
});
await expect(service.remove(101)).rejects.toBeInstanceOf(BadRequestException);
});
it('update() allows modification when the parent session is in_progress', async () => {
const { service, attendanceRepo, sessionRepo } = createService();
attendanceRepo.findOne.mockResolvedValue({
id: 101,
attendanceSessionId: 90,
status: 'present',
});
sessionRepo.findOne.mockResolvedValue({
id: 90,
status: 'in_progress',
});
const result = await service.update(101, { status: 'absent' });
expect(result.source).toBe('manual');
});
});
describe('AttendanceService — attendance window boundaries', () => {
it('crosses calendar boundaries only when the window requires it', () => {
const { service } = createService();
expect(
service.getLessonAttendanceImportDateRange(
{ startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 30 },
'2026-07-13',
),
).toEqual({ startDate: '2026-07-13', endDate: '2026-07-13' });
expect(
service.getLessonAttendanceImportDateRange(
{ startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 31 },
'2026-07-13',
),
).toEqual({ startDate: '2026-07-12', endDate: '2026-07-13' });
expect(
service.getLessonAttendanceImportDateRange(
{ startTime: '22:00', endTime: '01:00', attendanceAdvanceMinutes: 30 },
'2026-07-13',
),
).toEqual({ startDate: '2026-07-13', endDate: '2026-07-14' });
});
it('uses Asia/Shanghai time when deciding whether todays lesson has started', async () => {
const originalTz = process.env.TZ;
process.env.TZ = 'UTC';
jest.useFakeTimers().setSystemTime(new Date('2026-07-13T01:00:00.000Z'));
try {
const { service, scheduleRepo, sessionRepo, attendanceRepo, dingRawRepo, classStudentRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue({
...endedSchedule,
weekDay: 1,
startTime: '08:30',
endTime: '10:00',
startDate: '2026-07-13',
endDate: '2026-07-13',
});
sessionRepo.findOne.mockResolvedValue({ id: 90, status: 'completed' });
attendanceRepo.find.mockResolvedValue([]);
dingRawRepo.find.mockResolvedValue([]);
classStudentRepo.find.mockResolvedValue([]);
await expect(
service.createLessonAttendanceFromDingTalk(4, '2026-07-13', 21),
).resolves.toMatchObject({ records: [] });
} finally {
jest.useRealTimers();
process.env.TZ = originalTz;
}
});
});