forked from wangziqi/gongxue-base
fix: audit remediation — SSE user scoping, FK transactional safety, UI error handling
- H4: scoped SSE import progress to exact userId match; non-HTTP events excluded from all subscribers - H2: moved PRAGMA foreign_key_check inside SQLite transaction before COMMIT; violations rollback preserving old tables - M1: removed dead axios-style error branch from extractErrorMessage (interceptor already unwraps) - M2: split handleSave try/catch — save errors vs reload errors shown distinctly - M3: added provider field validation before AI config test request - Added SSE scoping regression tests (import service + controller) - Added FK check failure rollback test (database-migrations.spec) - Updated controller spec expectations for userId parameter Co-authored-by: Code Review <branch-review>
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
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 { DingAttendanceRaw } from '../entities/ding-attendance-raw.entity';
|
||||
import { Class } from '../entities/class.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
@@ -53,6 +55,8 @@ describe('AttendanceService — batchCreate', () => {
|
||||
{ provide: getRepositoryToken(StudentDingMapping), useValue: mockStudentDingMappingRepo },
|
||||
{ provide: getRepositoryToken(ClassStudent), useValue: mockClassStudentRepo },
|
||||
{ provide: getRepositoryToken(ClassTeacher), useValue: { findOne: jest.fn() } },
|
||||
{ provide: getRepositoryToken(AttendanceSession), useValue: {} },
|
||||
{ provide: getDataSourceToken(), useValue: { transaction: jest.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -130,6 +134,8 @@ describe('AttendanceService — teacher DingTalk class scope', () => {
|
||||
classStudentRepo as never,
|
||||
mappingRepo as never,
|
||||
classTeacherRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -203,6 +209,8 @@ describe('AttendanceService — DingTalk raw query', () => {
|
||||
classStudentRepo as never,
|
||||
mappingRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await expect(
|
||||
@@ -219,3 +227,205 @@ describe('AttendanceService — DingTalk raw query', () => {
|
||||
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,
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user