forked from wangziqi/gongxue-base
438 lines
15 KiB
TypeScript
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();
|
|
});
|
|
});
|