test: harden business boundary conditions

This commit is contained in:
2026-07-15 00:03:55 +08:00
parent 17a5046ea0
commit b1f35f9d1a
65 changed files with 2311 additions and 293 deletions

View File

@@ -71,3 +71,30 @@ describe('manual occupancy DTO bed requirements', () => {
expect(errors.some((error) => error.property === 'newLockerId')).toBe(false);
});
});
describe('occupancy date boundaries', () => {
it.each(['2026-02-31', '2026-07-13T00:00:00Z', '2026-7-13'])(
'rejects invalid or non-date-only check-in date %s',
async (checkInDate) => {
const dto = Object.assign(new CheckInDto(), {
studentId: 1,
roomId: 2,
checkInDate,
bedId: 3,
});
expect((await validate(dto)).some((error) => error.property === 'checkInDate')).toBe(true);
},
);
it('accepts a leap-day date', async () => {
const dto = Object.assign(new CheckInDto(), {
studentId: 1,
roomId: 2,
checkInDate: '2028-02-29',
bedId: 3,
});
await expect(validate(dto)).resolves.toHaveLength(0);
});
});

View File

@@ -1,4 +1,14 @@
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, Min } from 'class-validator';
import {
IsArray,
IsBoolean,
IsInt,
IsISO8601,
IsNumber,
IsOptional,
IsString,
Matches,
Min,
} from 'class-validator';
export class CheckInDto {
@IsInt()
@@ -7,11 +17,13 @@ export class CheckInDto {
@IsInt()
roomId: number;
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
checkInDate: string; // YYYY-MM-DD
@IsOptional()
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
billingStartDate?: string; // 默认=checkInDate可调整
@IsOptional()
@@ -40,11 +52,13 @@ export class CheckInDto {
}
export class CheckOutDto {
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
checkOutDate: string;
@IsOptional()
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
billingEndDate?: string; // 默认=checkOutDate
@IsOptional()
@@ -56,11 +70,13 @@ export class TransferRoomDto {
@IsInt()
newRoomId: number;
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
transferDate: string; // YYYY-MM-DD
@IsOptional()
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
oldBillingEndDate?: string; // 旧房计费截止日,默认=transferDate
@IsInt()
@@ -70,7 +86,8 @@ export class TransferRoomDto {
@IsInt()
newLockerId?: number;
@IsOptional()
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
newBillingStartDate?: string; // 新房计费起始日,默认=transferDate次日
@IsOptional()
@@ -82,11 +99,13 @@ export class BatchCheckOutDto {
@IsArray()
ids: number[];
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
checkOutDate: string; // YYYY-MM-DD
@IsOptional()
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@IsISO8601({ strict: true })
billingEndDate?: string; // 默认=checkOutDate
@IsOptional()

View File

@@ -117,8 +117,9 @@ describe('OccupanciesService — manual check-in deposit', () => {
expect(depositRepo.save).toHaveBeenCalledTimes(1);
});
it('does not create another paid deposit when one already exists', async () => {
const { service, depositRepo } = createService({ id: 99 } as Deposit);
it('adds the collected amount to the existing student deposit', async () => {
const existing = { id: 99, amount: 200, status: 'refunded' } as Deposit;
const { service, depositRepo } = createService(existing);
await service.checkIn({
studentId: 3,
@@ -130,7 +131,15 @@ describe('OccupanciesService — manual check-in deposit', () => {
});
expect(depositRepo.create).not.toHaveBeenCalled();
expect(depositRepo.save).not.toHaveBeenCalled();
expect(depositRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
id: 99,
amount: 1000,
status: 'paid',
paidDate: '2026-07-14',
notes: '入住登记自动收取',
}),
);
});
});
@@ -274,3 +283,66 @@ describe('OccupanciesService — import student matching', () => {
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 occupancyRepo = {
findOne: jest.fn().mockResolvedValue(occupancy),
save: jest.fn(),
} as any as Repository<Occupancy>;
const roomRepo = { update: jest.fn() } as any as Repository<Room>;
const bedRepo = { update: jest.fn() } as any as Repository<Bed>;
const lockerRepo = { update: jest.fn() } as any as Repository<Locker>;
const service = new OccupanciesService(
occupancyRepo,
roomRepo,
{} as Repository<Student>,
{} as Repository<Deposit>,
bedRepo,
lockerRepo,
{} as Repository<any>,
{} as DataSource,
);
await expect(service.checkOut(1, { checkOutDate: '2026-07-09' })).rejects.toThrow(
'退宿日期不能早于入住日期',
);
expect(occupancyRepo.save).not.toHaveBeenCalled();
expect(bedRepo.update).not.toHaveBeenCalled();
expect(lockerRepo.update).not.toHaveBeenCalled();
expect(roomRepo.update).not.toHaveBeenCalled();
});
it('rejects check-in to a maintenance room', async () => {
const occupancyRepo = {
findOne: jest.fn().mockResolvedValue(null),
count: jest.fn(),
} as any as Repository<Occupancy>;
const service = new OccupanciesService(
occupancyRepo,
{
findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4, status: 'maintenance' }),
} as any,
{} as Repository<Student>,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
{} as DataSource,
);
await expect(
service.checkIn({ studentId: 1, roomId: 2, checkInDate: '2026-07-10', bedId: 3 }),
).rejects.toThrow('该宿舍当前不可入住');
expect(occupancyRepo.count).not.toHaveBeenCalled();
});
});

View File

@@ -47,6 +47,8 @@ export class OccupanciesService {
}
async checkIn(dto: CheckInDto, userId?: number) {
this.assertDateOrder(dto.checkInDate, dto.billingStartDate, '计费起始日不能早于入住日期');
// 检查学生是否已有活跃入住
const existing = await this.repo.findOne({
where: { studentId: dto.studentId, checkOutDate: IsNull() },
@@ -56,6 +58,9 @@ export class OccupanciesService {
// 检查宿舍容量
const room = await this.roomRepo.findOne({ where: { id: dto.roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
if (room.status === 'archived' || room.status === 'maintenance') {
throw new BadRequestException('该宿舍当前不可入住');
}
const count = await this.repo.count({ where: { roomId: dto.roomId, checkOutDate: IsNull() } });
if (count >= room.capacity) throw new BadRequestException('宿舍已满');
@@ -138,6 +143,12 @@ export class OccupanciesService {
const occ = await this.repo.findOne({ where: { id: occupancyId } });
if (!occ) throw new NotFoundException('入住记录不存在');
if (occ.checkOutDate) throw new BadRequestException('该记录已退宿');
this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期');
this.assertDateOrder(
occ.billingStartDate || occ.checkInDate,
dto.billingEndDate || dto.checkOutDate,
'计费截止日不能早于计费起始日',
);
occ.checkOutDate = dto.checkOutDate;
occ.billingEndDate = dto.billingEndDate || dto.checkOutDate;
@@ -166,6 +177,14 @@ export class OccupanciesService {
const oldOcc = await runner.manager.findOne(Occupancy, { where: { id: occupancyId } });
if (!oldOcc) throw new NotFoundException('入住记录不存在');
if (oldOcc.checkOutDate) throw new BadRequestException('该记录已退宿');
if (oldOcc.roomId === dto.newRoomId)
throw new BadRequestException('目标宿舍不能与当前宿舍相同');
this.assertDateOrder(oldOcc.checkInDate, dto.transferDate, '换房日期不能早于原入住日期');
this.assertDateOrder(
oldOcc.billingStartDate || oldOcc.checkInDate,
dto.oldBillingEndDate || dto.transferDate,
'原宿舍计费截止日不能早于计费起始日',
);
// 退旧房
oldOcc.checkOutDate = dto.transferDate;
@@ -183,6 +202,9 @@ export class OccupanciesService {
// 检查新房容量
const newRoom = await runner.manager.findOne(Room, { where: { id: dto.newRoomId } });
if (!newRoom) throw new NotFoundException('目标宿舍不存在');
if (newRoom.status === 'archived' || newRoom.status === 'maintenance') {
throw new BadRequestException('目标宿舍当前不可入住');
}
const count = await runner.manager.count(Occupancy, {
where: { roomId: dto.newRoomId, checkOutDate: IsNull() },
});
@@ -209,6 +231,11 @@ export class OccupanciesService {
const nextDay = new Date(transferDate);
nextDay.setDate(nextDay.getDate() + 1);
const defaultBillingStart = nextDay.toISOString().split('T')[0];
this.assertDateOrder(
dto.transferDate,
dto.newBillingStartDate || defaultBillingStart,
'新宿舍计费起始日不能早于换房日期',
);
// 入住新房
const newOcc = runner.manager.create(Occupancy, {
@@ -321,6 +348,17 @@ export class OccupanciesService {
errors.push(`${occ.student?.name || id}已退宿`);
continue;
}
try {
this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期');
this.assertDateOrder(
occ.billingStartDate || occ.checkInDate,
dto.billingEndDate || dto.checkOutDate,
'计费截止日不能早于计费起始日',
);
} catch (error) {
errors.push(`${occ.student?.name || id}: ${(error as BadRequestException).message}`);
continue;
}
occ.checkOutDate = dto.checkOutDate;
occ.billingEndDate = dto.billingEndDate || dto.checkOutDate;
occ.checkOutReason = dto.checkOutReason || '';
@@ -447,12 +485,25 @@ export class OccupanciesService {
);
}
// 3. 检查是否已有活跃入住
const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0];
const checkOutDate = row.checkOutDate?.trim();
const billingStartDate = row.billingStartDate?.trim() || checkInDate;
const isHistoricalRecord = Boolean(checkOutDate);
this.assertDateOnly(checkInDate, '入住日期');
this.assertDateOnly(billingStartDate, '计费起始日');
this.assertDateOrder(checkInDate, billingStartDate, '计费起始日不能早于入住日期');
if (checkOutDate) {
this.assertDateOnly(checkOutDate, '退宿日期');
this.assertDateOrder(checkInDate, checkOutDate, '退宿日期不能早于入住日期');
this.assertDateOrder(billingStartDate, checkOutDate, '退宿日期不能早于计费起始日');
}
// 3. 检查是否已有活跃入住(历史记录不影响当前入住)
const existing = await this.repo.findOne({
where: { studentId: student.id, checkOutDate: IsNull() },
relations: ['room'],
});
if (existing) {
if (existing && !isHistoricalRecord) {
errors.push(
`${rowNum}行: ${row.name} 已在住(${existing.room?.roomNumber || '房间' + existing.roomId}),跳过`,
);
@@ -462,7 +513,7 @@ export class OccupanciesService {
// 4. 检查宿舍容量
const count = await this.repo.count({ where: { roomId: room.id, checkOutDate: IsNull() } });
if (count >= room.capacity) {
if (!isHistoricalRecord && count >= room.capacity) {
errors.push(
`${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity}),跳过 ${row.name}`,
);
@@ -471,7 +522,6 @@ export class OccupanciesService {
}
// 5. 匹配或创建床位、柜子,并校验是否可用
const isHistoricalRecord = Boolean(row.checkOutDate?.trim());
let bed: Bed | null = null;
if (row.bedNumber?.trim()) {
const bedNumber = row.bedNumber.trim();
@@ -507,12 +557,11 @@ export class OccupanciesService {
}
// 6. 创建入住记录
const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0];
const occData: any = {
studentId: student.id,
roomId: room.id,
checkInDate,
billingStartDate: row.billingStartDate?.trim() || checkInDate,
billingStartDate,
stayType: row.stayType || undefined,
responsibleOrganizationId: student.organizationId,
notes: row.notes || undefined,
@@ -520,9 +569,9 @@ export class OccupanciesService {
lockerId: locker?.id,
};
// 如果有退宿日期,直接记录
if (row.checkOutDate?.trim()) {
occData.checkOutDate = row.checkOutDate.trim();
occData.billingEndDate = row.checkOutDate.trim();
if (checkOutDate) {
occData.checkOutDate = checkOutDate;
occData.billingEndDate = checkOutDate;
}
await this.repo.save(this.repo.create(occData));
@@ -536,13 +585,15 @@ export class OccupanciesService {
}
// 9. 自动收取押金(仅对新入住且非历史记录的学生)
if (options?.autoDeposit && !row.checkOutDate?.trim()) {
if (options?.autoDeposit && !isHistoricalRecord) {
const existingDeposit = await this.depositRepo.findOne({
where: { studentId: student.id },
});
if (existingDeposit) {
existingDeposit.amount = Number(
(Number(existingDeposit.amount || 0) + Number(options.depositAmount || 500)).toFixed(2),
(Number(existingDeposit.amount || 0) + Number(options.depositAmount || 500)).toFixed(
2,
),
);
existingDeposit.status = 'paid';
existingDeposit.paidDate = checkInDate;
@@ -579,4 +630,26 @@ export class OccupanciesService {
errors: errors.length > 0 ? errors : undefined,
};
}
private assertDateOnly(value: string, label: string): void {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`);
}
const [year, month, day] = value.split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() + 1 !== month ||
date.getUTCDate() !== day
) {
throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`);
}
}
private assertDateOrder(start: string, end: string | undefined, message: string): void {
this.assertDateOnly(start, '起始日期');
if (!end) return;
this.assertDateOnly(end, '结束日期');
if (end < start) throw new BadRequestException(message);
}
}