import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { Subject } from 'rxjs'; import { AttendanceController } from './attendance.controller'; import { AttendanceService } from './attendance.service'; import { AttendanceImportService } from './attendance-import.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; describe('AttendanceController — DingTalk import scope', () => { const attendanceService = { getTeacherClassDingUserIds: jest.fn(), getImportableClasses: jest.fn(), }; const importService = { importFromDingTalk: jest.fn(), progress$: undefined as unknown, }; const logService = { log: jest.fn(), }; const authzService = { can: jest.fn().mockReturnValue(false), }; let controller: AttendanceController; beforeEach(() => { jest.clearAllMocks(); controller = new AttendanceController( attendanceService as unknown as AttendanceService, importService as unknown as AttendanceImportService, logService as unknown as OperationLogsService, authzService as never, ); importService.importFromDingTalk.mockResolvedValue({ success: true, imported: 0, skipped: 0, matched: 0, errors: [], duration: 1, }); }); it('defaults teacher DingTalk import to today when no date range is provided', async () => { jest.useFakeTimers().setSystemTime(new Date('2026-07-10T08:00:00.000Z')); attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-today']); await controller.importFromDingTalk({ classId: 8 }, { user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false }, } as never); expect(importService.importFromDingTalk).toHaveBeenCalledWith({ startDate: '2026-07-10', endDate: '2026-07-10', userIds: ['ding-today'], autoMatch: true, userId: 21, }); jest.useRealTimers(); }); it('uses only the selected class students mapped to DingTalk for a teacher import', async () => { attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-1', 'ding-2']); await controller.importFromDingTalk( { start: '2026-07-01', end: '2026-07-02', classId: 8 }, { user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never, ); expect(importService.importFromDingTalk).toHaveBeenCalledWith({ startDate: '2026-07-01', endDate: '2026-07-02', userIds: ['ding-1', 'ding-2'], autoMatch: true, userId: 21, }); }); it('does not allow a teacher to supply arbitrary DingTalk user IDs', async () => { await expect( controller.importFromDingTalk( { start: '2026-07-01', end: '2026-07-02', users: 'someone-else' }, { user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never, ), ).rejects.toBeInstanceOf(ForbiddenException); expect(importService.importFromDingTalk).not.toHaveBeenCalled(); }); it('requires teachers to select one of their classes', async () => { await expect( controller.importFromDingTalk({ start: '2026-07-01', end: '2026-07-02' }, { user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false }, } as never), ).rejects.toBeInstanceOf(BadRequestException); }); it('lists only classes available to the current user for DingTalk import', async () => { attendanceService.getImportableClasses.mockResolvedValue([{ classId: 8, className: '八班' }]); await expect( controller.getDingTalkImportClasses({ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false }, } as never), ).resolves.toEqual([{ classId: 8, className: '八班' }]); expect(attendanceService.getImportableClasses).toHaveBeenCalledWith(21, false); }); it('always auto-matches class-scoped imports', async () => { attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-1']); await controller.importFromDingTalk( { start: '2026-07-01', end: '2026-07-02', classId: 8 }, { user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never, ); expect(importService.importFromDingTalk).toHaveBeenCalledWith( expect.objectContaining({ autoMatch: true }), ); }); it('allows class managers to choose any importable class and supply explicit DingTalk users', async () => { attendanceService.getImportableClasses.mockResolvedValue([{ classId: 1, className: '一班' }]); authzService.can.mockReturnValue(true); await expect( controller.getDingTalkImportClasses({ user: { id: 7, username: 'manager', permissions: ['class:edit', 'attendance:create'], isSuperAdmin: false, }, } as never), ).resolves.toEqual([{ classId: 1, className: '一班' }]); expect(attendanceService.getImportableClasses).toHaveBeenCalledWith(7, true); await controller.importFromDingTalk( { start: '2026-07-01', end: '2026-07-02', users: 'ding-1,ding-2' }, { user: { id: 7, username: 'manager', permissions: ['class:edit', 'attendance:create'], isSuperAdmin: false, }, } as never, ); expect(importService.importFromDingTalk).toHaveBeenLastCalledWith({ startDate: '2026-07-01', endDate: '2026-07-02', userIds: ['ding-1', 'ding-2'], autoMatch: true, userId: 7, }); }); }); describe('AttendanceController — write data scope', () => { const attendanceService = { assertClassAccess: jest.fn(), getAccessibleClassIds: jest.fn(), getTeacherClassDingUserIds: jest.fn(), getLessonAttendanceImportDateRange: jest.fn().mockImplementation( (_schedule, lessonDate: string) => ({ startDate: lessonDate, endDate: lessonDate }), ), batchCreate: jest.fn(), generateFromSchedules: jest.fn(), findAttendanceRecord: jest.fn(), update: jest.fn(), remove: jest.fn(), getLessonAttendance: jest.fn(), createLessonAttendanceFromDingTalk: jest.fn(), findAttendanceSession: jest.fn(), completeLessonAttendance: jest.fn(), }; const importService = { importFromDingTalk: jest.fn() }; const logService = { log: jest.fn().mockResolvedValue(undefined) }; const authzService = { can: jest.fn().mockReturnValue(false) }; const req = { user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false, roles: ['teacher'] }, headers: {}, }; let controller: AttendanceController; beforeEach(() => { jest.clearAllMocks(); authzService.can.mockReturnValue(false); controller = new AttendanceController( attendanceService as unknown as AttendanceService, importService as unknown as AttendanceImportService, logService as unknown as OperationLogsService, authzService as never, ); }); it('checks every distinct class in a manual attendance batch', async () => { attendanceService.batchCreate.mockResolvedValue({ count: 2, records: [] }); const dto = { records: [ { studentId: 1, classId: 8, attendanceDate: '2026-07-01', session: 'morning', status: 'present', }, { studentId: 2, classId: 9, attendanceDate: '2026-07-01', session: 'morning', status: 'present', }, ], }; await controller.batchCreate(dto, req); expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 8, false); expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 9, false); }); it('rejects classless manual attendance records for a class-scoped teacher', async () => { attendanceService.batchCreate.mockResolvedValue({ count: 1, records: [] }); const dto = { records: [ { studentId: 1, attendanceDate: '2026-07-01', session: 'morning', status: 'present', }, ], }; await expect(controller.batchCreate(dto, req)).rejects.toBeInstanceOf( ForbiddenException, ); expect(attendanceService.batchCreate).not.toHaveBeenCalled(); }); it('checks the schedule class before starting and completing lesson attendance', async () => { attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-1']); importService.importFromDingTalk.mockResolvedValue({ success: true, imported: 1, skipped: 0, matched: 1, errors: [], duration: 10, }); attendanceService.getLessonAttendance.mockResolvedValue({ schedule: { id: 4, classId: 8 }, session: null, records: [], }); attendanceService.createLessonAttendanceFromDingTalk.mockResolvedValue({ schedule: { id: 4, classId: 8 }, session: { id: 90, classId: 8 }, records: [], }); attendanceService.findAttendanceSession.mockResolvedValue({ id: 90, classId: 8 }); attendanceService.completeLessonAttendance.mockResolvedValue({ session: { id: 90, classId: 8, status: 'completed' }, records: [], }); await controller.pullLessonAttendance('4', { date: '2026-07-11' }, req); await controller.completeLessonAttendance('90', req); expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(1, 21, 8, false); expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(2, 21, 8, false); }); it('checks class access before generating attendance from schedules', async () => { attendanceService.generateFromSchedules.mockResolvedValue({ count: 0, records: [] }); await controller.generateFromSchedules({ classId: 8 }, req); expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 8, false); }); it('checks the attendance record owning class before update and delete', async () => { attendanceService.findAttendanceRecord.mockResolvedValue({ id: 4, classId: 8 }); attendanceService.update.mockResolvedValue({ id: 4, classId: 8, status: 'late' }); attendanceService.remove.mockResolvedValue({ deleted: true }); await controller.update('4', { status: 'late' }, req); await controller.remove('4', req); expect(attendanceService.assertClassAccess).toHaveBeenCalledTimes(2); expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(1, 21, 8, false); expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(2, 21, 8, false); }); }); describe('AttendanceController — SSE progress scoping', () => { let progressSubject: Subject<{ phase: string; userId?: number }>; const importService = { importFromDingTalk: jest.fn(), get progress$() { return progressSubject.asObservable(); }, }; const attendanceService = {} as unknown as AttendanceService; const logService = {} as unknown as OperationLogsService; const authzService = {} as never; let controller: AttendanceController; beforeEach(() => { progressSubject = new Subject<{ phase: string; userId?: number }>(); controller = new AttendanceController( attendanceService, importService as unknown as AttendanceImportService, logService, authzService, ); }); afterEach(() => { progressSubject.complete(); }); it('delivers events matching the requesting user id', () => { const received: Array<{ phase: string; userId?: number }> = []; const sub = controller.importProgressStream({ user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] }, }).subscribe({ next: (e) => received.push(JSON.parse(e.data as string)), }); progressSubject.next({ phase: 'fetching', userId: 42 }); progressSubject.next({ phase: 'complete', userId: 42 }); sub.unsubscribe(); expect(received).toHaveLength(2); }); it('excludes events from a different user', () => { const received: Array<{ phase: string; userId?: number }> = []; const sub = controller.importProgressStream({ user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] }, }).subscribe({ next: (e) => received.push(JSON.parse(e.data as string)), }); progressSubject.next({ phase: 'fetching', userId: 99 }); // different user progressSubject.next({ phase: 'complete', userId: 42 }); sub.unsubscribe(); // Only the matching event should arrive expect(received).toHaveLength(1); expect(received[0].userId).toBe(42); }); it('excludes events with undefined userId (non-HTTP callers)', () => { const received: Array<{ phase: string; userId?: number }> = []; const sub = controller.importProgressStream({ user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] }, }).subscribe({ next: (e) => received.push(JSON.parse(e.data as string)), }); progressSubject.next({ phase: 'fetching' }); // no userId progressSubject.next({ phase: 'complete', userId: 42 }); sub.unsubscribe(); // Only the event with matching userId should arrive expect(received).toHaveLength(1); expect(received[0].userId).toBe(42); }); });