import { describe, it, expect, vi, beforeEach } from 'vitest' import { AppError } from '../../http/errors' // ── repo mock ────────────────────────────────────────────────────────────── vi.mock('./reservation.repo', () => ({ findMany: vi.fn(), findById: vi.fn(), findByIdSimple: vi.fn(), findByIdWithRelations: vi.fn(), findByIdForCheckout: vi.fn(), findForLicenseCheck: vi.fn(), findForClose: vi.fn(), findForInspection: vi.fn(), findConflict: vi.fn(), create: vi.fn(), updateById: vi.fn(), findActiveOffer: vi.fn(), incrementOfferRedemption: vi.fn(), findVehicle: vi.fn(), findCustomer: vi.fn(), findCustomerById: vi.fn(), updateVehicleStatus: vi.fn(), findCompanyWithBrand: vi.fn(), findBrandLocale: vi.fn(), findInspections: vi.fn(), findAdditionalDriver: vi.fn(), updateAdditionalDriver: vi.fn(), })) vi.mock('./reservation.pricing.service', () => ({ applyPricingRules: vi.fn(), calculateUpdatedInsuranceCharge: vi.fn(), calculateUpdatedAdditionalDriverCharge: vi.fn(), })) vi.mock('./reservation.insurance.service', () => ({ applyInsurancesToReservation: vi.fn(), })) vi.mock('./reservation.additional-driver.service', () => ({ applyAdditionalDriversToReservation: vi.fn(), approveAdditionalDriver: vi.fn(), })) vi.mock('../../services/licenseValidationService', () => ({ validateAndFlagLicense: vi.fn().mockResolvedValue(undefined), validateLicense: vi.fn(), })) vi.mock('../../services/notificationService', () => ({ sendNotification: vi.fn().mockResolvedValue(undefined), sendTransactionalEmail: vi.fn().mockResolvedValue(undefined), })) vi.mock('../../lib/prisma', () => ({ prisma: { reservation: { update: vi.fn() }, damageInspection: { upsert: vi.fn() }, damageReport: { upsert: vi.fn() }, }, })) vi.mock('./reservation.presenter', () => ({ serializeReservationForDashboard: vi.fn((r) => r), parseReservationExtras: vi.fn(() => ({})), buildReservationWorkflow: vi.fn(), normalizeOptionalString: vi.fn((s) => s), })) import * as repo from './reservation.repo' import * as pricingService from './reservation.pricing.service' import * as insuranceService from './reservation.insurance.service' import * as additionalDriverService from './reservation.additional-driver.service' import { validateLicense } from '../../services/licenseValidationService' import { sendNotification } from '../../services/notificationService' import { buildReservationWorkflow } from './reservation.presenter' import { createReservation, listReservations } from './reservation.service' import { confirmReservation, checkinReservation, checkoutReservation, closeReservation } from './reservation.lifecycle.service' import { approveAdditionalDriver } from './reservation.additional-driver.service' const COMPANY = 'company-1' const RES_ID = 'reservation-1' function makeVehicle(overrides: object = {}) { return { id: 'vehicle-1', dailyRate: 100, status: 'AVAILABLE', isPublished: true, mileage: 50000, ...overrides } } function makeReservation(overrides: object = {}) { return { id: RES_ID, companyId: COMPANY, vehicleId: 'vehicle-1', customerId: 'customer-1', status: 'DRAFT', startDate: new Date('2025-06-01'), endDate: new Date('2025-06-04'), totalDays: 3, dailyRate: 100, depositAmount: 0, discountAmount: 0, totalAmount: 300, additionalDrivers: [], insurances: [], customer: { firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com', licenseExpiry: null, licenseValidationStatus: 'APPROVED' }, vehicle: { year: 2022, make: 'Toyota', model: 'Camry' }, extras: {}, ...overrides, } } beforeEach(() => { vi.clearAllMocks() }) // ──────────────────────────────────────────────────────────────────────────── describe('listReservations', () => { it('passes search across vehicle, customer, contract, status and source fields', async () => { vi.mocked(repo.findMany).mockResolvedValue([[makeReservation({ vehicle: { make: 'Citroen', model: 'C4' } })], 1] as any) await expect(listReservations(COMPANY, { search: 'c4', pageSize: 100 })).resolves.toMatchObject({ total: 1, page: 1, pageSize: 100, }) expect(repo.findMany).toHaveBeenCalledWith( expect.objectContaining({ companyId: COMPANY, OR: expect.arrayContaining([ { vehicle: { is: { model: { contains: 'c4', mode: 'insensitive' } } } }, { vehicle: { is: { make: { contains: 'c4', mode: 'insensitive' } } } }, { customer: { is: { email: { contains: 'c4', mode: 'insensitive' } } } }, { contractNumber: { contains: 'c4', mode: 'insensitive' } }, ]), }), 0, 100, ) }) it('adds exact enum filters for status and source search terms', async () => { vi.mocked(repo.findMany).mockResolvedValue([[], 0] as any) await listReservations(COMPANY, { search: 'public site' }) expect(repo.findMany).toHaveBeenCalledWith( expect.objectContaining({ OR: expect.arrayContaining([{ source: 'PUBLIC_SITE' }]), }), 0, 20, ) }) }) // ──────────────────────────────────────────────────────────────────────────── describe('createReservation', () => { it('creates a reservation and returns it', async () => { vi.mocked(repo.findVehicle).mockResolvedValue(makeVehicle()) vi.mocked(repo.findCustomer).mockResolvedValue({ id: 'customer-1' } as any) vi.mocked(repo.findConflict).mockResolvedValue(null) vi.mocked(repo.findActiveOffer).mockResolvedValue(null) vi.mocked(pricingService.applyPricingRules).mockResolvedValue({ applied: [], total: 0 }) vi.mocked(repo.create).mockResolvedValue(makeReservation() as any) const result = await createReservation(COMPANY, { vehicleId: 'vehicle-1', customerId: 'customer-1', startDate: '2025-06-01T00:00:00.000Z', endDate: '2025-06-04T00:00:00.000Z', depositAmount: 0, selectedInsurancePolicyIds: [], additionalDrivers: [], }) expect(repo.create).toHaveBeenCalledOnce() expect(result.id).toBe(RES_ID) }) it('throws conflict error when vehicle is unavailable', async () => { vi.mocked(repo.findVehicle).mockResolvedValue(makeVehicle()) vi.mocked(repo.findCustomer).mockResolvedValue({ id: 'customer-1' } as any) vi.mocked(repo.findConflict).mockResolvedValue({ id: 'other-res' } as any) await expect( createReservation(COMPANY, { vehicleId: 'vehicle-1', customerId: 'customer-1', startDate: '2025-06-01T00:00:00.000Z', endDate: '2025-06-04T00:00:00.000Z', selectedInsurancePolicyIds: [], additionalDrivers: [], }), ).rejects.toThrow('Vehicle is not available for the selected dates') expect(repo.create).not.toHaveBeenCalled() }) }) // ──────────────────────────────────────────────────────────────────────────── describe('confirmReservation', () => { it('confirms a DRAFT reservation', async () => { const res = makeReservation({ status: 'DRAFT', pickupLocation: 'Casablanca Airport Terminal 1' }) vi.mocked(repo.findByIdSimple).mockResolvedValue(res as any) vi.mocked(repo.findForLicenseCheck).mockResolvedValue({ ...res, customer: { ...res.customer, licenseExpiry: null, licenseValidationStatus: 'APPROVED' }, additionalDrivers: [], } as any) vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any) vi.mocked(repo.updateById).mockResolvedValue({ ...res, status: 'CONFIRMED' } as any) vi.mocked(repo.findCustomerById).mockResolvedValue({ email: 'ali@test.com', firstName: 'Ali' } as any) vi.mocked(repo.findCompanyWithBrand).mockResolvedValue({ name: 'TestCo', brand: { displayName: 'TestCo', defaultLocale: 'fr' } } as any) vi.mocked(repo.findVehicle).mockResolvedValue({ year: 2022, make: 'Toyota', model: 'Camry' } as any) const result = await confirmReservation(RES_ID, COMPANY) expect(repo.updateById).toHaveBeenCalledWith(RES_ID, { status: 'CONFIRMED' }) expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({ templateKey: 'booking.confirmed', templateVariables: expect.objectContaining({ pickupLocation: 'Casablanca Airport Terminal 1', startDate: expect.objectContaining({ type: 'date', value: res.startDate, }), endDate: expect.objectContaining({ type: 'date', value: res.endDate, }), }), })) expect((result as any).status).toBe('CONFIRMED') }) it('rejects non-DRAFT reservation', async () => { vi.mocked(repo.findByIdSimple).mockResolvedValue(makeReservation({ status: 'CONFIRMED' }) as any) await expect(confirmReservation(RES_ID, COMPANY)).rejects.toThrow('Only DRAFT reservations can be confirmed') }) }) // ──────────────────────────────────────────────────────────────────────────── describe('checkinReservation', () => { it('transitions CONFIRMED → ACTIVE and marks vehicle RENTED', async () => { const res = makeReservation({ status: 'CONFIRMED' }) vi.mocked(repo.findByIdSimple).mockResolvedValue(res as any) vi.mocked(repo.findForLicenseCheck).mockResolvedValue({ ...res, customer: { ...res.customer, licenseExpiry: null, licenseValidationStatus: 'APPROVED' }, additionalDrivers: [], } as any) vi.mocked(validateLicense).mockReturnValue({ status: 'VALID', requiresApproval: false } as any) vi.mocked(repo.updateById).mockResolvedValue({ ...res, status: 'ACTIVE' } as any) vi.mocked(repo.updateVehicleStatus).mockResolvedValue(undefined as any) await checkinReservation(RES_ID, COMPANY, 55000) expect(repo.updateById).toHaveBeenCalledWith(RES_ID, expect.objectContaining({ status: 'ACTIVE', checkInMileage: 55000 })) expect(repo.updateVehicleStatus).toHaveBeenCalledWith('vehicle-1', 'RENTED') }) it('rejects non-CONFIRMED reservation', async () => { vi.mocked(repo.findByIdSimple).mockResolvedValue(makeReservation({ status: 'ACTIVE' }) as any) await expect(checkinReservation(RES_ID, COMPANY)).rejects.toThrow('Only CONFIRMED reservations can be checked in') }) }) // ──────────────────────────────────────────────────────────────────────────── describe('checkoutReservation', () => { it('transitions ACTIVE → COMPLETED and marks vehicle RETURNED', async () => { const res = makeReservation({ status: 'ACTIVE' }) vi.mocked(repo.findByIdForCheckout).mockResolvedValue(res as any) vi.mocked(repo.updateById).mockResolvedValue({ ...res, status: 'COMPLETED' } as any) vi.mocked(repo.updateVehicleStatus).mockResolvedValue(undefined as any) vi.mocked(repo.findCustomerById).mockResolvedValue({ email: 'ali@test.com', firstName: 'Ali' } as any) vi.mocked(repo.findCompanyWithBrand).mockResolvedValue({ name: 'TestCo', brand: { displayName: 'TestCo', defaultLocale: 'fr' } } as any) await checkoutReservation(RES_ID, COMPANY, 58000) expect(repo.updateById).toHaveBeenCalledWith(RES_ID, expect.objectContaining({ status: 'COMPLETED', checkOutMileage: 58000 })) expect(repo.updateVehicleStatus).toHaveBeenCalledWith('vehicle-1', 'RETURNED', 58000) }) it('rejects non-ACTIVE reservation', async () => { vi.mocked(repo.findByIdForCheckout).mockResolvedValue(makeReservation({ status: 'CONFIRMED' }) as any) await expect(checkoutReservation(RES_ID, COMPANY)).rejects.toThrow('Only ACTIVE reservations can be checked out') }) }) // ──────────────────────────────────────────────────────────────────────────── describe('closeReservation', () => { it('closes a COMPLETED reservation', async () => { const res = makeReservation({ status: 'COMPLETED' }) vi.mocked(repo.findForClose).mockResolvedValue(res as any) vi.mocked(buildReservationWorkflow).mockReturnValue({ closed: false } as any) const { prisma } = await import('../../lib/prisma') vi.mocked(prisma.reservation.update).mockResolvedValue({ ...res, extras: { reservationClosedBy: 'Admin User' } } as any) await closeReservation(RES_ID, COMPANY, 'Admin User') expect(prisma.reservation.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: RES_ID } }), ) }) it('rejects already closed reservation', async () => { vi.mocked(repo.findForClose).mockResolvedValue(makeReservation({ status: 'COMPLETED' }) as any) vi.mocked(buildReservationWorkflow).mockReturnValue({ closed: true } as any) await expect(closeReservation(RES_ID, COMPANY, 'Admin')).rejects.toThrow('already closed') }) }) // ──────────────────────────────────────────────────────────────────────────── describe('approveAdditionalDriver', () => { it('calls through to the additional driver service', async () => { vi.mocked(additionalDriverService.approveAdditionalDriver).mockResolvedValue({ id: 'driver-1', approvedAt: new Date() } as any) const result = await approveAdditionalDriver(RES_ID, 'driver-1', COMPANY, true, 'Looks good', 'Manager') expect(additionalDriverService.approveAdditionalDriver).toHaveBeenCalledWith(RES_ID, 'driver-1', COMPANY, true, 'Looks good', 'Manager') expect((result as any).id).toBe('driver-1') }) }) // ──────────────────────────────────────────────────────────────────────────── describe('pricing rule application', () => { it('applies pricing rules and adjusts totalAmount', async () => { vi.mocked(repo.findVehicle).mockResolvedValue(makeVehicle({ dailyRate: 100 })) vi.mocked(repo.findCustomer).mockResolvedValue({ id: 'customer-1' } as any) vi.mocked(repo.findConflict).mockResolvedValue(null) vi.mocked(repo.findActiveOffer).mockResolvedValue(null) vi.mocked(pricingService.applyPricingRules).mockResolvedValue({ applied: [{ id: 'rule-1' }], total: 50 }) vi.mocked(repo.create).mockResolvedValue(makeReservation({ totalAmount: 350 }) as any) await createReservation(COMPANY, { vehicleId: 'vehicle-1', customerId: 'customer-1', startDate: '2025-06-01T00:00:00.000Z', endDate: '2025-06-04T00:00:00.000Z', selectedInsurancePolicyIds: [], additionalDrivers: [], }) expect(pricingService.applyPricingRules).toHaveBeenCalledOnce() const createCall = vi.mocked(repo.create).mock.calls[0][0] as any expect(createCall.pricingRulesApplied).toEqual([{ id: 'rule-1' }]) expect(createCall.totalAmount).toBe(350) }) }) // ──────────────────────────────────────────────────────────────────────────── describe('insurance application', () => { it('calls applyInsurancesToReservation when insurance IDs are provided', async () => { vi.mocked(repo.findVehicle).mockResolvedValue(makeVehicle()) vi.mocked(repo.findCustomer).mockResolvedValue({ id: 'customer-1' } as any) vi.mocked(repo.findConflict).mockResolvedValue(null) vi.mocked(repo.findActiveOffer).mockResolvedValue(null) vi.mocked(pricingService.applyPricingRules).mockResolvedValue({ applied: [], total: 0 }) vi.mocked(repo.create).mockResolvedValue(makeReservation() as any) vi.mocked(insuranceService.applyInsurancesToReservation).mockResolvedValue(undefined as any) await createReservation(COMPANY, { vehicleId: 'vehicle-1', customerId: 'customer-1', startDate: '2025-06-01T00:00:00.000Z', endDate: '2025-06-04T00:00:00.000Z', selectedInsurancePolicyIds: ['policy-1', 'policy-2'], additionalDrivers: [], }) expect(insuranceService.applyInsurancesToReservation).toHaveBeenCalledWith( RES_ID, COMPANY, ['policy-1', 'policy-2'], 3, 300, ) }) it('skips insurance call when no IDs provided', async () => { vi.mocked(repo.findVehicle).mockResolvedValue(makeVehicle()) vi.mocked(repo.findCustomer).mockResolvedValue({ id: 'customer-1' } as any) vi.mocked(repo.findConflict).mockResolvedValue(null) vi.mocked(repo.findActiveOffer).mockResolvedValue(null) vi.mocked(pricingService.applyPricingRules).mockResolvedValue({ applied: [], total: 0 }) vi.mocked(repo.create).mockResolvedValue(makeReservation() as any) await createReservation(COMPANY, { vehicleId: 'vehicle-1', customerId: 'customer-1', startDate: '2025-06-01T00:00:00.000Z', endDate: '2025-06-04T00:00:00.000Z', selectedInsurancePolicyIds: [], additionalDrivers: [], }) expect(insuranceService.applyInsurancesToReservation).not.toHaveBeenCalled() }) })