Files
gongxue-base/apps/server/src/common/batch-restore.services.spec.ts

308 lines
12 KiB
TypeScript

import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ExpensesService } from '../expenses/expenses.service';
import { OccupanciesService } from '../occupancies/occupancies.service';
import { RoomsService } from '../rooms/rooms.service';
import { StudentsService } from '../students/students.service';
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;
}
function listQb() {
const qb = {
leftJoinAndSelect: jest.fn(),
where: jest.fn(),
orderBy: jest.fn(),
andWhere: jest.fn(),
getMany: jest.fn().mockResolvedValue([]),
};
qb.leftJoinAndSelect.mockReturnValue(qb);
qb.where.mockReturnValue(qb);
qb.orderBy.mockReturnValue(qb);
qb.andWhere.mockReturnValue(qb);
return qb;
}
describe('batch restore service semantics', () => {
it('rejects empty and invalid ids in every restore service', async () => {
const students = new StudentsService(
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
{} as never, {} as never, {} as never, {} as never, {} as never,
);
const rooms = new RoomsService(
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
);
const expenses = new ExpensesService(
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
);
const occupancies = new OccupanciesService(
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
);
const calls = [
(ids: number[]) => students.batchRestore(ids),
(ids: number[]) => rooms.batchRestore(ids),
(ids: number[]) => expenses.batchRestoreRoomExpenses(ids),
(ids: number[]) => expenses.batchRestorePersonalExpenses(ids),
(ids: number[]) => occupancies.batchRestore(ids),
];
for (const call of calls) {
await expect(call([])).rejects.toBeInstanceOf(BadRequestException);
await expect(call([0])).rejects.toBeInstanceOf(BadRequestException);
await expect(call([1.5])).rejects.toBeInstanceOf(BadRequestException);
}
});
it('deduplicates student ids, restores archived rows, and skips active rows', async () => {
const qb = updateQb();
const repo = {
find: jest.fn().mockResolvedValue([
{ id: 1, status: 'archived' },
{ id: 2, status: 'active' },
]),
createQueryBuilder: jest.fn(() => qb),
};
const service = new StudentsService(
repo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
await expect(service.batchRestore([1, 1, 2])).resolves.toEqual({
message: '已批量恢复 1 名学生',
restored: 1,
skipped: 1,
});
expect(repo.find).toHaveBeenCalledWith({ where: { id: expect.anything() } });
expect(qb.set).toHaveBeenCalledWith({ status: 'active' });
expect(qb.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: [1] });
});
it('rejects missing student ids before updating', async () => {
const repo = { find: jest.fn().mockResolvedValue([{ id: 1, status: 'archived' }]), createQueryBuilder: jest.fn() };
const service = new StudentsService(
repo as never, {} as never, {} as never, {} as never, {} as never, {} as never,
{} as never, {} as never, {} as never, {} as never, {} as never,
);
await expect(service.batchRestore([1, 2])).rejects.toBeInstanceOf(NotFoundException);
expect(repo.createQueryBuilder).not.toHaveBeenCalled();
});
it('restores archived rooms to available and skips non-archived rooms', async () => {
const qb = updateQb();
const repo = {
find: jest.fn().mockResolvedValue([
{ id: 1, status: 'archived' },
{ id: 2, status: 'maintenance' },
]),
createQueryBuilder: jest.fn(() => qb),
};
const service = new RoomsService(
repo as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
);
await expect(service.batchRestore([1, 2])).resolves.toEqual({
message: '已批量恢复 1 间宿舍', restored: 1, skipped: 1,
});
expect(qb.set).toHaveBeenCalledWith({ status: 'available' });
});
it('rejects room-expense restore when any selected record is billed', async () => {
const qb = updateQb();
const roomExpRepo = {
find: jest.fn().mockResolvedValue([{ id: 1, status: 'archived' }]),
createQueryBuilder: jest.fn(() => qb),
};
const billItemsRepo = { count: jest.fn().mockResolvedValue(1) };
const service = new ExpensesService(
roomExpRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{ getRepository: jest.fn(() => billItemsRepo) } as never,
);
await expect(service.batchRestoreRoomExpenses([1])).rejects.toBeInstanceOf(BadRequestException);
expect(roomExpRepo.createQueryBuilder).not.toHaveBeenCalled();
});
it('restores unbilled room expenses and reports active rows as skipped', async () => {
const qb = updateQb();
const roomExpRepo = {
find: jest.fn().mockResolvedValue([
{ id: 1, status: 'archived' },
{ id: 2, status: 'active' },
]),
createQueryBuilder: jest.fn(() => qb),
};
const service = new ExpensesService(
roomExpRepo as never, {} as never, {} as never, {} as never, {} as never,
{ getRepository: jest.fn(() => ({ count: jest.fn().mockResolvedValue(0) })) } as never,
);
await expect(service.batchRestoreRoomExpenses([1, 2])).resolves.toMatchObject({ restored: 1, skipped: 1 });
expect(qb.set).toHaveBeenCalledWith({ status: 'active' });
});
it('skips an active billed room expense without blocking an archived unbilled expense', async () => {
const qb = updateQb();
const count = jest.fn().mockResolvedValue(0);
const roomExpRepo = {
find: jest.fn().mockResolvedValue([
{ id: 1, status: 'archived' },
{ id: 2, status: 'active' },
]),
createQueryBuilder: jest.fn(() => qb),
};
const service = new ExpensesService(
roomExpRepo as never, {} as never, {} as never, {} as never, {} as never,
{ getRepository: jest.fn(() => ({ count })) } as never,
);
await expect(service.batchRestoreRoomExpenses([1, 2])).resolves.toMatchObject({
restored: 1,
skipped: 1,
});
expect(count).toHaveBeenCalledWith({ where: { roomExpenseId: expect.anything() } });
expect(qb.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: [1] });
});
it('rejects personal-expense restore when any selected record has a bill id', async () => {
const personalExpRepo = {
find: jest.fn().mockResolvedValue([{ id: 1, status: 'archived', billId: 9 }]),
createQueryBuilder: jest.fn(),
};
const service = new ExpensesService(
{} as never, personalExpRepo as never, {} as never, {} as never, {} as never, {} as never,
);
await expect(service.batchRestorePersonalExpenses([1])).rejects.toBeInstanceOf(BadRequestException);
expect(personalExpRepo.createQueryBuilder).not.toHaveBeenCalled();
});
it('restores unbilled personal expenses and skips already active records', async () => {
const qb = updateQb();
const personalExpRepo = {
find: jest.fn().mockResolvedValue([
{ id: 1, status: 'archived', billId: null },
{ id: 2, status: 'active', billId: null },
]),
createQueryBuilder: jest.fn(() => qb),
};
const service = new ExpensesService(
{} as never, personalExpRepo as never, {} as never, {} as never, {} as never, {} as never,
);
await expect(service.batchRestorePersonalExpenses([1, 1, 2])).resolves.toMatchObject({
restored: 1,
skipped: 1,
});
expect(qb.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: [1] });
});
it('skips an active billed personal expense without blocking an archived unbilled expense', async () => {
const qb = updateQb();
const personalExpRepo = {
find: jest.fn().mockResolvedValue([
{ id: 1, status: 'archived', billId: null },
{ id: 2, status: 'active', billId: 9 },
]),
createQueryBuilder: jest.fn(() => qb),
};
const service = new ExpensesService(
{} as never, personalExpRepo as never, {} as never, {} as never, {} as never, {} as never,
);
await expect(service.batchRestorePersonalExpenses([1, 2])).resolves.toMatchObject({
restored: 1,
skipped: 1,
});
expect(qb.where).toHaveBeenCalledWith('id IN (:...ids)', { ids: [1] });
});
it('uses archived status when querying expense archive views', async () => {
const roomQb = listQb();
const personalRepo = { find: jest.fn().mockResolvedValue([]) };
const service = new ExpensesService(
{ createQueryBuilder: jest.fn(() => roomQb) } as never,
personalRepo as never,
{} as never, {} as never, {} as never, {} as never,
);
await service.findRoomExpenses({ status: 'archived' });
await service.findPersonalExpenses({ status: 'archived' });
expect(roomQb.where).toHaveBeenCalledWith('e.status = :status', { status: 'archived' });
expect(personalRepo.find).toHaveBeenCalledWith(expect.objectContaining({ where: { status: 'archived' } }));
});
it('rejects invalid expense query status values', async () => {
const service = new ExpensesService(
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
);
await expect(service.findRoomExpenses({ status: 'deleted' as never })).rejects.toBeInstanceOf(BadRequestException);
await expect(service.findPersonalExpenses({ status: 'deleted' as never })).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects an archived occupancy without a checkout date before updating', async () => {
const repo = {
find: jest.fn().mockResolvedValue([{ id: 1, status: 'archived', checkOutDate: null }]),
createQueryBuilder: jest.fn(),
};
const service = new OccupanciesService(
repo as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
);
await expect(service.batchRestore([1])).rejects.toBeInstanceOf(BadRequestException);
expect(repo.createQueryBuilder).not.toHaveBeenCalled();
});
it('restores checked-out occupancies without changing room, bed, or locker state', async () => {
const qb = updateQb();
const repo = {
find: jest.fn().mockResolvedValue([
{ id: 1, status: 'archived', checkOutDate: '2026-07-01' },
{ id: 2, status: 'active', checkOutDate: '2026-07-02' },
]),
createQueryBuilder: jest.fn(() => qb),
};
const roomRepo = { update: jest.fn() };
const bedRepo = { update: jest.fn() };
const lockerRepo = { update: jest.fn() };
const service = new OccupanciesService(
repo as never, roomRepo as never, {} as never, {} as never, bedRepo as never,
lockerRepo as never, {} as never, {} as never,
);
await expect(service.batchRestore([1, 2])).resolves.toMatchObject({ restored: 1, skipped: 1 });
expect(qb.set).toHaveBeenCalledWith({ status: 'active' });
expect(roomRepo.update).not.toHaveBeenCalled();
expect(bedRepo.update).not.toHaveBeenCalled();
expect(lockerRepo.update).not.toHaveBeenCalled();
});
it('uses archived status while preserving active=true as checkout filtering', async () => {
const qb = listQb();
const service = new OccupanciesService(
{ createQueryBuilder: jest.fn(() => qb) } as never,
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
);
await service.findAll({ status: 'archived', active: true });
expect(qb.where).toHaveBeenCalledWith('o.status = :status', { status: 'archived' });
expect(qb.andWhere).toHaveBeenCalledWith('o.checkOutDate IS NULL');
});
it('rejects invalid occupancy query status values', async () => {
const service = new OccupanciesService(
{} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never, {} as never,
);
await expect(service.findAll({ status: 'deleted' as never })).rejects.toBeInstanceOf(BadRequestException);
});
});