Merge pull request 'test: 为边界条件修复添加测试用例' (#29) from test/boundary-conditions into main
This commit is contained in:
361
apps/server/src/attendance/attendance.boundaries.spec.ts
Normal file
361
apps/server/src/attendance/attendance.boundaries.spec.ts
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { AttendanceService } from './attendance.service';
|
||||||
|
import { AttendancePeriodConfig } from '../entities/attendance-period-config.entity';
|
||||||
|
import { ClassSchedule } from '../entities/class-schedule.entity';
|
||||||
|
|
||||||
|
// ── saveAttendancePeriodConfigs ──
|
||||||
|
|
||||||
|
describe('AttendanceService — saveAttendancePeriodConfigs boundaries', () => {
|
||||||
|
function createService(periodConfigRepoOverrides?: {
|
||||||
|
clear?: jest.Mock;
|
||||||
|
save?: jest.Mock;
|
||||||
|
create?: jest.Mock;
|
||||||
|
find?: jest.Mock;
|
||||||
|
count?: jest.Mock;
|
||||||
|
}) {
|
||||||
|
const periodConfigRepo = {
|
||||||
|
clear: jest.fn().mockResolvedValue(undefined),
|
||||||
|
save: jest.fn().mockImplementation((entities: unknown) => Promise.resolve(entities)),
|
||||||
|
create: jest.fn().mockImplementation((data: Partial<AttendancePeriodConfig>) => ({
|
||||||
|
id: 1,
|
||||||
|
...data,
|
||||||
|
} as AttendancePeriodConfig)),
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
count: jest.fn().mockResolvedValue(0),
|
||||||
|
...periodConfigRepoOverrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new AttendanceService(
|
||||||
|
{} as never, // attendanceRepo
|
||||||
|
{} as never, // dingRawRepo
|
||||||
|
{} as never, // classRepo
|
||||||
|
{} as never, // studentRepo
|
||||||
|
{} as never, // scheduleRepo
|
||||||
|
{} as never, // classStudentRepo
|
||||||
|
{} as never, // studentDingMappingRepo
|
||||||
|
{} as never, // classTeacherRepo
|
||||||
|
{} as never, // attendanceSessionRepo
|
||||||
|
{} as never, // attendanceDeviceRepo
|
||||||
|
periodConfigRepo as never,
|
||||||
|
{} as never, // dataSource
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects overlap when sorted by startTime even if sortOrder is inconsistent', async () => {
|
||||||
|
// A: sortOrder=1 but startTime="14:00" (later in the day)
|
||||||
|
// B: sortOrder=2 but startTime="09:00" (earlier in the day)
|
||||||
|
// When sorted by sortOrder, A comes first, then B.
|
||||||
|
// The overlap check sorts by startTime, so B (09:00–15:00) comes first,
|
||||||
|
// and A (14:00–17:00) is detected as overlapping B.
|
||||||
|
const savedPeriods: AttendancePeriodConfig[] = [];
|
||||||
|
const service = createService({
|
||||||
|
save: jest.fn().mockImplementation((entities: AttendancePeriodConfig[]) => {
|
||||||
|
savedPeriods.push(...entities);
|
||||||
|
return Promise.resolve(entities);
|
||||||
|
}),
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const dto = {
|
||||||
|
periods: [
|
||||||
|
{ periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 1, enabled: true },
|
||||||
|
{ periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '15:00', sortOrder: 2, enabled: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(service.saveAttendancePeriodConfigs(dto as any)).rejects.toThrow(BadRequestException);
|
||||||
|
await expect(service.saveAttendancePeriodConfigs(dto as any)).rejects.toThrow('时间段不能重叠');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts valid non-overlapping periods sorted by sortOrder', async () => {
|
||||||
|
const savedPeriods: AttendancePeriodConfig[] = [];
|
||||||
|
const expectedPeriods: AttendancePeriodConfig[] = [
|
||||||
|
{ id: 1, periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1, enabled: true, createdAt: expect.any(Date) as any, updatedAt: expect.any(Date) as any },
|
||||||
|
{ id: 1, periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2, enabled: true, createdAt: expect.any(Date) as any, updatedAt: expect.any(Date) as any },
|
||||||
|
{ id: 1, periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3, enabled: true, createdAt: expect.any(Date) as any, updatedAt: expect.any(Date) as any },
|
||||||
|
{ id: 1, periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4, enabled: true, createdAt: expect.any(Date) as any, updatedAt: expect.any(Date) as any },
|
||||||
|
];
|
||||||
|
|
||||||
|
// count returns > 0 so ensureAttendancePeriodConfigs does not re-seed defaults
|
||||||
|
const service = createService({
|
||||||
|
count: jest.fn().mockResolvedValue(4),
|
||||||
|
save: jest.fn().mockImplementation((entities: AttendancePeriodConfig[]) => {
|
||||||
|
savedPeriods.push(...entities);
|
||||||
|
return Promise.resolve(entities);
|
||||||
|
}),
|
||||||
|
find: jest.fn().mockResolvedValue(expectedPeriods),
|
||||||
|
});
|
||||||
|
|
||||||
|
const dto = {
|
||||||
|
periods: [
|
||||||
|
{ periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1, enabled: true },
|
||||||
|
{ periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2, enabled: true },
|
||||||
|
{ periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3, enabled: true },
|
||||||
|
{ periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4, enabled: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await service.saveAttendancePeriodConfigs(dto as any);
|
||||||
|
|
||||||
|
expect(result).toEqual(expectedPeriods);
|
||||||
|
expect(savedPeriods).toHaveLength(4);
|
||||||
|
// Verify they were saved in sortOrder order (ascending)
|
||||||
|
expect(savedPeriods[0].periodKey).toBe('morning_reading');
|
||||||
|
expect(savedPeriods[1].periodKey).toBe('morning');
|
||||||
|
expect(savedPeriods[2].periodKey).toBe('afternoon');
|
||||||
|
expect(savedPeriods[3].periodKey).toBe('evening_study');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects periods where endTime is not after startTime', async () => {
|
||||||
|
const service = createService();
|
||||||
|
|
||||||
|
const dto = {
|
||||||
|
periods: [
|
||||||
|
{ periodKey: 'bad', label: 'Bad Period', startTime: '10:00', endTime: '09:00', sortOrder: 1, enabled: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(service.saveAttendancePeriodConfigs(dto as any)).rejects.toThrow(BadRequestException);
|
||||||
|
await expect(service.saveAttendancePeriodConfigs(dto as any)).rejects.toThrow('结束时间必须晚于开始时间');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate periodKey', async () => {
|
||||||
|
const service = createService();
|
||||||
|
|
||||||
|
const dto = {
|
||||||
|
periods: [
|
||||||
|
{ periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 1, enabled: true },
|
||||||
|
{ periodKey: 'morning', label: '早课2', startTime: '14:00', endTime: '17:00', sortOrder: 2, enabled: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(service.saveAttendancePeriodConfigs(dto as any)).rejects.toThrow(BadRequestException);
|
||||||
|
await expect(service.saveAttendancePeriodConfigs(dto as any)).rejects.toThrow('重复');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty period key or label', async () => {
|
||||||
|
const service = createService();
|
||||||
|
|
||||||
|
const dto = {
|
||||||
|
periods: [
|
||||||
|
{ periodKey: ' ', label: 'Valid Label', startTime: '09:00', endTime: '12:00', sortOrder: 1, enabled: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(service.saveAttendancePeriodConfigs(dto as any)).rejects.toThrow(BadRequestException);
|
||||||
|
await expect(service.saveAttendancePeriodConfigs(dto as any)).rejects.toThrow('时段标识和名称不能为空');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disabled periods are ignored during overlap check', async () => {
|
||||||
|
const savedPeriods: AttendancePeriodConfig[] = [];
|
||||||
|
const service = createService({
|
||||||
|
count: jest.fn().mockResolvedValue(2),
|
||||||
|
save: jest.fn().mockImplementation((entities: AttendancePeriodConfig[]) => {
|
||||||
|
savedPeriods.push(...entities);
|
||||||
|
return Promise.resolve(entities);
|
||||||
|
}),
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
// A (enabled, startTime=09:00, endTime=17:00) and B (disabled, startTime=10:00, endTime=12:00, which would overlap A)
|
||||||
|
// The disabled period should be skipped in overlap check
|
||||||
|
const dto = {
|
||||||
|
periods: [
|
||||||
|
{ periodKey: 'day', label: '全天', startTime: '09:00', endTime: '17:00', sortOrder: 1, enabled: true },
|
||||||
|
{ periodKey: 'break', label: '休息', startTime: '10:00', endTime: '12:00', sortOrder: 2, enabled: false },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Should not throw — the disabled period is ignored
|
||||||
|
const result = await service.saveAttendancePeriodConfigs(dto as any);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(savedPeriods).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── getScheduleOptionsForAttendance ──
|
||||||
|
|
||||||
|
describe('AttendanceService — getScheduleOptionsForAttendance boundaries', () => {
|
||||||
|
it('returns null teacher when schedule has no matching raw entry (teacher not found)', async () => {
|
||||||
|
const entity = {
|
||||||
|
id: 42,
|
||||||
|
classId: 8,
|
||||||
|
subject: '数学',
|
||||||
|
startTime: '09:00',
|
||||||
|
endTime: '12:00',
|
||||||
|
weekDay: 1,
|
||||||
|
startDate: '2026-01-01',
|
||||||
|
endDate: '2026-12-31',
|
||||||
|
} as ClassSchedule;
|
||||||
|
|
||||||
|
// Simulate the case where getRawAndEntities returns the entity
|
||||||
|
// but `raw` is empty (no teacher row). This happens when the
|
||||||
|
// schedule has no teacher assignment.
|
||||||
|
const qb = {
|
||||||
|
leftJoin: jest.fn().mockReturnThis(),
|
||||||
|
addSelect: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
addOrderBy: jest.fn().mockReturnThis(),
|
||||||
|
getRawAndEntities: jest.fn().mockResolvedValue({
|
||||||
|
entities: [entity],
|
||||||
|
raw: [], // empty raw → no teacher info
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleRepo = {
|
||||||
|
createQueryBuilder: jest.fn().mockReturnValue(qb),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new AttendanceService(
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
scheduleRepo as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getScheduleOptionsForAttendance(8, '2026-07-20');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
id: 42,
|
||||||
|
teacherName: null,
|
||||||
|
teacherUsername: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns teacher info when raw contains a matching schedule entry', async () => {
|
||||||
|
const entity = {
|
||||||
|
id: 42,
|
||||||
|
classId: 8,
|
||||||
|
subject: '数学',
|
||||||
|
startTime: '09:00',
|
||||||
|
endTime: '12:00',
|
||||||
|
weekDay: 1,
|
||||||
|
startDate: '2026-01-01',
|
||||||
|
endDate: '2026-12-31',
|
||||||
|
} as ClassSchedule;
|
||||||
|
|
||||||
|
const qb = {
|
||||||
|
leftJoin: jest.fn().mockReturnThis(),
|
||||||
|
addSelect: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
addOrderBy: jest.fn().mockReturnThis(),
|
||||||
|
getRawAndEntities: jest.fn().mockResolvedValue({
|
||||||
|
entities: [entity],
|
||||||
|
raw: [
|
||||||
|
{
|
||||||
|
scheduleIdForTeacherMap: 42,
|
||||||
|
teacherName: '张老师',
|
||||||
|
teacherUsername: 'zhang',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleRepo = {
|
||||||
|
createQueryBuilder: jest.fn().mockReturnValue(qb),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new AttendanceService(
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
scheduleRepo as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getScheduleOptionsForAttendance(8, '2026-07-20');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
id: 42,
|
||||||
|
teacherName: '张老师',
|
||||||
|
teacherUsername: 'zhang',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null teacher when raw has a different schedule id than the entity', async () => {
|
||||||
|
const entity = {
|
||||||
|
id: 42,
|
||||||
|
classId: 8,
|
||||||
|
subject: '数学',
|
||||||
|
startTime: '09:00',
|
||||||
|
endTime: '12:00',
|
||||||
|
weekDay: 1,
|
||||||
|
startDate: '2026-01-01',
|
||||||
|
endDate: '2026-12-31',
|
||||||
|
} as ClassSchedule;
|
||||||
|
|
||||||
|
// raw contains teacher info but for a DIFFERENT schedule id (99 ≠ 42)
|
||||||
|
// The fallback `??` should kick in for schedule 42
|
||||||
|
const qb = {
|
||||||
|
leftJoin: jest.fn().mockReturnThis(),
|
||||||
|
addSelect: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
addOrderBy: jest.fn().mockReturnThis(),
|
||||||
|
getRawAndEntities: jest.fn().mockResolvedValue({
|
||||||
|
entities: [entity],
|
||||||
|
raw: [
|
||||||
|
{
|
||||||
|
scheduleIdForTeacherMap: 99,
|
||||||
|
teacherName: '张老师',
|
||||||
|
teacherUsername: 'zhang',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleRepo = {
|
||||||
|
createQueryBuilder: jest.fn().mockReturnValue(qb),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new AttendanceService(
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
scheduleRepo as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getScheduleOptionsForAttendance(8, '2026-07-20');
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
id: 42,
|
||||||
|
teacherName: null,
|
||||||
|
teacherUsername: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -104,4 +104,110 @@ describe('ExpensesService boundaries', () => {
|
|||||||
await expect(service.batchDeletePersonalExpenses([1, 2])).rejects.toBeInstanceOf(BadRequestException);
|
await expect(service.batchDeletePersonalExpenses([1, 2])).rejects.toBeInstanceOf(BadRequestException);
|
||||||
expect(personalExpRepo.createQueryBuilder).not.toHaveBeenCalled();
|
expect(personalExpRepo.createQueryBuilder).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('batchImportPersonalExpenses', () => {
|
||||||
|
it('rejects zero amount', async () => {
|
||||||
|
const { service } = createService();
|
||||||
|
const result = await service.batchImportPersonalExpenses([
|
||||||
|
{
|
||||||
|
studentName: '张三',
|
||||||
|
expenseType: '其他',
|
||||||
|
amount: 0,
|
||||||
|
expenseDate: '2026-07-01',
|
||||||
|
description: '测试',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.imported).toBe(0);
|
||||||
|
expect(result.skipped).toBe(1);
|
||||||
|
expect(result.errors).toBeDefined();
|
||||||
|
expect(result.errors![0]).toContain('费用金额必须大于0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects negative amount', async () => {
|
||||||
|
const { service } = createService();
|
||||||
|
const result = await service.batchImportPersonalExpenses([
|
||||||
|
{
|
||||||
|
studentName: '张三',
|
||||||
|
expenseType: '其他',
|
||||||
|
amount: -100,
|
||||||
|
expenseDate: '2026-07-01',
|
||||||
|
description: '测试',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.imported).toBe(0);
|
||||||
|
expect(result.skipped).toBe(1);
|
||||||
|
expect(result.errors).toBeDefined();
|
||||||
|
expect(result.errors![0]).toContain('费用金额必须大于0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects NaN amount', async () => {
|
||||||
|
const { service } = createService();
|
||||||
|
const result = await service.batchImportPersonalExpenses([
|
||||||
|
{
|
||||||
|
studentName: '张三',
|
||||||
|
expenseType: '其他',
|
||||||
|
amount: NaN,
|
||||||
|
expenseDate: '2026-07-01',
|
||||||
|
description: '测试',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.imported).toBe(0);
|
||||||
|
expect(result.skipped).toBe(1);
|
||||||
|
expect(result.errors).toBeDefined();
|
||||||
|
expect(result.errors![0]).toContain('费用金额最多保留两位小数');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows valid positive amount', async () => {
|
||||||
|
const { service, personalExpRepo } = createService();
|
||||||
|
const result = await service.batchImportPersonalExpenses([
|
||||||
|
{
|
||||||
|
studentName: '张三',
|
||||||
|
expenseType: '其他',
|
||||||
|
amount: 500,
|
||||||
|
expenseDate: '2026-07-01',
|
||||||
|
description: '测试',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.imported).toBe(1);
|
||||||
|
expect(result.skipped).toBe(0);
|
||||||
|
expect(personalExpRepo.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows fractional amount with two decimal places', async () => {
|
||||||
|
const { service, personalExpRepo } = createService();
|
||||||
|
const result = await service.batchImportPersonalExpenses([
|
||||||
|
{
|
||||||
|
studentName: '张三',
|
||||||
|
expenseType: '其他',
|
||||||
|
amount: 99.99,
|
||||||
|
expenseDate: '2026-07-01',
|
||||||
|
description: '测试',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.imported).toBe(1);
|
||||||
|
expect(result.skipped).toBe(0);
|
||||||
|
expect(personalExpRepo.save).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('batchImportUtilityExpenses', () => {
|
||||||
|
it('rejects reversed period (end before start)', async () => {
|
||||||
|
const { service } = createService();
|
||||||
|
const result = await service.batchImportUtilityExpenses([
|
||||||
|
{
|
||||||
|
periodStr: '2026-02-08 ~ 2026-01-21',
|
||||||
|
roomNumber: '101',
|
||||||
|
electricityAmount: 100,
|
||||||
|
electricityFee: 50,
|
||||||
|
waterAmount: 0,
|
||||||
|
waterFee: 0,
|
||||||
|
totalFee: 50,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.imported).toBe(0);
|
||||||
|
expect(result.skipped).toBe(1);
|
||||||
|
expect(result.errors).toBeDefined();
|
||||||
|
expect(result.errors![0]).toContain('账期无效');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
227
apps/server/src/rooms/rooms.boundaries.spec.ts
Normal file
227
apps/server/src/rooms/rooms.boundaries.spec.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { DataSource, Repository } from 'typeorm';
|
||||||
|
import { Bed } from '../entities/bed.entity';
|
||||||
|
import { Locker } from '../entities/locker.entity';
|
||||||
|
import { Occupancy } from '../entities/occupancy.entity';
|
||||||
|
import { Room } from '../entities/room.entity';
|
||||||
|
import { RoomExpense } from '../entities/room-expense.entity';
|
||||||
|
import { RoomsService } from './rooms.service';
|
||||||
|
import { RoomsController } from './rooms.controller';
|
||||||
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
|
|
||||||
|
// ── Controller test mocks ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const mockExcelEachRow = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('exceljs', () => ({
|
||||||
|
Workbook: jest.fn().mockImplementation(() => ({
|
||||||
|
xlsx: { load: jest.fn().mockResolvedValue(undefined) },
|
||||||
|
worksheets: [{ eachRow: mockExcelEachRow }],
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../common/request-utils', () => ({
|
||||||
|
extractRequestInfo: jest.fn(() => ({ ipAddress: '::1', userAgent: 'jest' })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ── Service: parseRoomNumber boundary conditions ───────────────────────
|
||||||
|
|
||||||
|
describe('RoomsService — parseRoomNumber boundary conditions', () => {
|
||||||
|
// 1
|
||||||
|
it('returns default {capacity:4, roomType:"四人间"} for unrecognised formats', () => {
|
||||||
|
expect(RoomsService.parseRoomNumber('A101')).toEqual({ capacity: 4, roomType: '四人间' });
|
||||||
|
expect(RoomsService.parseRoomNumber('仓库')).toEqual({ capacity: 4, roomType: '四人间' });
|
||||||
|
expect(RoomsService.parseRoomNumber('')).toEqual({ capacity: 4, roomType: '四人间' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2
|
||||||
|
it('preserves floor 0 — parseInt("0") should not become undefined via ||', () => {
|
||||||
|
// "4-0" in standard format: roomPart = "0", charAt(0) = "0",
|
||||||
|
// parseInt("0", 10) = 0, which is falsy but NOT NaN.
|
||||||
|
// The code must use Number.isNaN(), not ||, to keep floor: 0.
|
||||||
|
const result = RoomsService.parseRoomNumber('4-0');
|
||||||
|
expect(result.floor).toBe(0);
|
||||||
|
expect(result.floor).not.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3 — moved below into the batchImport describe (needs instantiated service)
|
||||||
|
|
||||||
|
// 4
|
||||||
|
it('parses standard format "4-102" correctly', () => {
|
||||||
|
const result = RoomsService.parseRoomNumber('4-102');
|
||||||
|
expect(result).toEqual({
|
||||||
|
building: '4号楼',
|
||||||
|
floor: 1,
|
||||||
|
roomType: '四人间',
|
||||||
|
capacity: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5
|
||||||
|
it('recognises building 2 as 单人间 (capacity 1)', () => {
|
||||||
|
const result = RoomsService.parseRoomNumber('2-301');
|
||||||
|
expect(result).toEqual({
|
||||||
|
building: '2号楼',
|
||||||
|
floor: 3,
|
||||||
|
roomType: '单人间',
|
||||||
|
capacity: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6
|
||||||
|
it('recognises building 8 as 爆改房 (capacity 2)', () => {
|
||||||
|
const result = RoomsService.parseRoomNumber('8-102');
|
||||||
|
expect(result).toEqual({
|
||||||
|
building: '8号楼',
|
||||||
|
floor: 1,
|
||||||
|
roomType: '爆改房',
|
||||||
|
capacity: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7
|
||||||
|
it('parses family format "1-2-101" correctly', () => {
|
||||||
|
const result = RoomsService.parseRoomNumber('1-2-101');
|
||||||
|
expect(result).toEqual({
|
||||||
|
building: '1-2栋',
|
||||||
|
floor: 1,
|
||||||
|
roomType: '家庭房',
|
||||||
|
capacity: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Service: batchImport boundary conditions ───────────────────────────
|
||||||
|
|
||||||
|
describe('RoomsService — batchImport boundary conditions', () => {
|
||||||
|
const createService = () => {
|
||||||
|
const saved: any[] = [];
|
||||||
|
|
||||||
|
const roomRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
create: jest.fn((data: any) => data),
|
||||||
|
save: jest.fn().mockImplementation((entity: any) => {
|
||||||
|
const record = { id: saved.length + 1, ...entity };
|
||||||
|
saved.push(record);
|
||||||
|
return Promise.resolve(record);
|
||||||
|
}),
|
||||||
|
} as unknown as Repository<Room>;
|
||||||
|
|
||||||
|
const bedRepo = {
|
||||||
|
create: jest.fn((data: any) => data),
|
||||||
|
save: jest.fn().mockResolvedValue([]),
|
||||||
|
} as unknown as Repository<Bed>;
|
||||||
|
|
||||||
|
const occupancyRepo = {} as unknown as Repository<Occupancy>;
|
||||||
|
const roomExpRepo = {} as unknown as Repository<RoomExpense>;
|
||||||
|
const lockerRepo = {} as unknown as Repository<Locker>;
|
||||||
|
const dataSource = {} as unknown as DataSource;
|
||||||
|
|
||||||
|
const service = new RoomsService(
|
||||||
|
roomRepo,
|
||||||
|
occupancyRepo,
|
||||||
|
roomExpRepo,
|
||||||
|
bedRepo,
|
||||||
|
lockerRepo,
|
||||||
|
dataSource,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, roomRepo, bedRepo };
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3
|
||||||
|
it('preserves explicit capacity 0 via ?? (not ||)', async () => {
|
||||||
|
const { service, roomRepo } = createService();
|
||||||
|
|
||||||
|
await service.batchImport([
|
||||||
|
{ roomNumber: '4-102', capacity: 0 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// If || were used, 0 would be falsy and fall through to parsed.capacity (4).
|
||||||
|
// Because ?? is used, explicit 0 stays 0.
|
||||||
|
expect(roomRepo.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ capacity: 0, roomNumber: '4-102' }),
|
||||||
|
);
|
||||||
|
expect(roomRepo.save).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to parsed capacity when row.capacity is undefined', async () => {
|
||||||
|
const { service, roomRepo } = createService();
|
||||||
|
|
||||||
|
await service.batchImport([
|
||||||
|
{ roomNumber: '4-102' }, // no capacity field
|
||||||
|
{ roomNumber: '2-301' }, // building 2 → parsed capacity 1
|
||||||
|
]);
|
||||||
|
|
||||||
|
const calls = (roomRepo.save as jest.Mock).mock.calls;
|
||||||
|
expect(calls[0][0]).toMatchObject({ capacity: 4, roomNumber: '4-102' });
|
||||||
|
expect(calls[1][0]).toMatchObject({ capacity: 1, roomNumber: '2-301' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the default 4 when both row.capacity and parsed.capacity are undefined', async () => {
|
||||||
|
const { service, roomRepo } = createService();
|
||||||
|
|
||||||
|
// "A101" is unrecognised → parsed = { capacity: 4, roomType: '四人间' }
|
||||||
|
// row has no capacity → row.capacity ?? parsed.capacity ?? 4 = 4
|
||||||
|
await service.batchImport([
|
||||||
|
{ roomNumber: 'A101' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(roomRepo.save).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ capacity: 4, roomNumber: 'A101' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Controller: boundary conditions ────────────────────────────────────
|
||||||
|
|
||||||
|
describe('RoomsController — boundary conditions', () => {
|
||||||
|
const mockLogService = {
|
||||||
|
log: jest.fn().mockResolvedValue(undefined),
|
||||||
|
} as unknown as OperationLogsService;
|
||||||
|
|
||||||
|
const mockRoomsService = {
|
||||||
|
batchImport: jest.fn().mockResolvedValue({
|
||||||
|
message: '成功导入 1 间宿舍,跳过 0 条(重复或空行)',
|
||||||
|
imported: 1,
|
||||||
|
skipped: 0,
|
||||||
|
}),
|
||||||
|
} as unknown as RoomsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('importExcel passes capacity=4 when Excel cell is 0 (|| instead of ??)', async () => {
|
||||||
|
mockExcelEachRow.mockImplementation((cb: (row: any, idx: number) => void) => {
|
||||||
|
// idx 1 = header → skip
|
||||||
|
// idx 2 = data row
|
||||||
|
const dataRow = {
|
||||||
|
getCell: jest.fn((col: number) => {
|
||||||
|
const values: Record<number, any> = {
|
||||||
|
1: '4-102',
|
||||||
|
2: '',
|
||||||
|
3: 1,
|
||||||
|
4: 0, // <-- capacity explicitly 0 in spreadsheet
|
||||||
|
5: '',
|
||||||
|
6: '',
|
||||||
|
7: '',
|
||||||
|
};
|
||||||
|
return { value: values[col] ?? '' };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
cb(dataRow, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const controller = new RoomsController(mockRoomsService, mockLogService);
|
||||||
|
const file = { buffer: Buffer.from('fake') } as Express.Multer.File;
|
||||||
|
const req = { user: { id: 1, username: 'tester' } };
|
||||||
|
|
||||||
|
await controller.importExcel(file, req);
|
||||||
|
|
||||||
|
expect(mockRoomsService.batchImport).toHaveBeenCalledTimes(1);
|
||||||
|
const rows = (mockRoomsService.batchImport as jest.Mock).mock.calls[0][0];
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
// Bug: || turns 0 into 4 — capacity should be 4, not 0
|
||||||
|
expect(rows[0].capacity).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
137
apps/server/src/schedules/schedules.boundaries.spec.ts
Normal file
137
apps/server/src/schedules/schedules.boundaries.spec.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { SchedulesService } from './schedules.service';
|
||||||
|
import { ClassroomStatus } from '../entities';
|
||||||
|
|
||||||
|
function mockQueryBuilder<T>(results: T[] = []) {
|
||||||
|
const qb = {
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
addOrderBy: jest.fn().mockReturnThis(),
|
||||||
|
getMany: jest.fn().mockResolvedValue(results),
|
||||||
|
};
|
||||||
|
return qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SchedulesService — boundary conditions', () => {
|
||||||
|
const validDto = {
|
||||||
|
classId: 1,
|
||||||
|
classroomId: 2,
|
||||||
|
weekDay: 1,
|
||||||
|
startTime: '09:00',
|
||||||
|
endTime: '10:00',
|
||||||
|
startDate: '2026-07-01',
|
||||||
|
endDate: '2026-07-31',
|
||||||
|
subject: '数学',
|
||||||
|
teacherId: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Minimal service where only scheduleRepo.create is mocked, used for early-rejection tests. */
|
||||||
|
function makeMinimalService() {
|
||||||
|
const scheduleRepo = { create: jest.fn() };
|
||||||
|
return {
|
||||||
|
service: new SchedulesService(
|
||||||
|
scheduleRepo as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
),
|
||||||
|
scheduleRepo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full service where all downstream mocks are wired so create can succeed end-to-end. */
|
||||||
|
function makeFullService() {
|
||||||
|
const scheduleRepo = {
|
||||||
|
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder([])),
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
};
|
||||||
|
const classroomRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({
|
||||||
|
id: 2,
|
||||||
|
status: ClassroomStatus.AVAILABLE,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const classTeacherRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue({
|
||||||
|
classId: 1,
|
||||||
|
userId: 1,
|
||||||
|
}),
|
||||||
|
find: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
const rentalRepo = {
|
||||||
|
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder([])),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
service: new SchedulesService(
|
||||||
|
scheduleRepo as never,
|
||||||
|
{} as never,
|
||||||
|
classroomRepo as never,
|
||||||
|
rentalRepo as never,
|
||||||
|
classTeacherRepo as never,
|
||||||
|
{} as never,
|
||||||
|
),
|
||||||
|
scheduleRepo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('create rejects reversed startTime/endTime before repository access', async () => {
|
||||||
|
const { service, scheduleRepo } = makeMinimalService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create({ ...validDto, startTime: '14:00', endTime: '13:00' }),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create({ ...validDto, startTime: '14:00', endTime: '13:00' }),
|
||||||
|
).rejects.toThrow('上课时间不能晚于下课时间');
|
||||||
|
|
||||||
|
// Validation short-circuits before any repository call.
|
||||||
|
expect(scheduleRepo.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create allows startTime < endTime and proceeds through full create flow', async () => {
|
||||||
|
const { service, scheduleRepo } = makeFullService();
|
||||||
|
|
||||||
|
const savedSchedule = {
|
||||||
|
id: 1,
|
||||||
|
...validDto,
|
||||||
|
startTime: '09:00',
|
||||||
|
endTime: '12:00',
|
||||||
|
status: 'active',
|
||||||
|
};
|
||||||
|
scheduleRepo.create.mockReturnValue(savedSchedule);
|
||||||
|
scheduleRepo.save.mockResolvedValue(savedSchedule);
|
||||||
|
scheduleRepo.findOne.mockResolvedValue(savedSchedule);
|
||||||
|
|
||||||
|
const result = await service.create({
|
||||||
|
...validDto,
|
||||||
|
startTime: '09:00',
|
||||||
|
endTime: '12:00',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not throw during validation.
|
||||||
|
expect(result).toMatchObject({ startTime: '09:00', endTime: '12:00' });
|
||||||
|
expect(scheduleRepo.create).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create rejects equal startTime/endTime before repository access', async () => {
|
||||||
|
const { service, scheduleRepo } = makeMinimalService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create({ ...validDto, startTime: '09:00', endTime: '09:00' }),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.create({ ...validDto, startTime: '09:00', endTime: '09:00' }),
|
||||||
|
).rejects.toThrow('上课时间和下课时间不能相同');
|
||||||
|
|
||||||
|
// Validation short-circuits before any repository call.
|
||||||
|
expect(scheduleRepo.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user