import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { ConflictException } from '@nestjs/common'; import { Not, Repository } from 'typeorm'; import { ClassroomRentalsService } from './classroom-rentals.service'; import { ClassroomRental } from '../entities/classroom-rental.entity'; import { Classroom } from '../entities/classroom.entity'; import { Organization } from '../entities/organization.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto'; function mockQueryBuilder(results: T[] = []) { const qb = { where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), leftJoinAndSelect: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue(results), }; return qb; } describe('ClassroomRentalsService — findConflicts', () => { let service: ClassroomRentalsService; let rentalRepo: jest.Mocked, 'createQueryBuilder'>>; let scheduleRepo: jest.Mocked, 'createQueryBuilder'>>; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ ClassroomRentalsService, { provide: getRepositoryToken(ClassroomRental), useValue: { createQueryBuilder: jest.fn() }, }, { provide: getRepositoryToken(Classroom), useValue: {} }, { provide: getRepositoryToken(Organization), useValue: {} }, { provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } }, ], }).compile(); service = module.get(ClassroomRentalsService); rentalRepo = module.get(getRepositoryToken(ClassroomRental)); scheduleRepo = module.get(getRepositoryToken(ClassSchedule)); }); it('returns rental conflicts when no schedule conflicts exist', async () => { const rental = { id: 1, startDate: '2026-03-01', endDate: '2026-03-31', organization: { name: 'A机构' }, } as ClassroomRental; const rentalQb = mockQueryBuilder([rental]); const scheduleQb = mockQueryBuilder([]); (rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb); (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(scheduleQb); const result = await service.findConflicts(1, '2026-03-15', '2026-04-15'); expect(result).toHaveLength(1); expect(result[0].id).toBe(1); }); it('throws ConflictException when an active schedule overlaps the same classroom and date range', async () => { const rentalQb = mockQueryBuilder([]); const scheduleQb = mockQueryBuilder([ { id: 5, subject: '数学', weekDay: 1, startDate: '2026-03-01', endDate: '2026-06-30', } as ClassSchedule, ]); (rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb); (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(scheduleQb); await expect(service.findConflicts(1, '2026-03-15', '2026-04-15')).rejects.toThrow( ConflictException, ); }); it('does not treat a weekly schedule as a conflict when its weekday does not occur in the rental range', async () => { const rentalQb = mockQueryBuilder([]); const scheduleQb = mockQueryBuilder([ { id: 5, subject: '数学', weekDay: 1, startDate: '2026-07-01', endDate: '2026-07-31', } as ClassSchedule, ]); (rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb); (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(scheduleQb); const result = await service.findConflicts(1, '2026-07-01', '2026-07-05'); expect(result).toHaveLength(0); }); it('does not throw when schedule is outside the requested date range', async () => { const rentalQb = mockQueryBuilder([]); const scheduleQb = mockQueryBuilder([]); (rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb); (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(scheduleQb); const result = await service.findConflicts(1, '2026-07-01', '2026-08-31'); expect(result).toHaveLength(0); }); it('excludes the given rental id from rental conflict check', async () => { const rentalQb = mockQueryBuilder([]); const scheduleQb = mockQueryBuilder([]); (rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb); (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(scheduleQb); await service.findConflicts(1, '2026-03-01', '2026-03-31', 99); expect(rentalQb.andWhere).toHaveBeenCalledWith('r.id != :excludeId', { excludeId: 99 }); }); }); describe('ClassroomRentalsService — unavailable dates', () => { let service: ClassroomRentalsService; let rentalRepo: jest.Mocked, 'find'>>; let scheduleRepo: jest.Mocked, 'find'>>; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ ClassroomRentalsService, { provide: getRepositoryToken(ClassroomRental), useValue: { find: jest.fn() } }, { provide: getRepositoryToken(Classroom), useValue: {} }, { provide: getRepositoryToken(Organization), useValue: {} }, { provide: getRepositoryToken(ClassSchedule), useValue: { find: jest.fn() } }, ], }).compile(); service = module.get(ClassroomRentalsService); rentalRepo = module.get(getRepositoryToken(ClassroomRental)); scheduleRepo = module.get(getRepositoryToken(ClassSchedule)); }); it('returns rental days and actual weekly schedule occurrence dates for a month', async () => { rentalRepo.find.mockResolvedValue([ { id: 10, startDate: '2026-07-03', endDate: '2026-07-04' } as ClassroomRental, ]); scheduleRepo.find.mockResolvedValue([ { id: 5, weekDay: 1, startDate: '2026-07-01', endDate: '2026-07-31' } as ClassSchedule, ]); const result = await service.getUnavailableDates(1, 2026, 7); expect(result).toEqual({ dates: ['2026-07-03', '2026-07-04', '2026-07-06', '2026-07-13', '2026-07-20', '2026-07-27'], }); }); it('excludes the rental being edited', async () => { rentalRepo.find.mockResolvedValue([]); scheduleRepo.find.mockResolvedValue([]); await service.getUnavailableDates(1, 2026, 7, 99); expect(rentalRepo.find).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ id: Not(99) }) }), ); }); }); describe('ClassroomRentalsService — rental schedule sync', () => { let service: ClassroomRentalsService; let rentalRepo: jest.Mocked< Pick< Repository, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder' > >; let classroomRepo: jest.Mocked, 'findOne'>>; let organizationRepo: jest.Mocked, 'findOne'>>; let scheduleRepo: jest.Mocked< Pick< Repository, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder' > >; beforeEach(async () => { rentalRepo = { findOne: jest.fn(), save: jest.fn(), create: jest.fn(), update: jest.fn(), delete: jest.fn(), createQueryBuilder: jest.fn(), } as jest.Mocked< Pick< Repository, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder' > >; classroomRepo = { findOne: jest.fn() } as jest.Mocked, 'findOne'>>; organizationRepo = { findOne: jest.fn() } as jest.Mocked< Pick, 'findOne'> >; scheduleRepo = { findOne: jest.fn(), save: jest.fn(), create: jest.fn(), update: jest.fn(), delete: jest.fn(), createQueryBuilder: jest.fn(), } as jest.Mocked< Pick< Repository, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder' > >; const module: TestingModule = await Test.createTestingModule({ providers: [ ClassroomRentalsService, { provide: getRepositoryToken(ClassroomRental), useValue: rentalRepo }, { provide: getRepositoryToken(Classroom), useValue: classroomRepo }, { provide: getRepositoryToken(Organization), useValue: organizationRepo }, { provide: getRepositoryToken(ClassSchedule), useValue: scheduleRepo }, ], }).compile(); service = module.get(ClassroomRentalsService); }); describe('create()', () => { it('saves the rental and creates a RENTAL class_schedule row', async () => { const dto: CreateRentalDto = { classroomId: 1, lesseeOrganizationId: 2, startDate: '2026-03-01', endDate: '2026-03-31', }; const classroom = { id: 1, status: 'available' } as Classroom; const hostOrganization = { id: 1, name: 'Host', isHost: true, status: 'active', } as Organization; const organization = { id: 2, name: 'Organization A', isHost: false, status: 'active', } as Organization; classroomRepo.findOne.mockResolvedValue(classroom); organizationRepo.findOne .mockResolvedValueOnce(hostOrganization) .mockResolvedValueOnce(organization); rentalRepo.create.mockImplementation( (entity) => ({ ...(entity as object) }) as ClassroomRental, ); rentalRepo.save.mockImplementation((entity) => Promise.resolve({ ...(entity as object), id: 1 } as ClassroomRental), ); rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); scheduleRepo.findOne.mockResolvedValue(null); scheduleRepo.create.mockImplementation( (entity) => ({ ...(entity as object) }) as ClassSchedule, ); scheduleRepo.save.mockImplementation((entity) => Promise.resolve({ ...(entity as object), id: 100 } as ClassSchedule), ); const result = await service.create(dto); expect(result.id).toBe(1); expect(rentalRepo.save).toHaveBeenCalledWith( expect.objectContaining({ classroomId: 1, lessorOrganizationId: 1, lesseeOrganizationId: 2, startDate: '2026-03-01', endDate: '2026-03-31', status: 'active', }), ); expect(scheduleRepo.create).toHaveBeenCalledWith( expect.objectContaining({ classroomId: 1, classId: null, startTime: '00:00', endTime: '23:59', startDate: '2026-03-01', endDate: '2026-03-31', subject: 'Organization A 租赁', teacherId: null, scheduleType: 'RENTAL', rentalId: 1, status: 'active', }), ); expect(scheduleRepo.save).toHaveBeenCalled(); }); }); describe('update()', () => { it('updates the existing RENTAL schedule row when dates or classroom change', async () => { const existingRental = { id: 1, classroomId: 1, lesseeOrganizationId: 2, startDate: '2026-07-01', endDate: '2099-03-31', status: 'active', notes: '', lesseeOrganization: { id: 2, name: 'Organization A' } as Organization, classroom: { id: 1 } as Classroom, } as ClassroomRental; const updatedRental = { ...existingRental, startDate: '2026-08-01', endDate: '2099-04-30', }; const existingSchedule = { id: 50, rentalId: 1, scheduleType: 'RENTAL', classroomId: 1, } as ClassSchedule; rentalRepo.findOne.mockResolvedValueOnce(existingRental).mockResolvedValueOnce(updatedRental); rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); scheduleRepo.findOne.mockResolvedValue(existingSchedule); const dto: UpdateRentalDto = { startDate: '2026-08-01', endDate: '2099-04-30' }; await service.update(1, dto); expect(rentalRepo.update).toHaveBeenCalledWith( 1, expect.objectContaining({ startDate: '2026-08-01', endDate: '2099-04-30' }), ); expect(scheduleRepo.update).toHaveBeenCalledWith( 50, expect.objectContaining({ scheduleType: 'RENTAL', rentalId: 1, classroomId: 1, startDate: '2026-08-01', endDate: '2099-04-30', status: 'active', subject: 'Organization A 租赁', }), ); expect(scheduleRepo.create).not.toHaveBeenCalled(); expect(scheduleRepo.delete).not.toHaveBeenCalled(); }); it('deactivates the RENTAL schedule row when the rental is cancelled', async () => { const rental = { id: 1, classroomId: 1, lesseeOrganizationId: 2, startDate: '2026-07-01', endDate: '2099-03-31', status: 'active', lesseeOrganization: { id: 2, name: 'Organization A' } as Organization, } as ClassroomRental; const cancelledRental = { ...rental, status: 'cancelled' } as ClassroomRental; rentalRepo.findOne.mockResolvedValueOnce(rental).mockResolvedValueOnce(cancelledRental); await service.cancel(1); expect(rentalRepo.update).toHaveBeenCalledWith(1, { status: 'cancelled' }); expect(scheduleRepo.update).toHaveBeenCalledWith( { rentalId: 1, scheduleType: 'RENTAL' }, { status: 'inactive' }, ); }); }); describe('lifecycle actions', () => { it('ends an active rental and shortens a future end date', async () => { const rental = { id: 1, classroomId: 1, startDate: '2026-07-01', endDate: '2099-12-31', status: 'active', lesseeOrganization: { name: 'Organization A' }, } as ClassroomRental; const ended = { ...rental, status: 'ended', endDate: '2026-07-13' } as ClassroomRental; rentalRepo.findOne.mockResolvedValueOnce(rental).mockResolvedValueOnce(ended); scheduleRepo.findOne.mockResolvedValue({ id: 50 } as ClassSchedule); await service.end(1); expect(rentalRepo.update).toHaveBeenCalledWith( 1, expect.objectContaining({ status: 'ended' }), ); expect(scheduleRepo.update).toHaveBeenCalled(); }); it('rejects ending a future rental', async () => { rentalRepo.findOne.mockResolvedValue({ id: 1, startDate: '2099-01-01', endDate: '2099-12-31', status: 'active', } as ClassroomRental); await expect(service.end(1)).rejects.toThrow('租赁尚未开始'); }); }); describe('remove()', () => { it('archives the rental and deactivates its RENTAL schedule row', async () => { const rental = { id: 1, classroomId: 1, lesseeOrganizationId: 2, startDate: '2026-03-01', endDate: '2026-03-31', status: 'active', lesseeOrganization: { id: 2, name: 'Organization A' } as Organization, } as ClassroomRental; rentalRepo.findOne.mockResolvedValue(rental); await service.remove(1); expect(rentalRepo.update).toHaveBeenCalledWith(1, { status: 'cancelled' }); expect(scheduleRepo.update).toHaveBeenCalledWith( { rentalId: 1, scheduleType: 'RENTAL' }, { status: 'inactive' }, ); expect(rentalRepo.delete).not.toHaveBeenCalled(); }); }); }); describe('ClassroomRentalsService — organization roles', () => { it('stores explicit lessor and lessee organizations for a rental', async () => { const rentalRepo = { findOne: jest.fn(), save: jest.fn(async (value) => ({ ...value, id: 1 })), create: jest.fn((value) => value), update: jest.fn(), delete: jest.fn(), createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder([])), } as any; const classroomRepo = { findOne: jest.fn().mockResolvedValue({ id: 1, status: 'available' }), } as any; const organizationRepo = { findOne: jest .fn() .mockResolvedValueOnce({ id: 1, name: '本机构', isHost: true, status: 'active' }) .mockResolvedValueOnce({ id: 2, name: '合作机构', isHost: false, status: 'active' }), } as any; const scheduleRepo = { findOne: jest.fn().mockResolvedValue(null), save: jest.fn(async (value) => ({ ...value, id: 100 })), create: jest.fn((value) => value), update: jest.fn(), delete: jest.fn(), createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder([])), } as any; const service = new ClassroomRentalsService( rentalRepo, classroomRepo, organizationRepo, scheduleRepo, ); await service.create({ classroomId: 1, lessorOrganizationId: 1, lesseeOrganizationId: 2, startDate: '2026-08-01', endDate: '2026-08-31', }); expect(rentalRepo.save).toHaveBeenCalledWith( expect.objectContaining({ lessorOrganizationId: 1, lesseeOrganizationId: 2, }), ); }); });