Files
gongxue-base/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts

501 lines
17 KiB
TypeScript

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<T>(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<Pick<Repository<ClassroomRental>, 'createQueryBuilder'>>;
let scheduleRepo: jest.Mocked<Pick<Repository<ClassSchedule>, '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>(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<ClassroomRental>([rental]);
const scheduleQb = mockQueryBuilder<ClassSchedule>([]);
(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<ClassroomRental>([]);
const scheduleQb = mockQueryBuilder<ClassSchedule>([
{
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<ClassroomRental>([]);
const scheduleQb = mockQueryBuilder<ClassSchedule>([
{
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<ClassroomRental>([]);
const scheduleQb = mockQueryBuilder<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-08-31');
expect(result).toHaveLength(0);
});
it('excludes the given rental id from rental conflict check', async () => {
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
const scheduleQb = mockQueryBuilder<ClassSchedule>([]);
(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<Pick<Repository<ClassroomRental>, 'find'>>;
let scheduleRepo: jest.Mocked<Pick<Repository<ClassSchedule>, '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>(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<ClassroomRental>,
'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'
>
>;
let classroomRepo: jest.Mocked<Pick<Repository<Classroom>, 'findOne'>>;
let organizationRepo: jest.Mocked<Pick<Repository<Organization>, 'findOne'>>;
let scheduleRepo: jest.Mocked<
Pick<
Repository<ClassSchedule>,
'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<ClassroomRental>,
'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'
>
>;
classroomRepo = { findOne: jest.fn() } as jest.Mocked<Pick<Repository<Classroom>, 'findOne'>>;
organizationRepo = { findOne: jest.fn() } as jest.Mocked<
Pick<Repository<Organization>, '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<ClassSchedule>,
'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>(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<ClassroomRental>([]));
scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
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<ClassroomRental>([]));
scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
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<ClassroomRental>([])),
} 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<ClassSchedule>([])),
} 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,
}),
);
});
});