373 lines
13 KiB
TypeScript
373 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|