forked from wangziqi/gongxue-base
501 lines
17 KiB
TypeScript
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,
|
|
}),
|
|
);
|
|
});
|
|
});
|