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);
|
||||
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