Merge pull request 'test: 为边界条件修复添加测试用例' (#29) from test/boundary-conditions into main

This commit is contained in:
2026-07-20 07:31:36 +00:00
5 changed files with 1175 additions and 0 deletions

View 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:0015:00) comes first,
// and A (14:0017: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,
});
});
});

View File

@@ -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('账期无效');
});
});
});

View File

@@ -0,0 +1,344 @@
import { Repository, DataSource } from 'typeorm';
import { OccupanciesService } from './occupancies.service';
import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity';
import { Student } from '../entities/student.entity';
import { Deposit } from '../entities/deposit.entity';
import { Bed } from '../entities/bed.entity';
import { Locker } from '../entities/locker.entity';
import { Organization } from '../entities/organization.entity';
type QueryBuilderMock<T> = {
where: jest.Mock;
andWhere: jest.Mock;
setLock: jest.Mock;
getOne: jest.Mock<Promise<T | null>, []>;
};
function createQueryBuilderMock<T>(result: T | null): QueryBuilderMock<T> {
const qb = {
where: jest.fn(),
andWhere: jest.fn(),
setLock: jest.fn(),
getOne: jest.fn().mockResolvedValue(result),
} as QueryBuilderMock<T>;
qb.where.mockReturnValue(qb);
qb.andWhere.mockReturnValue(qb);
qb.setLock.mockReturnValue(qb);
return qb;
}
function createTransactionDataSource(manager: Record<string, unknown>): DataSource {
return {
options: { type: 'sqlite' },
transaction: jest.fn(
async (fn: (manager: Record<string, unknown>) => unknown) => fn(manager),
),
} as any as DataSource;
}
function createCheckInManager(options?: {
existingOccupancy?: Occupancy | null;
room?: Partial<Room> | null;
student?: Partial<Student> | null;
bed?: Partial<Bed> | null;
locker?: Partial<Locker> | null;
occupancyCount?: number;
deposit?: Deposit | null;
}) {
const manager = {
createQueryBuilder: jest.fn(),
count: jest.fn().mockResolvedValue(options?.occupancyCount ?? 0),
findOne: jest.fn(),
create: jest.fn((_: unknown, value: unknown) => value),
save: jest.fn(async (value: any) => ({ ...value, id: value?.id ?? 10 })),
update: jest.fn(),
};
const queryResults = [
options?.existingOccupancy ?? null,
options?.room ?? { id: 2, capacity: 4, status: 'available' },
...(options?.bed !== undefined ? [options.bed] : []),
...(options?.locker !== undefined ? [options.locker] : []),
];
manager.createQueryBuilder.mockImplementation(() =>
createQueryBuilderMock(queryResults.shift() ?? null),
);
manager.findOne.mockImplementation(async (entity: unknown) => {
if (entity === Student) return options?.student ?? { id: 3, organizationId: 7 };
if (entity === Deposit) return options?.deposit ?? null;
return null;
});
return manager;
}
function createQueryRunnerDataSource(config: {
oldOccupancy?: Partial<Occupancy> | null;
newRoom?: Partial<Room> | null;
newRoomCount?: number;
}): DataSource {
const queryResults: (Record<string, unknown> | null)[] = [
(config.oldOccupancy ?? {
id: 1,
roomId: 2,
bedId: 3,
lockerId: 4,
checkInDate: '2026-07-01',
billingStartDate: '2026-07-01',
checkOutDate: null,
stayType: 'long',
responsibleOrganizationId: 7,
studentId: 5,
}) as Record<string, unknown>,
(config.newRoom !== undefined
? config.newRoom
: { id: 3, capacity: 4, status: 'available' }) as Record<string, unknown> | null,
];
const manager = {
createQueryBuilder: jest.fn().mockImplementation(() =>
createQueryBuilderMock(queryResults.shift() ?? null),
),
save: jest.fn(async (value: any) => ({ ...value, id: value?.id ?? 10 })),
update: jest.fn(),
create: jest.fn((_: unknown, value: unknown) => value),
count: jest.fn().mockResolvedValue(config.newRoomCount ?? 0),
};
return {
options: { type: 'sqlite' },
createQueryRunner: jest.fn().mockReturnValue({
connect: jest.fn().mockResolvedValue(undefined),
startTransaction: jest.fn().mockResolvedValue(undefined),
commitTransaction: jest.fn().mockResolvedValue(undefined),
rollbackTransaction: jest.fn().mockResolvedValue(undefined),
release: jest.fn().mockResolvedValue(undefined),
manager,
}),
} as any as DataSource;
}
function createImportTransactionDataSource(repos: {
occupancyRepo: Repository<Occupancy>;
roomRepo: Repository<Room>;
studentRepo: Repository<Student>;
depositRepo: Repository<Deposit>;
bedRepo: Repository<Bed>;
lockerRepo: Repository<Locker>;
organizationRepo: Repository<any>;
}): DataSource {
return createTransactionDataSource({
getRepository: jest.fn((entity: unknown) => {
if (entity === Occupancy) return repos.occupancyRepo;
if (entity === Room) return repos.roomRepo;
if (entity === Student) return repos.studentRepo;
if (entity === Deposit) return repos.depositRepo;
if (entity === Bed) return repos.bedRepo;
if (entity === Locker) return repos.lockerRepo;
if (entity === Organization) return repos.organizationRepo;
throw new Error('Unexpected repository');
}),
});
}
describe('OccupanciesService — boundary conditions', () => {
// ── checkIn capacity guards ────────────────────────────────────────────
it('1. checkIn rejects when capacity is 0 (??0 guard — fail closed)', async () => {
const manager = createCheckInManager({
room: { id: 2, capacity: 0, status: 'available' },
occupancyCount: 0,
});
const service = new OccupanciesService(
{} as Repository<Occupancy>,
{} as Repository<Room>,
{} as Repository<Student>,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
createTransactionDataSource(manager),
);
await expect(
service.checkIn({ studentId: 3, roomId: 2, checkInDate: '2026-07-10' }),
).rejects.toThrow('宿舍已满');
});
it('2. checkIn rejects when capacity is undefined (??0 guard — fail closed)', async () => {
const manager = createCheckInManager({
room: { id: 2, capacity: undefined, status: 'available' },
occupancyCount: 0,
});
const service = new OccupanciesService(
{} as Repository<Occupancy>,
{} as Repository<Room>,
{} as Repository<Student>,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
createTransactionDataSource(manager),
);
await expect(
service.checkIn({ studentId: 3, roomId: 2, checkInDate: '2026-07-10' }),
).rejects.toThrow('宿舍已满');
});
it('3. checkIn allows when count < capacity (normal path)', async () => {
const manager = createCheckInManager({
room: { id: 2, capacity: 4, status: 'available' },
occupancyCount: 2,
});
const service = new OccupanciesService(
{} as Repository<Occupancy>,
{} as Repository<Room>,
{} as Repository<Student>,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
createTransactionDataSource(manager),
);
const result = await service.checkIn({
studentId: 3,
roomId: 2,
checkInDate: '2026-07-10',
});
expect(result).toBeTruthy();
expect(manager.save).toHaveBeenCalled();
});
it('4. checkIn rejects when count >= capacity (normal rejection)', async () => {
const manager = createCheckInManager({
room: { id: 2, capacity: 4, status: 'available' },
occupancyCount: 4,
});
const service = new OccupanciesService(
{} as Repository<Occupancy>,
{} as Repository<Room>,
{} as Repository<Student>,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
createTransactionDataSource(manager),
);
await expect(
service.checkIn({ studentId: 3, roomId: 2, checkInDate: '2026-07-10' }),
).rejects.toThrow('宿舍已满');
});
// ── transferRoom capacity guard ─────────────────────────────────────────
it('5. transferRoom rejects when target room capacity is undefined (??0 guard — fail closed)', async () => {
const dataSource = createQueryRunnerDataSource({
oldOccupancy: {
id: 1,
roomId: 2,
bedId: 3,
lockerId: 4,
checkInDate: '2026-07-01',
billingStartDate: '2026-07-01',
checkOutDate: null,
stayType: 'long',
responsibleOrganizationId: 7,
studentId: 5,
},
newRoom: { id: 3, capacity: undefined, status: 'available' },
newRoomCount: 0,
});
const service = new OccupanciesService(
{} as Repository<Occupancy>,
{} as Repository<Room>,
{} as Repository<Student>,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
dataSource,
);
await expect(
service.transferRoom(1, { newRoomId: 3, transferDate: '2026-07-15' }),
).rejects.toThrow('目标宿舍已满');
});
// ── batchImportCheckIn capacity guard ───────────────────────────────────
it('6. batchImportCheckIn skips row when room capacity is undefined (??0 guard — fail closed)', async () => {
const occupancyRepo = {
findOne: jest.fn().mockResolvedValue(null),
count: jest.fn().mockResolvedValue(0),
create: jest.fn((value: any) => value),
save: jest.fn(),
} as any as Repository<Occupancy>;
const roomRepo = {
findOne: jest.fn().mockResolvedValue({
id: 2,
roomNumber: '4-102',
capacity: undefined,
}),
update: jest.fn(),
} as any as Repository<Room>;
const studentRepo = {
findOne: jest
.fn()
.mockResolvedValue({
id: 3,
name: '张三',
phone: '13800138000',
organizationId: 7,
}),
} as any as Repository<Student>;
const bedRepo = {
findOne: jest.fn(),
count: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
} as any as Repository<Bed>;
const organizationRepo = {
findOne: jest.fn(),
} as any as Repository<any>;
const service = new OccupanciesService(
occupancyRepo,
roomRepo,
studentRepo,
{ findOne: jest.fn() } as any as Repository<Deposit>,
bedRepo,
{ findOne: jest.fn() } as any as Repository<Locker>,
organizationRepo,
createImportTransactionDataSource({
occupancyRepo,
roomRepo,
studentRepo,
depositRepo: { findOne: jest.fn() } as any as Repository<Deposit>,
bedRepo,
lockerRepo: { findOne: jest.fn() } as any as Repository<Locker>,
organizationRepo,
}),
);
const result = await service.batchImportCheckIn([
{
name: '张三',
phone: '13800138000',
roomNumber: '4-102',
bedNumber: '1号床',
checkInDate: '2026-07-14',
},
]);
expect(result.imported).toBe(0);
expect(result.skipped).toBe(1);
expect(result.errors).toEqual(
expect.arrayContaining([expect.stringContaining('已满')]),
);
expect(occupancyRepo.save).not.toHaveBeenCalled();
});
});

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

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