import { Repository, DataSource } from 'typeorm'; import { OccupanciesService } from './occupancies.service'; import { Occupancy } from '../entities/occupancy.entity'; import { Room } from '../entities/room.entity'; import { Student } from '../entities/student.entity'; import { Deposit } from '../entities/deposit.entity'; import { Bed } from '../entities/bed.entity'; import { Locker } from '../entities/locker.entity'; import { Organization } from '../entities/organization.entity'; type QueryBuilderMock = { where: jest.Mock; andWhere: jest.Mock; setLock: jest.Mock; getOne: jest.Mock, []>; }; function createQueryBuilderMock(result: T | null): QueryBuilderMock { const qb = { where: jest.fn(), andWhere: jest.fn(), setLock: jest.fn(), getOne: jest.fn().mockResolvedValue(result), } as QueryBuilderMock; qb.where.mockReturnValue(qb); qb.andWhere.mockReturnValue(qb); qb.setLock.mockReturnValue(qb); return qb; } function createTransactionDataSource(manager: Record): DataSource { return { options: { type: 'sqlite' }, transaction: jest.fn( async (fn: (manager: Record) => unknown) => fn(manager), ), } as any as DataSource; } function createCheckInManager(options?: { existingOccupancy?: Occupancy | null; room?: Partial | null; student?: Partial | null; bed?: Partial | null; locker?: Partial | null; occupancyCount?: number; deposit?: Deposit | null; }) { const manager = { createQueryBuilder: jest.fn(), count: jest.fn().mockResolvedValue(options?.occupancyCount ?? 0), findOne: jest.fn(), create: jest.fn((_: unknown, value: unknown) => value), save: jest.fn(async (value: any) => ({ ...value, id: value?.id ?? 10 })), update: jest.fn(), }; const queryResults = [ options?.existingOccupancy ?? null, options?.room ?? { id: 2, capacity: 4, status: 'available' }, ...(options?.bed !== undefined ? [options.bed] : []), ...(options?.locker !== undefined ? [options.locker] : []), ]; manager.createQueryBuilder.mockImplementation(() => createQueryBuilderMock(queryResults.shift() ?? null), ); manager.findOne.mockImplementation(async (entity: unknown) => { if (entity === Student) return options?.student ?? { id: 3, organizationId: 7 }; if (entity === Deposit) return options?.deposit ?? null; return null; }); return manager; } function createQueryRunnerDataSource(config: { oldOccupancy?: Partial | null; newRoom?: Partial | null; newRoomCount?: number; }): DataSource { const queryResults: (Record | null)[] = [ (config.oldOccupancy ?? { id: 1, roomId: 2, bedId: 3, lockerId: 4, checkInDate: '2026-07-01', billingStartDate: '2026-07-01', checkOutDate: null, stayType: 'long', responsibleOrganizationId: 7, studentId: 5, }) as Record, (config.newRoom !== undefined ? config.newRoom : { id: 3, capacity: 4, status: 'available' }) as Record | null, ]; const manager = { createQueryBuilder: jest.fn().mockImplementation(() => createQueryBuilderMock(queryResults.shift() ?? null), ), save: jest.fn(async (value: any) => ({ ...value, id: value?.id ?? 10 })), update: jest.fn(), create: jest.fn((_: unknown, value: unknown) => value), count: jest.fn().mockResolvedValue(config.newRoomCount ?? 0), }; return { options: { type: 'sqlite' }, createQueryRunner: jest.fn().mockReturnValue({ connect: jest.fn().mockResolvedValue(undefined), startTransaction: jest.fn().mockResolvedValue(undefined), commitTransaction: jest.fn().mockResolvedValue(undefined), rollbackTransaction: jest.fn().mockResolvedValue(undefined), release: jest.fn().mockResolvedValue(undefined), manager, }), } as any as DataSource; } function createImportTransactionDataSource(repos: { occupancyRepo: Repository; roomRepo: Repository; studentRepo: Repository; depositRepo: Repository; bedRepo: Repository; lockerRepo: Repository; organizationRepo: Repository; }): DataSource { return createTransactionDataSource({ getRepository: jest.fn((entity: unknown) => { if (entity === Occupancy) return repos.occupancyRepo; if (entity === Room) return repos.roomRepo; if (entity === Student) return repos.studentRepo; if (entity === Deposit) return repos.depositRepo; if (entity === Bed) return repos.bedRepo; if (entity === Locker) return repos.lockerRepo; if (entity === Organization) return repos.organizationRepo; throw new Error('Unexpected repository'); }), }); } describe('OccupanciesService — boundary conditions', () => { // ── checkIn capacity guards ──────────────────────────────────────────── it('1. checkIn rejects when capacity is 0 (??0 guard — fail closed)', async () => { const manager = createCheckInManager({ room: { id: 2, capacity: 0, status: 'available' }, occupancyCount: 0, }); const service = new OccupanciesService( {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, createTransactionDataSource(manager), ); await expect( service.checkIn({ studentId: 3, roomId: 2, checkInDate: '2026-07-10' }), ).rejects.toThrow('宿舍已满'); }); it('2. checkIn rejects when capacity is undefined (??0 guard — fail closed)', async () => { const manager = createCheckInManager({ room: { id: 2, capacity: undefined, status: 'available' }, occupancyCount: 0, }); const service = new OccupanciesService( {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, createTransactionDataSource(manager), ); await expect( service.checkIn({ studentId: 3, roomId: 2, checkInDate: '2026-07-10' }), ).rejects.toThrow('宿舍已满'); }); it('3. checkIn allows when count < capacity (normal path)', async () => { const manager = createCheckInManager({ room: { id: 2, capacity: 4, status: 'available' }, occupancyCount: 2, }); const service = new OccupanciesService( {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, createTransactionDataSource(manager), ); const result = await service.checkIn({ studentId: 3, roomId: 2, checkInDate: '2026-07-10', }); expect(result).toBeTruthy(); expect(manager.save).toHaveBeenCalled(); }); it('4. checkIn rejects when count >= capacity (normal rejection)', async () => { const manager = createCheckInManager({ room: { id: 2, capacity: 4, status: 'available' }, occupancyCount: 4, }); const service = new OccupanciesService( {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, createTransactionDataSource(manager), ); await expect( service.checkIn({ studentId: 3, roomId: 2, checkInDate: '2026-07-10' }), ).rejects.toThrow('宿舍已满'); }); // ── transferRoom capacity guard ───────────────────────────────────────── it('5. transferRoom rejects when target room capacity is undefined (??0 guard — fail closed)', async () => { const dataSource = createQueryRunnerDataSource({ oldOccupancy: { id: 1, roomId: 2, bedId: 3, lockerId: 4, checkInDate: '2026-07-01', billingStartDate: '2026-07-01', checkOutDate: null, stayType: 'long', responsibleOrganizationId: 7, studentId: 5, }, newRoom: { id: 3, capacity: undefined, status: 'available' }, newRoomCount: 0, }); const service = new OccupanciesService( {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, dataSource, ); await expect( service.transferRoom(1, { newRoomId: 3, transferDate: '2026-07-15' }), ).rejects.toThrow('目标宿舍已满'); }); // ── batchImportCheckIn capacity guard ─────────────────────────────────── it('6. batchImportCheckIn skips row when room capacity is undefined (??0 guard — fail closed)', async () => { const occupancyRepo = { findOne: jest.fn().mockResolvedValue(null), count: jest.fn().mockResolvedValue(0), create: jest.fn((value: any) => value), save: jest.fn(), } as any as Repository; const roomRepo = { findOne: jest.fn().mockResolvedValue({ id: 2, roomNumber: '4-102', capacity: undefined, }), update: jest.fn(), } as any as Repository; const studentRepo = { findOne: jest .fn() .mockResolvedValue({ id: 3, name: '张三', phone: '13800138000', organizationId: 7, }), } as any as Repository; const bedRepo = { findOne: jest.fn(), count: jest.fn(), create: jest.fn(), save: jest.fn(), update: jest.fn(), } as any as Repository; const organizationRepo = { findOne: jest.fn(), } as any as Repository; const service = new OccupanciesService( occupancyRepo, roomRepo, studentRepo, { findOne: jest.fn() } as any as Repository, bedRepo, { findOne: jest.fn() } as any as Repository, organizationRepo, createImportTransactionDataSource({ occupancyRepo, roomRepo, studentRepo, depositRepo: { findOne: jest.fn() } as any as Repository, bedRepo, lockerRepo: { findOne: jest.fn() } as any as Repository, organizationRepo, }), ); const result = await service.batchImportCheckIn([ { name: '张三', phone: '13800138000', roomNumber: '4-102', bedNumber: '1号床', checkInDate: '2026-07-14', }, ]); expect(result.imported).toBe(0); expect(result.skipped).toBe(1); expect(result.errors).toEqual( expect.arrayContaining([expect.stringContaining('已满')]), ); expect(occupancyRepo.save).not.toHaveBeenCalled(); }); });