From 2c0be0f033183597e45159b42ec6382c4722cecf Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 20 Jul 2026 15:30:41 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E4=B8=BA=E8=BE=B9=E7=95=8C=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E4=BF=AE=E5=A4=8D=E6=B7=BB=E5=8A=A0=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rooms: parseRoomNumber 未知格式、楼层0、capacity ?? 测试 - occupancies: capacity undefined/null/0 fail-closed 防守测试 - schedules: startTime > endTime 拒绝测试 - attendance: 重叠检查按时间排序、raw[index] 移除测试 - expenses: 金额零/负/NaN 拒绝、period反转校验测试 5 modules, 40 tests, all passing --- .../attendance/attendance.boundaries.spec.ts | 361 ++++++++++++++++++ .../src/expenses/expenses.boundaries.spec.ts | 106 +++++ .../occupancies.boundaries.spec.ts | 344 +++++++++++++++++ .../server/src/rooms/rooms.boundaries.spec.ts | 227 +++++++++++ .../schedules/schedules.boundaries.spec.ts | 137 +++++++ 5 files changed, 1175 insertions(+) create mode 100644 apps/server/src/attendance/attendance.boundaries.spec.ts create mode 100644 apps/server/src/occupancies/occupancies.boundaries.spec.ts create mode 100644 apps/server/src/rooms/rooms.boundaries.spec.ts create mode 100644 apps/server/src/schedules/schedules.boundaries.spec.ts diff --git a/apps/server/src/attendance/attendance.boundaries.spec.ts b/apps/server/src/attendance/attendance.boundaries.spec.ts new file mode 100644 index 0000000..0587ee8 --- /dev/null +++ b/apps/server/src/attendance/attendance.boundaries.spec.ts @@ -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) => ({ + 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, + }); + }); +}); diff --git a/apps/server/src/expenses/expenses.boundaries.spec.ts b/apps/server/src/expenses/expenses.boundaries.spec.ts index 4acd85c..1b54e03 100644 --- a/apps/server/src/expenses/expenses.boundaries.spec.ts +++ b/apps/server/src/expenses/expenses.boundaries.spec.ts @@ -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('账期无效'); + }); + }); }); diff --git a/apps/server/src/occupancies/occupancies.boundaries.spec.ts b/apps/server/src/occupancies/occupancies.boundaries.spec.ts new file mode 100644 index 0000000..92fc1f2 --- /dev/null +++ b/apps/server/src/occupancies/occupancies.boundaries.spec.ts @@ -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 = { + where: jest.Mock; + andWhere: jest.Mock; + setLock: jest.Mock; + getOne: jest.Mock, []>; +}; + +function createQueryBuilderMock(result: T | null): QueryBuilderMock { + const qb = { + where: jest.fn(), + andWhere: jest.fn(), + setLock: jest.fn(), + getOne: jest.fn().mockResolvedValue(result), + } as QueryBuilderMock; + qb.where.mockReturnValue(qb); + qb.andWhere.mockReturnValue(qb); + qb.setLock.mockReturnValue(qb); + return qb; +} + +function createTransactionDataSource(manager: Record): DataSource { + return { + options: { type: 'sqlite' }, + transaction: jest.fn( + async (fn: (manager: Record) => unknown) => fn(manager), + ), + } as any as DataSource; +} + +function createCheckInManager(options?: { + existingOccupancy?: Occupancy | null; + room?: Partial | null; + student?: Partial | null; + bed?: Partial | null; + locker?: Partial | 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 | null; + newRoom?: Partial | null; + newRoomCount?: number; +}): DataSource { + const queryResults: (Record | 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, + (config.newRoom !== undefined + ? config.newRoom + : { id: 3, capacity: 4, status: 'available' }) as Record | 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; + roomRepo: Repository; + studentRepo: Repository; + depositRepo: Repository; + bedRepo: Repository; + lockerRepo: Repository; + organizationRepo: Repository; +}): 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, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + 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, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + 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, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + 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, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + 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, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + 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; + const roomRepo = { + findOne: jest.fn().mockResolvedValue({ + id: 2, + roomNumber: '4-102', + capacity: undefined, + }), + update: jest.fn(), + } as any as Repository; + const studentRepo = { + findOne: jest + .fn() + .mockResolvedValue({ + id: 3, + name: '张三', + phone: '13800138000', + organizationId: 7, + }), + } as any as Repository; + const bedRepo = { + findOne: jest.fn(), + count: jest.fn(), + create: jest.fn(), + save: jest.fn(), + update: jest.fn(), + } as any as Repository; + const organizationRepo = { + findOne: jest.fn(), + } as any as Repository; + + const service = new OccupanciesService( + occupancyRepo, + roomRepo, + studentRepo, + { findOne: jest.fn() } as any as Repository, + bedRepo, + { findOne: jest.fn() } as any as Repository, + organizationRepo, + createImportTransactionDataSource({ + occupancyRepo, + roomRepo, + studentRepo, + depositRepo: { findOne: jest.fn() } as any as Repository, + bedRepo, + lockerRepo: { findOne: jest.fn() } as any as Repository, + 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(); + }); +}); diff --git a/apps/server/src/rooms/rooms.boundaries.spec.ts b/apps/server/src/rooms/rooms.boundaries.spec.ts new file mode 100644 index 0000000..f84658c --- /dev/null +++ b/apps/server/src/rooms/rooms.boundaries.spec.ts @@ -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; + + const bedRepo = { + create: jest.fn((data: any) => data), + save: jest.fn().mockResolvedValue([]), + } as unknown as Repository; + + const occupancyRepo = {} as unknown as Repository; + const roomExpRepo = {} as unknown as Repository; + const lockerRepo = {} as unknown as Repository; + 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 = { + 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); + }); +}); diff --git a/apps/server/src/schedules/schedules.boundaries.spec.ts b/apps/server/src/schedules/schedules.boundaries.spec.ts new file mode 100644 index 0000000..34abd00 --- /dev/null +++ b/apps/server/src/schedules/schedules.boundaries.spec.ts @@ -0,0 +1,137 @@ +import { BadRequestException } from '@nestjs/common'; +import { SchedulesService } from './schedules.service'; +import { ClassroomStatus } from '../entities'; + +function mockQueryBuilder(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(); + }); +});