- rooms: parseRoomNumber 未知格式、楼层0、capacity ?? 测试 - occupancies: capacity undefined/null/0 fail-closed 防守测试 - schedules: startTime > endTime 拒绝测试 - attendance: 重叠检查按时间排序、raw[index] 移除测试 - expenses: 金额零/负/NaN 拒绝、period反转校验测试 5 modules, 40 tests, all passing
345 lines
11 KiB
TypeScript
345 lines
11 KiB
TypeScript
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<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 createQueryRunnerDataSource(config: {
|
|
oldOccupancy?: Partial<Occupancy> | null;
|
|
newRoom?: Partial<Room> | null;
|
|
newRoomCount?: number;
|
|
}): DataSource {
|
|
const queryResults: (Record<string, unknown> | 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<string, unknown>,
|
|
(config.newRoom !== undefined
|
|
? config.newRoom
|
|
: { id: 3, capacity: 4, status: 'available' }) as Record<string, unknown> | 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<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');
|
|
}),
|
|
});
|
|
}
|
|
|
|
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<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: 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<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: 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<Occupancy>,
|
|
{} as Repository<Room>,
|
|
{} as Repository<Student>,
|
|
{} as Repository<Deposit>,
|
|
{} as Repository<Bed>,
|
|
{} as Repository<Locker>,
|
|
{} as Repository<any>,
|
|
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<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: 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<Occupancy>,
|
|
{} as Repository<Room>,
|
|
{} as Repository<Student>,
|
|
{} as Repository<Deposit>,
|
|
{} as Repository<Bed>,
|
|
{} as Repository<Locker>,
|
|
{} as Repository<any>,
|
|
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<Occupancy>;
|
|
const roomRepo = {
|
|
findOne: jest.fn().mockResolvedValue({
|
|
id: 2,
|
|
roomNumber: '4-102',
|
|
capacity: undefined,
|
|
}),
|
|
update: jest.fn(),
|
|
} as any as Repository<Room>;
|
|
const studentRepo = {
|
|
findOne: jest
|
|
.fn()
|
|
.mockResolvedValue({
|
|
id: 3,
|
|
name: '张三',
|
|
phone: '13800138000',
|
|
organizationId: 7,
|
|
}),
|
|
} as any as Repository<Student>;
|
|
const bedRepo = {
|
|
findOne: jest.fn(),
|
|
count: jest.fn(),
|
|
create: jest.fn(),
|
|
save: jest.fn(),
|
|
update: jest.fn(),
|
|
} as any as Repository<Bed>;
|
|
const organizationRepo = {
|
|
findOne: 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: '张三',
|
|
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();
|
|
});
|
|
});
|