import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { ConflictException } from '@nestjs/common'; import { Repository } from 'typeorm'; import { SchedulesService } from './schedules.service'; import { ClassSchedule, ScheduleType } from '../entities/class-schedule.entity'; import { ClassroomRental } from '../entities/classroom-rental.entity'; import { Class } from '../entities/class.entity'; /** Build a mock query-builder where each chain method returns `this`. */ 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 — checkConflict', () => { let service: SchedulesService; let scheduleRepo: jest.Mocked, 'createQueryBuilder'>>; let rentalRepo: jest.Mocked, 'createQueryBuilder'>>; beforeEach(async () => { const mockRepo = { createQueryBuilder: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ providers: [ SchedulesService, { provide: getRepositoryToken(ClassSchedule), useValue: mockRepo }, { provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } }, { provide: getRepositoryToken(ClassroomRental), useValue: { createQueryBuilder: jest.fn() } }, ], }).compile(); service = module.get(SchedulesService); scheduleRepo = module.get(getRepositoryToken(ClassSchedule)); rentalRepo = module.get(getRepositoryToken(ClassroomRental)); }); it('same classroom + same weekday + overlapping times → ConflictException', async () => { const qb = mockQueryBuilder([ { id: 1, subject: '数学', startTime: '08:00', endTime: '10:00' } as ClassSchedule, ]); const rentalQb = mockQueryBuilder([]); (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); (rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb); await expect( service.checkConflict(1, 3, '09:00', '11:00', '2026-03-01', '2026-06-30'), ).rejects.toThrow(ConflictException); }); it('same classroom + same weekday + non-overlapping times → no conflict', async () => { const qb = mockQueryBuilder([]); const rentalQb = mockQueryBuilder([]); (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); (rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb); await expect( service.checkConflict(1, 3, '10:00', '12:00', '2026-03-01', '2026-06-30'), ).resolves.toEqual([]); }); it('same classroom + same weekday + overlapping times but disjoint date ranges → no conflict', async () => { const qb = mockQueryBuilder([]); const rentalQb = mockQueryBuilder([]); (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); (rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb); await expect( service.checkConflict(1, 3, '09:00', '11:00', '2026-07-01', '2026-08-31'), ).resolves.toEqual([]); }); it('different classroom → no conflict', async () => { const qb = mockQueryBuilder([]); const rentalQb = mockQueryBuilder([]); (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); (rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb); await expect( service.checkConflict(2, 3, '09:00', '11:00', '2026-03-01', '2026-06-30'), ).resolves.toEqual([]); }); it('excludes the given schedule id from conflict check', async () => { const qb = mockQueryBuilder([]); const rentalQb = mockQueryBuilder([]); (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); (rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb); await service.checkConflict(1, 3, '09:00', '11:00', '2026-03-01', '2026-06-30', 42); expect(qb.andWhere).toHaveBeenCalledWith('cs.id != :excludeId', { excludeId: 42 }); }); it('overlapping classroom rental → ConflictException', async () => { const qb = mockQueryBuilder([]); const rentalQb = mockQueryBuilder([ { id: 10, startDate: '2026-03-01', endDate: '2026-03-31', status: 'active' } as ClassroomRental, ]); (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); (rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb); await expect( service.checkConflict(1, 3, '09:00', '11:00', '2026-03-01', '2026-06-30'), ).rejects.toThrow(ConflictException); }); it('cancelled rental does not conflict', async () => { const qb = mockQueryBuilder([]); const rentalQb = mockQueryBuilder([]); (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); (rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb); await expect( service.checkConflict(1, 3, '09:00', '11:00', '2026-03-01', '2026-06-30'), ).resolves.toEqual([]); }); }); describe('SchedulesService — getClassroomOccupancy', () => { let service: SchedulesService; let scheduleRepo: jest.Mocked, 'createQueryBuilder'>>; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ SchedulesService, { provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } }, { provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } }, { provide: getRepositoryToken(ClassroomRental), useValue: { createQueryBuilder: jest.fn() } }, ], }).compile(); service = module.get(SchedulesService); scheduleRepo = module.get(getRepositoryToken(ClassSchedule)); }); it('returns both INTERNAL and RENTAL schedules', async () => { const internalSchedule = { id: 1, scheduleType: ScheduleType.INTERNAL, subject: '数学', } as ClassSchedule; const rentalSchedule = { id: 2, scheduleType: ScheduleType.RENTAL, subject: 'Tenant A 租赁', } as ClassSchedule; const qb = mockQueryBuilder([internalSchedule, rentalSchedule]); (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); const result = await service.getClassroomOccupancy(1, '2026-03-15'); expect(result).toHaveLength(2); expect(result.map((s) => s.scheduleType)).toContain(ScheduleType.INTERNAL); expect(result.map((s) => s.scheduleType)).toContain(ScheduleType.RENTAL); expect(qb.where).toHaveBeenCalledWith('cs.classroomId = :classroomId', { classroomId: 1 }); expect(qb.andWhere).toHaveBeenCalledWith('cs.status = :status', { status: 'active' }); expect(qb.andWhere).toHaveBeenCalledWith('cs.scheduleType IN (:...scheduleTypes)', { scheduleTypes: [ScheduleType.INTERNAL, ScheduleType.RENTAL], }); expect(qb.andWhere).toHaveBeenCalledWith('cs.startDate <= :date', { date: '2026-03-15' }); expect(qb.andWhere).toHaveBeenCalledWith('cs.endDate >= :date', { date: '2026-03-15' }); }); });