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

438 lines
15 KiB
TypeScript

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 { 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<Pick<Repository<AttendanceRecord>, 'create' | 'save'>>;
const savedRecords: AttendanceRecord[] = [];
beforeEach(async () => {
savedRecords.length = 0;
const mockRepo = {
create: jest
.fn()
.mockImplementation((data: Partial<AttendanceRecord>) => ({ 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: getDataSourceToken(), useValue: { transaction: jest.fn() } },
],
}).compile();
service = module.get<AttendanceService>(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,
);
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,
);
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);
});
});
// ── Session serialization tests ──
function deferred<T>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
} {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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<string, unknown> = {
id: recordId,
attendanceSessionId: sessionId,
studentId: 1,
status: 'present',
source: 'dingtalk',
attendanceDate: '2026-07-01',
session: 'morning',
};
function makeService(
dataSourceMock: { transaction: jest.Mock },
attendanceRepoOverrides?: Record<string, jest.Mock>,
) {
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,
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<unknown>();
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<unknown>();
// 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();
});
});