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:
2026-07-20 15:30:41 +08:00
parent bbd700369c
commit 2c0be0f033
5 changed files with 1175 additions and 0 deletions

View 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();
});
});