forked from wangziqi/gongxue-base
test: harden business boundary conditions
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user