Files
carmanagement/apps/api/src/modules/reservations/reservation.test.ts
T
root 2b422ca197
Build & Push / Pipeline Tests (push) Failing after 1m14s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Successful in 53s
Test / API Unit Tests (push) Failing after 50s
Test / Homepage Unit Tests (push) Successful in 45s
Test / Carplace Unit Tests (push) Successful in 44s
Test / Admin Unit Tests (push) Successful in 42s
Test / Dashboard Unit Tests (push) Successful in 43s
Test / API Integration Tests (push) Successful in 1m4s
fix search feature
2026-07-26 01:18:05 -04:00

400 lines
18 KiB
TypeScript

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()
})
})