import { Repository, DataSource } from 'typeorm'; import { OccupanciesService } from './occupancies.service'; import { OccupancyOperationsService } from './occupancy-operations.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 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'); }), }); } function createCheckOutManager(occupancy: Occupancy | null) { const manager = { createQueryBuilder: jest.fn().mockReturnValue(createQueryBuilderMock(occupancy)), save: jest.fn(async (value) => value), update: jest.fn(), }; return manager; } describe('OccupanciesService — responsible organization', () => { it('always takes the responsible organization from the student', async () => { const manager = createCheckInManager({ student: { id: 3, gender: '男', organizationId: 7 }, bed: { id: 4, roomId: 2, status: 'available' }, }); const operations = new OccupancyOperationsService( {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, createTransactionDataSource(manager), {} as Repository, ); const service = new OccupanciesService( {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, createTransactionDataSource(manager), {} as Repository, operations, ); await service.checkIn({ studentId: 3, roomId: 2, checkInDate: '2026-07-10', bedId: 4, responsibleOrganizationId: 99, } as any); expect(manager.create).toHaveBeenCalledWith( Occupancy, expect.objectContaining({ responsibleOrganizationId: 7 }), ); }); }); describe('OccupanciesService — manual check-in deposit', () => { const createService = (existingDeposit: Deposit | null = null) => { const manager = createCheckInManager({ bed: { id: 4, roomId: 2, status: 'available' }, deposit: existingDeposit, }); return { service: new OccupanciesService( {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, createTransactionDataSource(manager), {} as Repository, new OccupancyOperationsService( {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, createTransactionDataSource(manager), {} as Repository, ), ), manager, }; }; it('creates a paid deposit together with manual check-in', async () => { const { service, manager } = createService(); await service.checkIn( { studentId: 3, roomId: 2, checkInDate: '2026-07-14', bedId: 4, collectDeposit: true, depositAmount: 800, }, 11, ); expect(manager.create).toHaveBeenCalledWith(Deposit, { studentId: 3, amount: 800, paidDate: '2026-07-14', status: 'paid', recordedBy: 11, notes: '入住登记自动收取', }); expect(manager.save).toHaveBeenCalledWith(expect.objectContaining({ status: 'paid' })); }); it('adds the collected amount to the existing student deposit', async () => { const existing = { id: 99, amount: 200, status: 'refunded' } as Deposit; const { service, manager } = createService(existing); await service.checkIn({ studentId: 3, roomId: 2, checkInDate: '2026-07-14', bedId: 4, collectDeposit: true, depositAmount: 800, }); expect(manager.create).toHaveBeenCalledTimes(1); expect(manager.save).toHaveBeenCalledWith( expect.objectContaining({ id: 99, amount: 1000, status: 'paid', paidDate: '2026-07-14', notes: '入住登记自动收取', }), ); }); }); describe('OccupanciesService — import bed capacity', () => { it('rejects creating a new bed when the room already has its capacity in beds', async () => { const occupancyRepo = { findOne: jest.fn().mockResolvedValue(null), count: jest.fn().mockResolvedValue(0), create: jest.fn((value) => value), save: jest.fn(), } as any as Repository; const roomRepo = { findOne: jest.fn().mockResolvedValue({ id: 2, roomNumber: '4-102', capacity: 4 }), create: jest.fn((value) => value), save: jest.fn(), update: jest.fn(), } as any as Repository; const studentRepo = { findOne: jest.fn().mockResolvedValue({ id: 3, name: '张三', organizationId: 7 }), create: jest.fn((value) => value), save: jest.fn(), update: jest.fn(), } as any as Repository; const bedRepo = { findOne: jest.fn().mockResolvedValue(null), count: jest.fn().mockResolvedValue(4), create: jest.fn((value) => value), save: jest.fn(), update: jest.fn(), } as any as Repository; const organizationRepo = { findOne: jest.fn().mockResolvedValue({ id: 7, name: '本机构', isHost: true }), create: jest.fn((value) => value), save: jest.fn(), } as any as Repository; const service = new OccupanciesService( occupancyRepo, roomRepo, studentRepo, { findOne: jest.fn(), create: jest.fn(), save: jest.fn() } as any as Repository, bedRepo, { findOne: jest.fn() } as any as Repository, organizationRepo, createImportTransactionDataSource({ occupancyRepo, roomRepo, studentRepo, depositRepo: { findOne: jest.fn(), create: jest.fn(), save: 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: '5号床', checkInDate: '2026-07-14', }, ]); expect(result).toEqual( expect.objectContaining({ imported: 0, skipped: 1, errors: [expect.stringContaining('不能超过额定人数 4')], }), ); expect(bedRepo.save).not.toHaveBeenCalled(); expect(occupancyRepo.save).not.toHaveBeenCalled(); }); }); describe('OccupanciesService — import student matching', () => { it('associates an existing student by phone and keeps the student organization', async () => { const existingStudent = { id: 3, name: '学生档案姓名', phone: '13800138000', organizationId: 7, }; const occupancyRepo = { findOne: jest.fn().mockResolvedValue(null), count: jest.fn().mockResolvedValue(0), create: jest.fn((value) => value), save: jest.fn(async (value) => ({ ...value, id: 10 })), } as any as Repository; const roomRepo = { findOne: jest.fn().mockResolvedValue({ id: 2, roomNumber: '4-102', capacity: 4 }), create: jest.fn((value) => value), save: jest.fn(), update: jest.fn(), } as any as Repository; const studentRepo = { findOne: jest.fn().mockResolvedValue(existingStudent), create: jest.fn((value) => value), save: jest.fn(), update: jest.fn(), } as any as Repository; const bedRepo = { findOne: jest.fn().mockResolvedValue({ id: 4, roomId: 2, bedNumber: '1号床', status: 'available', }), count: jest.fn(), create: jest.fn((value) => value), save: jest.fn(), update: jest.fn(), } as any as Repository; const organizationRepo = { findOne: jest.fn(), create: jest.fn((value) => value), save: 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: 'Excel姓名', phone: '13800138000', roomNumber: '4-102', bedNumber: '1号床', checkInDate: '2026-07-14', }, ]); expect(studentRepo.findOne).toHaveBeenCalledWith({ where: { phone: '13800138000' } }); expect(studentRepo.save).not.toHaveBeenCalled(); expect(organizationRepo.findOne).not.toHaveBeenCalled(); expect(occupancyRepo.create).toHaveBeenCalledWith( expect.objectContaining({ studentId: 3, responsibleOrganizationId: 7 }), ); expect(result).toEqual(expect.objectContaining({ imported: 1, skipped: 0 })); }); }); describe('OccupanciesService — stay lifecycle boundaries', () => { it('rejects check-out before check-in without releasing resources', async () => { const occupancy = { id: 1, roomId: 2, bedId: 3, lockerId: 4, checkInDate: '2026-07-10', billingStartDate: '2026-07-10', checkOutDate: null, } as Occupancy; const manager = createCheckOutManager(occupancy); const service = new OccupanciesService( {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, createTransactionDataSource(manager), ); await expect(service.checkOut(1, { checkOutDate: '2026-07-09' })).rejects.toThrow( '退宿日期不能早于入住日期', ); expect(manager.save).not.toHaveBeenCalled(); expect(manager.update).not.toHaveBeenCalled(); }); it('rejects check-in to a maintenance room', async () => { const manager = createCheckInManager({ room: { id: 2, capacity: 4, status: 'maintenance' }, bed: { id: 3, roomId: 2, status: 'available' }, }); 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: 1, roomId: 2, checkInDate: '2026-07-10', bedId: 3 }), ).rejects.toThrow('该宿舍当前不可入住'); expect(manager.count).not.toHaveBeenCalled(); }); }); describe('OccupanciesService — import deposit boundaries', () => { const createImportService = (existingDeposit: Deposit | null = null) => { const occupancyRepo = { findOne: jest.fn().mockResolvedValue(null), count: jest.fn().mockResolvedValue(0), create: jest.fn((value) => value), save: jest.fn(async (value) => ({ ...value, id: 10 })), } as any as Repository; const roomRepo = { findOne: jest.fn().mockResolvedValue({ id: 2, roomNumber: '4-102', capacity: 4 }), update: jest.fn(), } as any as Repository; const studentRepo = { findOne: jest.fn().mockResolvedValue({ id: 3, name: '张三', organizationId: 7 }), } as any as Repository; const bedRepo = { findOne: jest.fn().mockResolvedValue({ id: 4, roomId: 2, status: 'available' }), update: jest.fn(), } as any as Repository; const depositRepo = { findOne: jest.fn().mockResolvedValue(existingDeposit), create: jest.fn((value) => value), save: jest.fn(async (value) => ({ ...value, id: value?.id ?? 20 })), } as any as Repository; return { service: new OccupanciesService( occupancyRepo, roomRepo, studentRepo, depositRepo, bedRepo, { findOne: jest.fn() } as any as Repository, {} as Repository, createImportTransactionDataSource({ occupancyRepo, roomRepo, studentRepo, depositRepo, bedRepo, lockerRepo: { findOne: jest.fn() } as any as Repository, organizationRepo: {} as Repository, }), ), depositRepo, }; }; const row = { name: '张三', phone: '13800138000', roomNumber: '4-102', bedNumber: '1号床', checkInDate: '2026-07-14', }; it('rejects invalid import deposit amount before writing rows', async () => { const { service, depositRepo } = createImportService(); await expect( service.batchImportCheckIn([row], { autoDeposit: true, depositAmount: 0 }), ).rejects.toThrow('押金金额必须大于0'); expect(depositRepo.create).not.toHaveBeenCalled(); expect(depositRepo.save).not.toHaveBeenCalled(); }); it('does not collect import deposit again when the student already has paid deposit', async () => { const existing = { id: 99, amount: 500, status: 'paid' } as Deposit; const { service, depositRepo } = createImportService(existing); const result = await service.batchImportCheckIn([row], { autoDeposit: true, depositAmount: 800, }); expect(result).toEqual(expect.objectContaining({ imported: 1, depositsCreated: 0 })); expect(depositRepo.create).not.toHaveBeenCalled(); expect(depositRepo.save).not.toHaveBeenCalledWith(expect.objectContaining({ id: 99 })); expect(existing.amount).toBe(500); }); it('reactivates a refunded import deposit without adding the old refunded amount', async () => { const existing = { id: 99, amount: 0, status: 'refunded', refundDate: '2026-07-01', refundAmount: 500, refundedBy: 11, refundedAt: new Date('2026-07-01T00:00:00Z'), } as Deposit; const { service, depositRepo } = createImportService(existing); const result = await service.batchImportCheckIn([row], { autoDeposit: true, depositAmount: 800, }); expect(result).toEqual(expect.objectContaining({ imported: 1, depositsCreated: 1 })); expect(depositRepo.save).toHaveBeenCalledWith( expect.objectContaining({ id: 99, amount: 800, status: 'paid', paidDate: '2026-07-14', refundDate: null, refundAmount: null, refundedBy: null, refundedAt: null, }), ); }); });