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); }); });