test: 为边界条件修复添加测试用例
- 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
This commit is contained in:
344
apps/server/src/occupancies/occupancies.boundaries.spec.ts
Normal file
344
apps/server/src/occupancies/occupancies.boundaries.spec.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user