Files
gongxue-base/apps/server/src/exams/exams.service.spec.ts
xiong 095eccea76
All checks were successful
CI / check (pull_request) Successful in 2m4s
feat: 支持考试批量归档与恢复
2026-07-25 09:45:58 +08:00

365 lines
13 KiB
TypeScript

import { BadRequestException, ForbiddenException, NotFoundException, ValidationPipe } from '@nestjs/common';
import { ExamScore } from '../entities';
import { QueryExamDto } from './dto/exam.dto';
import { ExamsService } from './exams.service';
function createService(
transaction: (run: (manager: any) => Promise<unknown>) => Promise<unknown>,
repositories: {
examRepo?: Record<string, jest.Mock>;
scoreRepo?: Record<string, jest.Mock>;
classTeacherRepo?: Record<string, jest.Mock>;
} = {},
) {
return new ExamsService(
(repositories.examRepo ?? {}) as never,
(repositories.scoreRepo ?? {}) as never,
{} as never,
{} as never,
(repositories.classTeacherRepo ?? {
findOne: jest.fn().mockResolvedValue({ id: 1 }),
}) as never,
{ transaction } as never,
);
}
function updateQb(affected = 1) {
const qb = {
update: jest.fn(),
set: jest.fn(),
where: jest.fn(),
execute: jest.fn().mockResolvedValue({ affected }),
};
qb.update.mockReturnValue(qb);
qb.set.mockReturnValue(qb);
qb.where.mockReturnValue(qb);
return qb;
}
describe('ExamsService', () => {
it('creates score rows from the active class roster snapshot', async () => {
const members = [
{ studentId: 11, status: 'active' },
{ studentId: 12, status: 'active' },
];
const manager = {
findOne: jest.fn().mockResolvedValue({ id: 3, isArchived: false }),
find: jest.fn().mockResolvedValue(members),
create: jest.fn((_entity, value) => value),
save: jest.fn(async (entity, value) =>
entity.name === 'Exam' ? { ...value, id: 9 } : value,
),
};
const service = createService(async (run) => run(manager));
await service.create(
{
examType: '月考',
examName: '七月月考',
subject: '数学',
examDate: '2026-07-21',
classId: 3,
},
1,
true,
);
expect(manager.find).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ where: { classId: 3, status: 'active' } }),
);
expect(manager.save).toHaveBeenCalledWith(
ExamScore,
expect.arrayContaining([
expect.objectContaining({ examId: 9, studentId: 11, score: null }),
expect.objectContaining({ examId: 9, studentId: 12, score: null }),
]),
);
});
it('rejects creating an exam for an empty class', async () => {
const manager = {
findOne: jest.fn().mockResolvedValue({ id: 3, isArchived: false }),
find: jest.fn().mockResolvedValue([]),
};
const service = createService(async (run) => run(manager));
await expect(
service.create(
{
examType: '月考',
examName: '七月月考',
subject: '数学',
examDate: '2026-07-21',
classId: 3,
},
1,
true,
),
).rejects.toBeInstanceOf(BadRequestException);
});
it('counts zero, ignores empty scores, and uses competition ranking', async () => {
const rows = [
{ id: 1, score: 90, classAvg: null, rank: null },
{ id: 2, score: 90, classAvg: null, rank: null },
{ id: 3, score: 60, classAvg: null, rank: null },
{ id: 4, score: 0, classAvg: null, rank: null },
{ id: 5, score: null, classAvg: null, rank: null },
];
const manager = {
findOne: jest
.fn()
.mockResolvedValueOnce({ id: 8, classId: 3, status: 'active' })
.mockResolvedValueOnce(rows[0])
.mockResolvedValueOnce(rows[0]),
find: jest.fn().mockResolvedValue(rows),
save: jest.fn(async (_entity, value) => value),
};
const service = createService(async (run) => run(manager));
await service.updateScore(8, 1, 90, 1, true);
expect(rows.map((row) => row.rank)).toEqual([1, 1, 3, 4, null]);
expect(rows.map((row) => row.classAvg)).toEqual([60, 60, 60, 60, 60]);
});
it('clears a score and recalculates the remaining rows', async () => {
const rows = [
{ id: 1, score: 90, classAvg: 80, rank: 1 },
{ id: 2, score: 70, classAvg: 80, rank: 2 },
];
const manager = {
findOne: jest
.fn()
.mockResolvedValueOnce({ id: 8, classId: 3, status: 'active' })
.mockResolvedValueOnce(rows[0])
.mockResolvedValueOnce(rows[0]),
find: jest.fn().mockResolvedValue(rows),
save: jest.fn(async (_entity, value) => value),
};
const service = createService(async (run) => run(manager));
await service.updateScore(8, 1, null, 1, true);
expect(rows).toEqual([
expect.objectContaining({ score: null, classAvg: 70, rank: null }),
expect.objectContaining({ score: 70, classAvg: 70, rank: 1 }),
]);
});
it('lists active exams by default and archived exams on request', async () => {
const examRepo = {
find: jest.fn().mockResolvedValue([]),
};
const service = createService(async () => undefined, { examRepo });
await service.findAll({});
await service.findAll({ isArchived: true });
expect(examRepo.find).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ where: expect.objectContaining({ status: 'active' }) }),
);
expect(examRepo.find).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ where: expect.objectContaining({ status: 'archived' }) }),
);
});
it('preserves the accessible class filter for archived exams', async () => {
const examRepo = {
find: jest.fn().mockResolvedValue([]),
};
const service = createService(async () => undefined, { examRepo });
await service.findAll({ isArchived: true }, [3, 5]);
expect(examRepo.find).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ status: 'archived', classId: expect.anything() }),
}),
);
});
it('archives an exam without changing score rows', async () => {
const examRepo = {
findOne: jest.fn().mockResolvedValue({ id: 8, classId: 3, status: 'active' }),
update: jest.fn().mockResolvedValue({ affected: 1 }),
};
const scoreRepo = { update: jest.fn(), save: jest.fn() };
const service = createService(async () => undefined, { examRepo, scoreRepo });
await expect(service.archive(8, 1, true)).resolves.toEqual({ success: true });
expect(examRepo.update).toHaveBeenCalledWith(8, { status: 'archived' });
expect(scoreRepo.update).not.toHaveBeenCalled();
expect(scoreRepo.save).not.toHaveBeenCalled();
});
it('rejects archiving an archived exam', async () => {
const examRepo = {
findOne: jest.fn().mockResolvedValue({ id: 8, classId: 3, status: 'archived' }),
update: jest.fn(),
};
const service = createService(async () => undefined, { examRepo });
await expect(service.archive(8, 1, true)).rejects.toBeInstanceOf(BadRequestException);
expect(examRepo.update).not.toHaveBeenCalled();
});
it('restores an archived exam and rejects restoring an active exam', async () => {
const examRepo = {
findOne: jest
.fn()
.mockResolvedValueOnce({ id: 8, classId: 3, status: 'archived' })
.mockResolvedValueOnce({ id: 9, classId: 3, status: 'active' }),
update: jest.fn().mockResolvedValue({ affected: 1 }),
};
const service = createService(async () => undefined, { examRepo });
await expect(service.restore(8, 1, true)).resolves.toEqual({ success: true });
await expect(service.restore(9, 1, true)).rejects.toBeInstanceOf(BadRequestException);
expect(examRepo.update).toHaveBeenCalledTimes(1);
expect(examRepo.update).toHaveBeenCalledWith(8, { status: 'active' });
});
it('requires class access before archiving an exam', async () => {
const examRepo = {
findOne: jest.fn().mockResolvedValue({ id: 8, classId: 3, status: 'active' }),
update: jest.fn(),
};
const classTeacherRepo = { findOne: jest.fn().mockResolvedValue(null) };
const service = createService(async () => undefined, { examRepo, classTeacherRepo });
await expect(service.archive(8, 21, false)).rejects.toBeInstanceOf(ForbiddenException);
expect(classTeacherRepo.findOne).toHaveBeenCalledWith({ where: { userId: 21, classId: 3 } });
expect(examRepo.update).not.toHaveBeenCalled();
});
it('rejects empty and invalid ids for batch archive and restore', async () => {
const service = createService(async () => undefined, { examRepo: { find: jest.fn() } });
for (const call of [
(ids: number[]) => service.batchArchive(ids, 1, true),
(ids: number[]) => service.batchRestore(ids, 1, true),
]) {
await expect(call([])).rejects.toBeInstanceOf(BadRequestException);
await expect(call([0])).rejects.toBeInstanceOf(BadRequestException);
await expect(call([1.5])).rejects.toBeInstanceOf(BadRequestException);
}
});
it('deduplicates ids, archives active exams, skips archived exams, and leaves scores unchanged', async () => {
const qb = updateQb();
const examRepo = {
find: jest.fn().mockResolvedValue([
{ id: 8, classId: 3, status: 'active' },
{ id: 9, classId: 4, status: 'archived' },
]),
createQueryBuilder: jest.fn(() => qb),
};
const scoreRepo = { update: jest.fn(), save: jest.fn() };
const service = createService(async () => undefined, { examRepo, scoreRepo });
await expect(service.batchArchive([8, 8, 9], 1, true)).resolves.toEqual({
message: '已批量归档 1 场考试',
archived: 1,
skipped: 1,
});
expect(examRepo.find).toHaveBeenCalledWith({ where: { id: expect.anything() } });
expect(qb.set).toHaveBeenCalledWith({ status: 'archived' });
expect(qb.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: [8] });
expect(scoreRepo.update).not.toHaveBeenCalled();
expect(scoreRepo.save).not.toHaveBeenCalled();
});
it('restores archived exams and skips active exams', async () => {
const qb = updateQb();
const examRepo = {
find: jest.fn().mockResolvedValue([
{ id: 8, classId: 3, status: 'archived' },
{ id: 9, classId: 4, status: 'active' },
]),
createQueryBuilder: jest.fn(() => qb),
};
const service = createService(async () => undefined, { examRepo });
await expect(service.batchRestore([8, 9], 1, true)).resolves.toEqual({
message: '已批量恢复 1 场考试',
restored: 1,
skipped: 1,
});
expect(qb.set).toHaveBeenCalledWith({ status: 'active' });
expect(qb.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: [8] });
});
it('rejects a batch when any exam is missing before updating', async () => {
const examRepo = {
find: jest.fn().mockResolvedValue([{ id: 8, classId: 3, status: 'active' }]),
createQueryBuilder: jest.fn(),
};
const service = createService(async () => undefined, { examRepo });
await expect(service.batchArchive([8, 9], 1, true)).rejects.toBeInstanceOf(NotFoundException);
expect(examRepo.createQueryBuilder).not.toHaveBeenCalled();
});
it('checks access for every selected exam before a batch update', async () => {
const examRepo = {
find: jest.fn().mockResolvedValue([
{ id: 8, classId: 3, status: 'active' },
{ id: 9, classId: 4, status: 'active' },
]),
createQueryBuilder: jest.fn(),
};
const classTeacherRepo = {
findOne: jest
.fn()
.mockResolvedValueOnce({ id: 1 })
.mockResolvedValueOnce(null),
};
const service = createService(async () => undefined, { examRepo, classTeacherRepo });
await expect(service.batchArchive([8, 9], 21, false)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(classTeacherRepo.findOne).toHaveBeenNthCalledWith(1, {
where: { userId: 21, classId: 3 },
});
expect(classTeacherRepo.findOne).toHaveBeenNthCalledWith(2, {
where: { userId: 21, classId: 4 },
});
expect(examRepo.createQueryBuilder).not.toHaveBeenCalled();
});
it('rejects score updates for archived exams', async () => {
const manager = {
findOne: jest.fn().mockResolvedValue({ id: 8, classId: 3, status: 'archived' }),
};
const service = createService(async (run) => run(manager));
await expect(service.updateScore(8, 1, 90, 1, true)).rejects.toBeInstanceOf(
BadRequestException,
);
});
});
describe('QueryExamDto - query transformation', () => {
const pipe = new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true });
it.each([
['false', false],
['0', false],
['true', true],
['1', true],
])('transforms isArchived=%s to %s', async (input, expected) => {
await expect(
pipe.transform(
{ isArchived: input },
{ type: 'query', metatype: QueryExamDto, data: undefined },
),
).resolves.toEqual({ isArchived: expected });
});
});