256 lines
8.7 KiB
TypeScript
256 lines
8.7 KiB
TypeScript
import { BadRequestException, ForbiddenException, 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,
|
|
);
|
|
}
|
|
|
|
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 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 });
|
|
});
|
|
});
|