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
400 lines
18 KiB
TypeScript
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()
|
|
})
|
|
})
|