import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { getDataSourceToken } from '@nestjs/typeorm'; import { BadRequestException, ValidationPipe } from '@nestjs/common'; import { Repository } from 'typeorm'; import { AttendanceService } from './attendance.service'; import { AttendanceRecord } from '../entities/attendance-record.entity'; import { AttendanceSession } from '../entities/attendance-session.entity'; import { AttendanceDevice } from '../entities/attendance-device.entity'; import { AttendancePeriodConfig } from '../entities/attendance-period-config.entity'; import { DingAttendanceRaw } from '../entities/ding-attendance-raw.entity'; import { Class } from '../entities/class.entity'; import { Student } from '../entities/student.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; import { ClassStudent } from '../entities/class-student.entity'; import { StudentDingMapping } from '../entities/student-ding-mapping.entity'; import { ClassTeacher } from '../entities/class-teacher.entity'; import { BatchCreateAttendanceDto } from './dto/attendance.dto'; describe('AttendanceService — batchCreate', () => { let service: AttendanceService; let attendanceRepo: jest.Mocked, 'create' | 'save'>>; const savedRecords: AttendanceRecord[] = []; beforeEach(async () => { savedRecords.length = 0; const mockRepo = { create: jest .fn() .mockImplementation((data: Partial) => ({ id: 1, ...data } as AttendanceRecord)), save: jest .fn() .mockImplementation((entities: AttendanceRecord[]) => { const result = entities.map((e, i) => ({ ...e, id: i + 1 })); savedRecords.push(...result); return Promise.resolve(result); }), }; const mockDingRepo = {}; const mockClassRepo = { find: jest.fn().mockResolvedValue([]) }; const mockStudentRepo = { find: jest.fn().mockResolvedValue([]) }; // Reserved for future tests (auto-match, schedule-based attendance, etc.) const mockScheduleRepo = { find: jest.fn().mockResolvedValue([]) }; const mockClassStudentRepo = { find: jest.fn().mockResolvedValue([]) }; const mockStudentDingMappingRepo = { find: jest.fn().mockResolvedValue([]) }; const mockAttendanceDeviceRepo = { find: jest.fn().mockResolvedValue([]) }; const module: TestingModule = await Test.createTestingModule({ providers: [ AttendanceService, { provide: getRepositoryToken(AttendanceRecord), useValue: mockRepo }, { provide: getRepositoryToken(DingAttendanceRaw), useValue: mockDingRepo }, { provide: getRepositoryToken(Class), useValue: mockClassRepo }, { provide: getRepositoryToken(Student), useValue: mockStudentRepo }, { provide: getRepositoryToken(ClassSchedule), useValue: mockScheduleRepo }, { provide: getRepositoryToken(StudentDingMapping), useValue: mockStudentDingMappingRepo }, { provide: getRepositoryToken(ClassStudent), useValue: mockClassStudentRepo }, { provide: getRepositoryToken(ClassTeacher), useValue: { findOne: jest.fn() } }, { provide: getRepositoryToken(AttendanceSession), useValue: {} }, { provide: getRepositoryToken(AttendanceDevice), useValue: mockAttendanceDeviceRepo }, { provide: getRepositoryToken(AttendancePeriodConfig), useValue: {} }, { provide: getDataSourceToken(), useValue: { transaction: jest.fn() } }, ], }).compile(); service = module.get(AttendanceService); attendanceRepo = module.get(getRepositoryToken(AttendanceRecord)); }); it('valid batch with morning_reading, evening_study, and night_check sessions → succeeds', async () => { const dto: BatchCreateAttendanceDto = { records: [ { studentId: 1, classId: 10, attendanceDate: '2026-07-05', session: 'morning_reading', status: 'present' }, { studentId: 2, classId: 10, attendanceDate: '2026-07-05', session: 'evening_study', status: 'present' }, { studentId: 3, classId: 10, attendanceDate: '2026-07-05', session: 'night_check', status: 'present' }, ], }; const result = await service.batchCreate(dto); expect(result.count).toBe(3); expect(result.records).toHaveLength(3); expect(result.records[0].session).toBe('morning_reading'); expect(result.records[1].session).toBe('evening_study'); expect(result.records[2].session).toBe('night_check'); }); it('invalid session → validation error (DTO-level)', async () => { const pipe = new ValidationPipe({ whitelist: true }); const invalidPayload = { records: [ { studentId: 1, attendanceDate: '2026-07-05', session: 'invalid_session', status: 'present' }, ], }; await expect( pipe.transform(invalidPayload, { type: 'body', metatype: BatchCreateAttendanceDto, }), ).rejects.toThrow(); }); it('empty batch → BadRequestException', async () => { const dto: BatchCreateAttendanceDto = { records: [] }; await expect(service.batchCreate(dto)).rejects.toThrow(BadRequestException); }); it.skip('autoMatchDingRecords with StudentDingMapping chain', async () => { // TODO: match dingtalk raw records to students via StudentDingMapping lookup, // then to class schedules → ClassStudent association, producing attendance records. // Requires mock setup for StudentDingMapping, ClassSchedule, ClassStudent, and DingAttendanceRaw repos. }); }); describe('AttendanceService — teacher DingTalk class scope', () => { const classTeacherRepo = { findOne: jest.fn(), find: jest.fn(), }; const classStudentRepo = { find: jest.fn(), }; const mappingRepo = { find: jest.fn(), }; const createService = () => new AttendanceService( {} as never, {} as never, {} as never, {} as never, {} as never, classStudentRepo as never, mappingRepo as never, classTeacherRepo as never, {} as never, {} as never, {} as never, {} as never, ); beforeEach(() => { jest.clearAllMocks(); }); it('returns only mapped active students for a class assigned to the teacher', async () => { classTeacherRepo.findOne.mockResolvedValue({ classId: 8, userId: 21 }); classStudentRepo.find.mockResolvedValue([ { studentId: 2 }, { studentId: 1 }, { studentId: 2 }, ]); mappingRepo.find.mockResolvedValue([ { studentId: 1, dingUserId: 'ding-1' }, { studentId: 2, dingUserId: 'ding-2' }, ]); await expect(createService().getTeacherClassDingUserIds(21, 8, false)).resolves.toEqual([ 'ding-1', 'ding-2', ]); }); it('lists distinct classes assigned to a teacher', async () => { classTeacherRepo.find.mockResolvedValue([ { classId: 8, class: { name: '八班' } }, { classId: 8, class: { name: '八班' } }, { classId: 9, class: { name: '九班' } }, ]); await expect(createService().getImportableClasses(21, false)).resolves.toEqual([ { classId: 8, className: '八班' }, { classId: 9, className: '九班' }, ]); expect(classTeacherRepo.find).toHaveBeenCalledWith({ where: { userId: 21 }, relations: ['class'], }); }); it('rejects a class that is not assigned to the teacher', async () => { classTeacherRepo.findOne.mockResolvedValue(null); await expect(createService().getTeacherClassDingUserIds(21, 99, false)).rejects.toBeInstanceOf( BadRequestException, ); }); }); describe('AttendanceService — DingTalk raw query', () => { it('returns the paginated shape and filters by class student mappings', async () => { const qb = { leftJoinAndSelect: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), addOrderBy: jest.fn().mockReturnThis(), skip: jest.fn().mockReturnThis(), take: jest.fn().mockReturnThis(), getManyAndCount: jest.fn().mockResolvedValue([[{ id: 1 }], 1]), }; const dingRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) }; const classStudentRepo = { find: jest.fn().mockResolvedValue([{ studentId: 3 }]) }; const mappingRepo = { find: jest.fn().mockResolvedValue([{ studentId: 3, dingUserId: 'ding-3' }]) }; const service = new AttendanceService( {} as never, dingRepo as never, {} as never, {} as never, {} as never, classStudentRepo as never, mappingRepo as never, {} as never, {} as never, {} as never, {} as never, {} as never, ); await expect( service.getDingRaw({ classId: 8, dateFrom: '2026-07-01', page: 2, pageSize: 10 }), ).resolves.toEqual({ list: [{ id: 1 }], total: 1, page: 2, pageSize: 10 }); expect(qb.andWhere).toHaveBeenCalledWith('ar.dingUserId IN (:...dingUserIds)', { dingUserIds: ['ding-3'], }); expect(qb.andWhere).toHaveBeenCalledWith('ar.attendanceDate >= :dateFrom', { dateFrom: '2026-07-01', }); expect(qb.skip).toHaveBeenCalledWith(10); expect(qb.take).toHaveBeenCalledWith(10); }); }); describe('AttendanceService — attendance device display mappings', () => { function createHistoryQueryBuilder(records: AttendanceRecord[]) { return { leftJoinAndSelect: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), addOrderBy: jest.fn().mockReturnThis(), skip: jest.fn().mockReturnThis(), take: jest.fn().mockReturnThis(), getManyAndCount: jest.fn().mockResolvedValue([records, records.length]), getMany: jest.fn().mockResolvedValue(records), }; } function createServiceWithRecords(records: AttendanceRecord[]) { const qb = createHistoryQueryBuilder(records); const attendanceRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) }; const attendanceDeviceRepo = { find: jest.fn().mockImplementation(async (options: { where?: Record }) => { if (options.where && 'deviceSn' in options.where) { return [ { id: 1, deviceSn: 'ATM-01', deviceName: '东门考勤机', classroomId: 8, classroom: { id: 8, name: '一号教室' }, status: 'disabled', }, ]; } return []; }), }; const service = new AttendanceService( attendanceRepo as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, attendanceDeviceRepo as never, {} as never, {} as never, ); return { service, qb, attendanceDeviceRepo }; } it('maps history list punch device ids to configured attendance device names', async () => { const record = { id: 1, classId: 8, status: 'present', source: 'dingtalk', punchSource: 'ATM', punchDeviceId: 'ATM-01', punchDeviceName: '钉钉原始设备名', } as AttendanceRecord; const { service, attendanceDeviceRepo } = createServiceWithRecords([record]); await expect(service.findAll({}, [8])).resolves.toMatchObject({ list: [ { punchDeviceId: 'ATM-01', punchDeviceName: '东门考勤机 · 一号教室', }, ], total: 1, }); expect(attendanceDeviceRepo.find).toHaveBeenCalledWith({ where: { deviceSn: expect.any(Object) }, relations: ['classroom'], }); }); it('maps exported punch device ids to configured attendance device names', async () => { const record = { id: 2, classId: 8, status: 'present', source: 'dingtalk', punchSource: 'ATM', punchDeviceId: 'ATM-01', punchDeviceName: '钉钉原始设备名', } as AttendanceRecord; const { service } = createServiceWithRecords([record]); await expect(service.findAllForExport({}, [8])).resolves.toEqual([ expect.objectContaining({ punchDeviceId: 'ATM-01', punchDeviceName: '东门考勤机 · 一号教室', }), ]); }); }); // ── Session serialization tests ── function deferred(): { promise: Promise; resolve: (value: T | PromiseLike) => void; reject: (reason?: unknown) => void; } { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } describe('AttendanceService — session serialization', () => { const sessionId = 100; const recordId = 200; const sessionInProgress = { id: sessionId, status: 'in_progress' }; const sessionCompleted = { id: sessionId, status: 'completed' }; const record: Record = { id: recordId, attendanceSessionId: sessionId, studentId: 1, status: 'present', source: 'dingtalk', attendanceDate: '2026-07-01', session: 'morning', }; function makeService( dataSourceMock: { transaction: jest.Mock }, attendanceRepoOverrides?: Record, ) { const defaultAttendanceRepo = { findOne: jest.fn().mockResolvedValue(record), find: jest.fn().mockResolvedValue([record]), save: jest.fn().mockImplementation((r: unknown) => Promise.resolve(r)), remove: jest.fn().mockResolvedValue(undefined), create: jest.fn(), createQueryBuilder: jest.fn(), }; const attendanceRepo = { ...defaultAttendanceRepo, ...attendanceRepoOverrides }; return new AttendanceService( attendanceRepo as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, { find: jest.fn().mockResolvedValue([]) } as never, {} as never, dataSourceMock as never, ); } function makeTxManager(sessionStatus: string) { const session = sessionStatus === 'completed' ? { ...sessionCompleted } : { ...sessionInProgress }; const sessionRepo = { findOne: jest.fn().mockResolvedValue(session), save: jest.fn().mockImplementation((s: unknown) => Promise.resolve(s)), }; const recordRepo = { findOne: jest.fn().mockResolvedValue(record), find: jest.fn().mockResolvedValue([record]), count: jest.fn().mockResolvedValue(0), save: jest.fn().mockImplementation((r: unknown) => Promise.resolve(r)), remove: jest.fn().mockResolvedValue(undefined), }; return { getRepository: jest.fn((entity: unknown) => { if (entity === AttendanceSession) return sessionRepo; if (entity === AttendanceRecord) return recordRepo; throw new Error('Unexpected entity'); }), sessionRepo, recordRepo, }; } it('complete holds lock; queued update is rejected after session becomes completed', async () => { // For this test, the manager has a completed session // The complete callback is stalled, update queues and then finds completed const manager = makeTxManager('completed'); const completeStall = deferred(); const txMock = jest .fn() .mockImplementationOnce(() => completeStall.promise) .mockImplementationOnce((cb: (m: unknown) => unknown) => cb(manager)); const svc = makeService({ transaction: txMock }); const completeP = svc.completeLessonAttendance(sessionId, 1); const updateP = svc.update(recordId, { status: 'absent' }); completeStall.resolve({ session: sessionCompleted, records: [record] }); await expect(completeP).resolves.toEqual({ session: sessionCompleted, records: [record] }); await expect(updateP).rejects.toThrow(BadRequestException); expect(manager.sessionRepo.findOne).toHaveBeenCalled(); }); it('update holds lock; complete reads current state after update finishes', async () => { const manager = makeTxManager('in_progress'); const updateStall = deferred(); // Track call order const callOrder: string[] = []; let transactionsRun = 0; const txMock = jest.fn().mockImplementation((cb: (m: unknown) => unknown) => { transactionsRun++; if (transactionsRun === 1) { // update: stall callOrder.push('update-tx-started'); return updateStall.promise.then((v) => { callOrder.push('update-tx-resolved'); return v; }); } // complete: actually run the callback callOrder.push('complete-tx-started'); return cb(manager); }); const svc = makeService({ transaction: txMock }); // Start update but DON'T await — it will stall const updateP = svc.update(recordId, { status: 'absent' }); // Give update time to enter the mutex and transaction await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); // Start complete while update is stalled const completeP = svc.completeLessonAttendance(sessionId, 1); // Another tick await Promise.resolve(); // Resolve update's stalled transaction updateStall.resolve(record); const updateResult = await updateP; expect(updateResult).toEqual(record); const completeResult = await completeP; // Verify the callback was actually run correctly expect(callOrder).toContain('update-tx-started'); expect(callOrder).toContain('complete-tx-started'); expect(completeResult).toHaveProperty('session'); expect(completeResult).toHaveProperty('records'); }); it('records without attendanceSessionId bypass the mutex and keep original behaviour', async () => { const noSessionRecord = { ...record, attendanceSessionId: null }; const overrides = { findOne: jest.fn().mockResolvedValue(noSessionRecord), save: jest.fn().mockImplementation((r: unknown) => Promise.resolve(r)), }; const txMock = jest.fn(); const svc = makeService({ transaction: txMock }, overrides); const result = await svc.update(recordId, { status: 'absent' }); expect(result).toEqual(noSessionRecord); expect(txMock).not.toHaveBeenCalled(); const removeResult = await svc.remove(recordId); expect(removeResult).toEqual({ deleted: true }); }); it('complete is idempotent: returns current state when session already completed', async () => { const manager = makeTxManager('completed'); const txMock = jest .fn() .mockImplementation((cb: (m: unknown) => unknown) => cb(manager)); const svc = makeService({ transaction: txMock }); const result = await svc.completeLessonAttendance(sessionId, 1); expect(result).toHaveProperty('session'); expect(result).toHaveProperty('records'); expect(manager.recordRepo.count).not.toHaveBeenCalled(); }); });