Files
gongxue-base/apps/server/src/occupancies/occupancies.service.spec.ts

561 lines
18 KiB
TypeScript

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<T> = {
where: jest.Mock;
andWhere: jest.Mock;
setLock: jest.Mock;
getOne: jest.Mock<Promise<T | null>, []>;
};
function createQueryBuilderMock<T>(result: T | null): QueryBuilderMock<T> {
const qb = {
where: jest.fn(),
andWhere: jest.fn(),
setLock: jest.fn(),
getOne: jest.fn().mockResolvedValue(result),
} as QueryBuilderMock<T>;
qb.where.mockReturnValue(qb);
qb.andWhere.mockReturnValue(qb);
qb.setLock.mockReturnValue(qb);
return qb;
}
function createTransactionDataSource(manager: Record<string, unknown>): DataSource {
return {
options: { type: 'sqlite' },
transaction: jest.fn(async (fn: (manager: Record<string, unknown>) => unknown) => fn(manager)),
} as any as DataSource;
}
function createCheckInManager(options?: {
existingOccupancy?: Occupancy | null;
room?: Partial<Room> | null;
student?: Partial<Student> | null;
bed?: Partial<Bed> | null;
locker?: Partial<Locker> | 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<Occupancy>;
roomRepo: Repository<Room>;
studentRepo: Repository<Student>;
depositRepo: Repository<Deposit>;
bedRepo: Repository<Bed>;
lockerRepo: Repository<Locker>;
organizationRepo: Repository<any>;
}): 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<Occupancy>,
{} as Repository<Room>,
{} as Repository<Student>,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
createTransactionDataSource(manager),
{} as Repository<any>,
);
const service = new OccupanciesService(
{} as Repository<Occupancy>,
{} as Repository<Room>,
{} as Repository<Student>,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
createTransactionDataSource(manager),
{} as Repository<any>,
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<Occupancy>,
{} as Repository<Room>,
{} as Repository<Student>,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
createTransactionDataSource(manager),
{} as Repository<any>,
new OccupancyOperationsService(
{} as Repository<Occupancy>,
{} as Repository<Room>,
{} as Repository<Student>,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
createTransactionDataSource(manager),
{} as Repository<any>,
),
),
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<Occupancy>;
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<Room>;
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<Student>;
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<Bed>;
const organizationRepo = {
findOne: jest.fn().mockResolvedValue({ id: 7, name: '本机构', isHost: true }),
create: jest.fn((value) => value),
save: jest.fn(),
} as any as Repository<any>;
const service = new OccupanciesService(
occupancyRepo,
roomRepo,
studentRepo,
{ findOne: jest.fn(), create: jest.fn(), save: jest.fn() } as any as Repository<Deposit>,
bedRepo,
{ findOne: jest.fn() } as any as Repository<Locker>,
organizationRepo,
createImportTransactionDataSource({
occupancyRepo,
roomRepo,
studentRepo,
depositRepo: { findOne: jest.fn(), create: jest.fn(), save: jest.fn() } as any as Repository<Deposit>,
bedRepo,
lockerRepo: { findOne: jest.fn() } as any as Repository<Locker>,
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<Occupancy>;
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<Room>;
const studentRepo = {
findOne: jest.fn().mockResolvedValue(existingStudent),
create: jest.fn((value) => value),
save: jest.fn(),
update: jest.fn(),
} as any as Repository<Student>;
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<Bed>;
const organizationRepo = {
findOne: jest.fn(),
create: jest.fn((value) => value),
save: jest.fn(),
} as any as Repository<any>;
const service = new OccupanciesService(
occupancyRepo,
roomRepo,
studentRepo,
{ findOne: jest.fn() } as any as Repository<Deposit>,
bedRepo,
{ findOne: jest.fn() } as any as Repository<Locker>,
organizationRepo,
createImportTransactionDataSource({
occupancyRepo,
roomRepo,
studentRepo,
depositRepo: { findOne: jest.fn() } as any as Repository<Deposit>,
bedRepo,
lockerRepo: { findOne: jest.fn() } as any as Repository<Locker>,
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<Occupancy>,
{} as Repository<Room>,
{} as Repository<Student>,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
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<Occupancy>,
{} as Repository<Room>,
{} as Repository<Student>,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
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<Occupancy>;
const roomRepo = {
findOne: jest.fn().mockResolvedValue({ id: 2, roomNumber: '4-102', capacity: 4 }),
update: jest.fn(),
} as any as Repository<Room>;
const studentRepo = {
findOne: jest.fn().mockResolvedValue({ id: 3, name: '张三', organizationId: 7 }),
} as any as Repository<Student>;
const bedRepo = {
findOne: jest.fn().mockResolvedValue({ id: 4, roomId: 2, status: 'available' }),
update: jest.fn(),
} as any as Repository<Bed>;
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<Deposit>;
return {
service: new OccupanciesService(
occupancyRepo,
roomRepo,
studentRepo,
depositRepo,
bedRepo,
{ findOne: jest.fn() } as any as Repository<Locker>,
{} as Repository<any>,
createImportTransactionDataSource({
occupancyRepo,
roomRepo,
studentRepo,
depositRepo,
bedRepo,
lockerRepo: { findOne: jest.fn() } as any as Repository<Locker>,
organizationRepo: {} as Repository<any>,
}),
),
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,
}),
);
});
});