fix architecture and write new tests

This commit is contained in:
root
2026-06-10 00:40:19 -04:00
parent 560da1cadf
commit 80a597bc10
377 changed files with 84020 additions and 1337 deletions
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../lib/prisma', () => ({
prisma: {
contractSettings: { findUnique: vi.fn() },
additionalDriver: { createMany: vi.fn() },
reservation: { update: vi.fn() },
$transaction: vi.fn(),
},
}))
import { prisma } from '../lib/prisma'
import { applyAdditionalDriversToReservation, calculateAdditionalDriverCharge } from './additionalDriverService'
describe('additionalDriverService', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
})
it('calculates additional driver charges by configured charge mode', () => {
expect(calculateAdditionalDriverCharge('PER_DAY', 40, 5)).toBe(200)
expect(calculateAdditionalDriverCharge('FLAT', 150, 5)).toBe(150)
expect(calculateAdditionalDriverCharge('FREE', 150, 5)).toBe(0)
})
it('returns a zero-impact result without touching Prisma when no drivers are supplied', async () => {
await expect(applyAdditionalDriversToReservation('reservation_1', 'company_1', [], 4)).resolves.toEqual({
records: [],
additionalDriverTotal: 0,
requiresManualApproval: false,
})
expect(prisma.contractSettings.findUnique).not.toHaveBeenCalled()
})
it('applies charge settings, license risk flags, and reservation totals in one transaction', async () => {
vi.mocked(prisma.contractSettings.findUnique).mockResolvedValue({
additionalDriverCharge: 'PER_DAY',
additionalDriverDailyRate: 75,
additionalDriverFlatRate: 300,
} as never)
vi.mocked(prisma.$transaction).mockResolvedValue([] as never)
const result = await applyAdditionalDriversToReservation('reservation_1', 'company_1', [
{ firstName: 'Nora', lastName: 'Safe', driverLicense: 'A123', licenseExpiry: '2027-06-09T12:00:00.000Z' },
{ firstName: 'Imad', lastName: 'Risky', driverLicense: 'B456', licenseExpiry: '2026-06-20T12:00:00.000Z' },
], 4)
expect(result.additionalDriverTotal).toBe(600)
expect(result.requiresManualApproval).toBe(true)
expect(result.records).toEqual([
expect.objectContaining({ firstName: 'Nora', totalCharge: 300, requiresApproval: false, approvalNote: null }),
expect.objectContaining({ firstName: 'Imad', totalCharge: 300, requiresApproval: true, licenseExpiringSoon: true }),
])
expect(prisma.additionalDriver.createMany).toHaveBeenCalledWith({ data: result.records })
expect(prisma.reservation.update).toHaveBeenCalledWith({
where: { id: 'reservation_1' },
data: { additionalDriverTotal: 600, totalAmount: { increment: 600 } },
})
})
})
@@ -0,0 +1,94 @@
import crypto from 'crypto'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const originalEnv = { ...process.env }
async function importService(env: Record<string, string | undefined> = {}) {
vi.resetModules()
process.env = { ...originalEnv, ...env }
return import('./amanpayService')
}
describe('amanpayService', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
afterEach(() => {
process.env = { ...originalEnv }
vi.resetModules()
})
it('reports unconfigured when merchant credentials are placeholders or missing', async () => {
const service = await importService({ AMANPAY_MERCHANT_ID: 'your-amanpay-merchant-id', AMANPAY_SECRET_KEY: 'secret' })
expect(service.isConfigured()).toBe(false)
})
it('creates a checkout using merchant headers and maps alternate provider response fields', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ id: 'txn_1', payment_url: 'https://pay.example/txn_1' }),
})
vi.stubGlobal('fetch', fetchMock)
const service = await importService({
AMANPAY_BASE_URL: 'https://amanpay.test',
AMANPAY_MERCHANT_ID: 'merchant_1',
AMANPAY_SECRET_KEY: 'secret_1',
})
await expect(service.createCheckout({
amount: 12900,
currency: 'MAD',
orderId: 'order_1',
description: 'Reservation payment',
customerEmail: 'nora@example.com',
customerName: 'Nora Saidi',
successUrl: 'https://app.test/success',
failureUrl: 'https://app.test/failure',
webhookUrl: 'https://api.test/webhook',
})).resolves.toEqual({
transactionId: 'txn_1',
checkoutUrl: 'https://pay.example/txn_1',
status: 'PENDING',
})
expect(fetchMock).toHaveBeenCalledWith('https://amanpay.test/v1/payments', expect.objectContaining({
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-merchant-id': 'merchant_1',
'x-api-key': 'secret_1',
},
body: expect.stringContaining('"order_id":"order_1"'),
}))
})
it('turns provider checkout failures into useful errors', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
statusText: 'Bad Request',
json: vi.fn().mockResolvedValue({ message: 'invalid amount' }),
}))
const service = await importService({ AMANPAY_MERCHANT_ID: 'merchant_1', AMANPAY_SECRET_KEY: 'secret_1' })
await expect(service.createCheckout({
amount: 0,
currency: 'MAD',
orderId: 'order_1',
description: 'Bad payment',
successUrl: 'https://app.test/success',
failureUrl: 'https://app.test/failure',
webhookUrl: 'https://api.test/webhook',
})).rejects.toThrow('AmanPay checkout failed: invalid amount')
})
it('verifies webhook signatures using the configured shared secret', async () => {
const rawBody = JSON.stringify({ id: 'txn_1', status: 'PAID' })
const signature = crypto.createHmac('sha256', 'webhook_secret').update(rawBody).digest('hex')
const service = await importService({ AMANPAY_WEBHOOK_SECRET: 'webhook_secret' })
expect(service.verifyWebhookSignature(rawBody, signature)).toBe(true)
expect(service.verifyWebhookSignature(rawBody, crypto.createHmac('sha256', 'webhook_secret').update('other').digest('hex'))).toBe(false)
})
})
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const execFileMock = vi.fn()
const fsState = new Map<string, string>()
vi.mock('child_process', () => ({
execFile: (file: string, args: string[], cb: (error: any, stdout: string, stderr: string) => void) => {
const result = execFileMock(file, args) as { stdout?: string; stderr?: string } | undefined
cb(null, result?.stdout ?? '', result?.stderr ?? '')
},
}))
vi.mock('fs/promises', () => ({
mkdir: vi.fn().mockResolvedValue(undefined),
access: vi.fn(async (file: string) => {
if (!fsState.has(file)) throw Object.assign(new Error('missing'), { code: 'ENOENT' })
}),
readFile: vi.fn(async (file: string) => fsState.get(file) ?? '{}'),
writeFile: vi.fn(async (file: string, contents: string) => { fsState.set(file, contents) }),
}))
vi.mock('../lib/prisma', () => ({
prisma: {
companyContainer: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
findUniqueOrThrow: vi.fn(),
findUnique: vi.fn(),
findMany: vi.fn(),
delete: vi.fn(),
},
},
}))
import * as fs from 'fs/promises'
import { prisma } from '../lib/prisma'
import {
createCompanyContainer,
getContainerLogs,
restartCompanyContainer,
startCompanyContainer,
stopCompanyContainer,
syncContainerStatuses,
} from './containerService'
describe('containerService', () => {
beforeEach(() => {
vi.clearAllMocks()
fsState.clear()
fsState.set('/opt/companies/docker-compose.companies.yml', 'services: {}')
fsState.set('/opt/companies/companies-services.json', '{}')
})
afterEach(() => {
vi.resetModules()
})
it('allocates the first configured port, writes compose service metadata, starts the service, and stores docker id', async () => {
vi.mocked(prisma.companyContainer.findFirst).mockResolvedValue(null as never)
vi.mocked(prisma.companyContainer.create).mockResolvedValue({ id: 'container_row_1' } as never)
execFileMock.mockImplementation((file: string, args: string[]) => {
// The child_process mock calls back with empty stdout. This spy records the command shape.
return { file, args }
})
await createCompanyContainer({ id: 'company_1', slug: 'atlas-cars', name: 'Atlas Cars' })
expect(prisma.companyContainer.create).toHaveBeenCalledWith({
data: expect.objectContaining({
companyId: 'company_1',
containerName: 'rdg-company-atlas-cars',
status: 'CREATING',
port: 5100,
}),
})
expect(fs.writeFile).toHaveBeenCalledWith(
'/opt/companies/companies-services.json',
expect.stringContaining('"atlas-cars"'),
'utf8',
)
expect(fs.writeFile).toHaveBeenCalledWith(
'/opt/companies/docker-compose.companies.yml',
expect.stringContaining('container_name: "rdg-company-atlas-cars"'),
'utf8',
)
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['version'])
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['-f', '/opt/companies/docker-compose.companies.yml', 'up', '-d', '--no-recreate', 'company-atlas-cars'])
expect(prisma.companyContainer.update).toHaveBeenCalledWith({
where: { id: 'container_row_1' },
data: { dockerId: null, status: 'RUNNING' },
})
})
it('starts, stops, and restarts an existing company service with status transitions', async () => {
vi.mocked(prisma.companyContainer.findUniqueOrThrow).mockResolvedValue({
companyId: 'company_1',
company: { slug: 'atlas-cars' },
} as never)
await startCompanyContainer('company_1')
await stopCompanyContainer('company_1')
await restartCompanyContainer('company_1')
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['-f', '/opt/companies/docker-compose.companies.yml', 'up', '-d', '--no-recreate', 'company-atlas-cars'])
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['-f', '/opt/companies/docker-compose.companies.yml', 'stop', 'company-atlas-cars'])
expect(execFileMock).toHaveBeenCalledWith('docker-compose', ['-f', '/opt/companies/docker-compose.companies.yml', 'restart', 'company-atlas-cars'])
expect(prisma.companyContainer.update).toHaveBeenCalledWith({ where: { companyId: 'company_1' }, data: { status: 'RESTARTING', errorMessage: null } })
expect(prisma.companyContainer.update).toHaveBeenCalledWith({ where: { companyId: 'company_1' }, data: { status: 'RUNNING', errorMessage: null } })
expect(prisma.companyContainer.update).toHaveBeenCalledWith({ where: { companyId: 'company_1' }, data: { status: 'STOPPED', errorMessage: null } })
})
it('returns an empty log string when docker log retrieval fails', async () => {
vi.mocked(prisma.companyContainer.findUniqueOrThrow).mockResolvedValue({ company: { slug: 'atlas-cars' } } as never)
execFileMock.mockImplementationOnce(() => { throw new Error('docker unavailable') })
await expect(getContainerLogs('company_1', 50)).resolves.toBe('')
})
it('loads container records and leaves stored statuses unchanged when docker status sync cannot complete', async () => {
vi.mocked(prisma.companyContainer.findMany).mockResolvedValue([
{ id: 'row_1', status: 'STOPPED', company: { slug: 'atlas-cars' } },
{ id: 'row_2', status: 'RUNNING', company: { slug: 'sahara-rentals' } },
] as never)
execFileMock.mockImplementation(() => { throw new Error('Docker daemon is unavailable') })
await expect(syncContainerStatuses()).resolves.toBeUndefined()
expect(prisma.companyContainer.findMany).toHaveBeenCalledWith({
where: { status: { notIn: ['PENDING', 'CREATING', 'REMOVING'] } },
include: { company: { select: { slug: true } } },
})
expect(prisma.companyContainer.update).not.toHaveBeenCalled()
})})
@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { prisma } from '../lib/prisma'
import { generateFinancialReport, toCsv } from './financialReportService'
vi.mock('../lib/prisma', () => ({
prisma: {
reservation: { findMany: vi.fn() },
},
}))
describe('financialReportService', () => {
beforeEach(() => vi.clearAllMocks())
it('generates summary totals and rows from confirmed revenue-bearing reservations only as requested from Prisma', async () => {
const startDate = new Date('2026-06-01T00:00:00.000Z')
const endDate = new Date('2026-06-30T23:59:59.999Z')
vi.mocked(prisma.reservation.findMany).mockResolvedValue([
{
id: 'reservation_1',
contractNumber: 'CNT-1',
invoiceNumber: null,
dailyRate: 500,
totalDays: 3,
discountAmount: 100,
insuranceTotal: 60,
additionalDriverTotal: 40,
pricingRulesTotal: -25,
depositAmount: 1000,
totalAmount: 1475,
source: 'DIRECT',
startDate: new Date('2026-06-10T10:00:00.000Z'),
endDate: new Date('2026-06-13T10:00:00.000Z'),
vehicle: { year: 2024, make: 'Dacia', model: 'Duster', licensePlate: 'A-123' },
customer: { firstName: 'Nora', lastName: 'Saidi', email: 'nora@example.com' },
rentalPayments: [{ status: 'SUCCEEDED', paymentMethod: 'CARD' }],
insurances: [],
additionalDrivers: [],
},
{
id: 'reservation_2',
contractNumber: null,
invoiceNumber: 'INV-2',
dailyRate: 300,
totalDays: 2,
discountAmount: 0,
insuranceTotal: 30,
additionalDriverTotal: 0,
pricingRulesTotal: 10,
depositAmount: 500,
totalAmount: 640,
source: 'MARKETPLACE',
startDate: new Date('2026-06-18T10:00:00.000Z'),
endDate: new Date('2026-06-20T10:00:00.000Z'),
vehicle: { year: 2023, make: 'Renault', model: 'Clio', licensePlate: 'B-456' },
customer: { firstName: 'Omar', lastName: 'Tazi', email: 'omar@example.com' },
rentalPayments: [],
insurances: [],
additionalDrivers: [],
},
] as never)
const report = await generateFinancialReport('company_1', startDate, endDate)
expect(prisma.reservation.findMany).toHaveBeenCalledWith({
where: {
companyId: 'company_1',
status: { in: ['CONFIRMED', 'ACTIVE', 'COMPLETED'] },
startDate: { gte: startDate },
endDate: { lte: endDate },
},
include: expect.objectContaining({
vehicle: { select: { make: true, model: true, year: true, licensePlate: true } },
customer: { select: { firstName: true, lastName: true, email: true } },
rentalPayments: { where: { status: 'SUCCEEDED' } },
}),
orderBy: { startDate: 'asc' },
})
expect(report.summary).toEqual({
totalReservations: 2,
totalRentalRevenue: 2100,
totalDiscounts: 100,
totalInsurance: 90,
totalAdditionalDrivers: 40,
totalPricingRulesAdj: -15,
totalDeposits: 1500,
totalCollected: 2115,
averageRentalDays: 2.5,
})
expect(report.rows).toEqual([
expect.objectContaining({
reservationId: 'reservation_1',
contractNumber: 'CNT-1',
invoiceNumber: '—',
customerName: 'Nora Saidi',
vehicle: '2024 Dacia Duster',
plate: 'A-123',
startDate: '2026-06-10',
endDate: '2026-06-13',
baseAmount: 1500,
paymentStatus: 'SUCCEEDED',
paymentMethod: 'CARD',
}),
expect.objectContaining({
reservationId: 'reservation_2',
contractNumber: '—',
invoiceNumber: 'INV-2',
paymentStatus: 'UNPAID',
paymentMethod: '—',
}),
])
expect(report.period).toEqual({ startDate, endDate })
})
it('returns zero-safe summary values when no rows match', async () => {
vi.mocked(prisma.reservation.findMany).mockResolvedValue([] as never)
const report = await generateFinancialReport('company_1', new Date('2026-06-01'), new Date('2026-06-30'))
expect(report.summary).toMatchObject({
totalReservations: 0,
totalRentalRevenue: 0,
averageRentalDays: 0,
})
expect(report.rows).toEqual([])
})
it('serializes CSV rows with headers and quotes comma-containing string values', () => {
expect(toCsv([
{ customerName: 'Nora, Saidi', totalAmount: 1200, paymentStatus: 'SUCCEEDED' },
{ customerName: 'Omar Tazi', totalAmount: 900, paymentStatus: null },
])).toBe('customerName,totalAmount,paymentStatus\n"Nora, Saidi",1200,SUCCEEDED\nOmar Tazi,900,')
})
it('returns an empty CSV string for empty rows', () => {
expect(toCsv([])).toBe('')
})
})
@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../lib/prisma', () => ({
prisma: {
insurancePolicy: { findMany: vi.fn() },
reservationInsurance: { createMany: vi.fn() },
reservation: { update: vi.fn() },
$transaction: vi.fn(),
},
}))
import { prisma } from '../lib/prisma'
import { applyInsurancesToReservation, calculateInsuranceCharge } from './insuranceService'
const policy = (overrides: Record<string, unknown>) => ({
id: 'policy_1',
companyId: 'company_1',
name: 'Collision damage waiver',
type: 'CDW',
chargeType: 'PER_DAY',
chargeValue: 100,
isRequired: false,
isActive: true,
...overrides,
}) as any
describe('insuranceService', () => {
beforeEach(() => vi.clearAllMocks())
it('calculates per-day, per-rental, percentage, and unknown insurance charges defensively', () => {
expect(calculateInsuranceCharge(policy({ chargeType: 'PER_DAY', chargeValue: 25 }), 4, 1_000)).toBe(100)
expect(calculateInsuranceCharge(policy({ chargeType: 'PER_RENTAL', chargeValue: 90 }), 4, 1_000)).toBe(90)
expect(calculateInsuranceCharge(policy({ chargeType: 'PERCENTAGE_OF_RENTAL', chargeValue: 12.5 }), 4, 999)).toBe(125)
expect(calculateInsuranceCharge(policy({ chargeType: 'WHO_KNOWS', chargeValue: 50 }), 4, 1_000)).toBe(0)
})
it('always applies required active policies and selected optional policies in one reservation transaction', async () => {
vi.mocked(prisma.insurancePolicy.findMany).mockResolvedValue([
policy({ id: 'required_1', name: 'Mandatory liability', isRequired: true, chargeType: 'PER_RENTAL', chargeValue: 300 }),
policy({ id: 'optional_1', name: 'Glass cover', isRequired: false, chargeType: 'PER_DAY', chargeValue: 50 }),
policy({ id: 'optional_2', name: 'Ignored cover', isRequired: false, chargeType: 'PER_DAY', chargeValue: 999 }),
] as never)
vi.mocked(prisma.$transaction).mockResolvedValue([] as never)
const result = await applyInsurancesToReservation('reservation_1', 'company_1', ['optional_1'], 3, 1_200)
expect(result.insuranceTotal).toBe(450)
expect(result.records.map((r) => r.insurancePolicyId)).toEqual(['required_1', 'optional_1'])
expect(prisma.insurancePolicy.findMany).toHaveBeenCalledWith({ where: { companyId: 'company_1', isActive: true } })
expect(prisma.reservationInsurance.createMany).toHaveBeenCalledWith({ data: result.records })
expect(prisma.reservation.update).toHaveBeenCalledWith({
where: { id: 'reservation_1' },
data: { insuranceTotal: 450, totalAmount: { increment: 450 } },
})
expect(prisma.$transaction).toHaveBeenCalledWith([undefined, undefined])
})
})
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const renderToBufferMock = vi.fn().mockResolvedValue(Buffer.from('pdf-bytes'))
vi.mock('@react-pdf/renderer', () => ({
renderToBuffer: (...args: any[]) => renderToBufferMock(...args),
Document: 'Document',
Page: 'Page',
Text: 'Text',
View: 'View',
StyleSheet: { create: (styles: any) => styles },
}))
import { buildInvoiceNumber, generateInvoicePdf } from './invoicePdfService'
describe('invoicePdfService', () => {
afterEach(() => {
vi.clearAllMocks()
})
it('builds stable legacy invoice numbers from the creation year and id suffix', () => {
expect(buildInvoiceNumber('invoice_abcdef', new Date('2026-06-09T12:00:00.000Z'))).toBe('INV-2026-ABCDEF')
expect(buildInvoiceNumber('short', new Date('2027-06-01T00:00:00.000Z'))).toBe('INV-2027-SHORT')
})
it('renders a subscription invoice document into a buffer', async () => {
const result = await generateInvoicePdf({
invoiceNumber: 'INV-2026-000001',
issueDate: '2026-06-09T00:00:00.000Z',
dueDate: null,
company: { name: 'Atlas Cars', email: 'billing@atlas.test', phone: '+212600000000', address: { formatted: 'Casablanca' } },
subscription: { plan: 'PRO', billingPeriod: 'MONTHLY', currency: 'MAD' },
amount: 99000,
currency: 'MAD',
status: 'PAID',
paymentProvider: 'PAYPAL',
transactionId: 'txn_1',
paidAt: '2026-06-09T12:00:00.000Z',
lineItems: [{ description: 'Pro monthly subscription', amount: 99000, currency: 'MAD', quantity: 1, unitAmount: 99000 }],
totals: {
subtotalAmount: 99000,
discountAmount: 0,
creditAmount: 0,
taxAmount: 0,
totalAmount: 99000,
amountPaid: 99000,
amountDue: 0,
},
})
expect(result).toEqual(Buffer.from('pdf-bytes'))
expect(renderToBufferMock).toHaveBeenCalledTimes(1)
expect(renderToBufferMock.mock.calls[0][0]).toEqual(expect.objectContaining({
props: expect.objectContaining({ data: expect.objectContaining({ invoiceNumber: 'INV-2026-000001' }) }),
}))
})
})
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../lib/prisma', () => ({
prisma: {
customer: {
findFirstOrThrow: vi.fn(),
findUniqueOrThrow: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
},
}))
import { prisma } from '../lib/prisma'
import { validateAndFlagLicense, validateLicense } from './licenseValidationService'
describe('licenseValidationService', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('treats missing license expiry as valid but explicitly records that no date exists', () => {
expect(validateLicense(null)).toEqual({
status: 'VALID',
daysUntilExpiry: null,
requiresApproval: false,
message: 'No expiry date on record',
})
})
it('requires approval for licenses expiring inside the three-month risk window', () => {
const result = validateLicense(new Date('2026-07-09T12:00:00.000Z'))
expect(result.status).toBe('EXPIRING')
expect(result.daysUntilExpiry).toBe(30)
expect(result.requiresApproval).toBe(true)
expect(result.message).toContain('License expires in 30 day(s)')
})
it('requires approval for already expired licenses and reports negative days until expiry', () => {
const result = validateLicense(new Date('2026-06-04T12:00:00.000Z'))
expect(result.status).toBe('EXPIRED')
expect(result.daysUntilExpiry).toBe(-5)
expect(result.requiresApproval).toBe(true)
})
it('persists customer flags for risky licenses without clearing existing safe-customer metadata unnecessarily', async () => {
vi.mocked(prisma.customer.findFirstOrThrow).mockResolvedValue({
id: 'customer_1',
licenseExpiry: new Date('2026-06-20T12:00:00.000Z'),
flagged: false,
flagReason: null,
} as never)
const result = await validateAndFlagLicense('customer_1')
expect(result.status).toBe('EXPIRING')
expect(prisma.customer.update).toHaveBeenCalledWith({
where: { id: 'customer_1' },
data: expect.objectContaining({
licenseExpired: false,
licenseExpiringSoon: true,
licenseValidationStatus: 'EXPIRING',
flagged: true,
flagReason: expect.stringContaining('License expires in 11 day(s)'),
}),
})
})
})
@@ -29,24 +29,38 @@ export function validateLicense(licenseExpiry: Date | null): LicenseValidationRe
return { status: 'VALID', daysUntilExpiry, requiresApproval: false, message: `Valid — expires in ${daysUntilExpiry} day(s)` }
}
export async function validateAndFlagLicense(customerId: string) {
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: customerId } })
export async function validateAndFlagLicense(customerId: string, companyId?: string) {
const where = companyId ? { id: customerId, companyId } : { id: customerId }
const customer = await prisma.customer.findFirstOrThrow({ where })
const result = validateLicense(customer.licenseExpiry)
const status: LicenseStatus =
result.status === 'EXPIRED' ? 'EXPIRED' :
result.status === 'EXPIRING' ? 'EXPIRING' : 'VALID'
await prisma.customer.update({
where: { id: customerId },
data: {
if (companyId) {
await prisma.customer.updateMany({
where: { id: customerId, companyId },
data: {
licenseExpired: result.status === 'EXPIRED',
licenseExpiringSoon: result.status === 'EXPIRING',
licenseValidationStatus: status,
flagged: result.requiresApproval ? true : customer.flagged,
flagReason: result.requiresApproval ? result.message : customer.flagReason,
},
})
},
})
} else {
await prisma.customer.update({
where: { id: customerId },
data: {
licenseExpired: result.status === 'EXPIRED',
licenseExpiringSoon: result.status === 'EXPIRING',
licenseValidationStatus: status,
flagged: result.requiresApproval ? true : customer.flagged,
flagReason: result.requiresApproval ? result.message : customer.flagReason,
},
})
}
return result
}
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest'
import {
coerceNotificationLocale,
formatLocalizedCurrency,
localizeBillingPeriod,
localizeInvoiceStatus,
localizePlanName,
renderLocalizedEmailHtml,
renderNotificationText,
resolveNotificationLocale,
resolveNotificationTemplate,
} from './notificationLocalizationService'
function dbStub(overrides: Record<string, any> = {}) {
return {
employee: { findUnique: vi.fn().mockResolvedValue(null) },
renter: { findUnique: vi.fn().mockResolvedValue(null) },
brandSettings: { findUnique: vi.fn().mockResolvedValue(null) },
billingAccount: { findUnique: vi.fn().mockResolvedValue(null), findFirst: vi.fn().mockResolvedValue(null) },
notificationTemplate: { findFirst: vi.fn().mockResolvedValue(null) },
...overrides,
}
}
describe('notificationLocalizationService formatting and fallback boundaries', () => {
it('coerces unsupported or blank locales to English', () => {
expect(coerceNotificationLocale('fr')).toBe('fr')
expect(coerceNotificationLocale('es')).toBe('en')
expect(coerceNotificationLocale(null)).toBe('en')
})
it('formats template values across currency, dates and status objects', () => {
const rendered = renderNotificationText(
'Total {{amount}}, due {{dueDate}}, status {{invoiceStatus}}, plan {{plan}}',
{
amount: { type: 'currency', amountMinor: 123450, currency: 'MAD' },
dueDate: { type: 'date', value: '2026-06-10T00:00:00.000Z', options: { month: 'short', day: 'numeric' } },
invoiceStatus: { type: 'invoice_status', status: 'partially_paid' },
plan: true,
},
['amount', 'dueDate', 'invoiceStatus'],
'en',
)
expect(rendered).toContain('MAD')
expect(rendered).toContain('Jun')
expect(rendered).toContain('Partially paid')
expect(rendered).toContain('true')
})
it('throws before rendering when required variables are missing', () => {
expect(() => renderNotificationText('Hello {{firstName}}', {}, ['firstName'], 'en')).toThrow(
'Missing notification template variable: firstName',
)
})
it('escapes localized email body text while preserving paragraphs and line breaks', () => {
const html = renderLocalizedEmailHtml('Hello <Admin> & team\nLine 2\n\nSecond paragraph', 'en')
expect(html).toContain('&lt;Admin&gt; &amp; team')
expect(html).toContain('Line 2')
expect(html).toContain('<br />')
expect(html).toContain('text-align:left')
})
it('uses explicit locale without querying user, workspace or billing preferences', async () => {
const db = dbStub()
await expect(resolveNotificationLocale({ locale: 'ar', companyId: 'company_1', employeeId: 'employee_1' }, db as any)).resolves.toBe('ar')
expect(db.employee.findUnique).not.toHaveBeenCalled()
expect(db.brandSettings.findUnique).not.toHaveBeenCalled()
expect(db.billingAccount.findFirst).not.toHaveBeenCalled()
})
it('resolves renter preference before workspace and billing defaults', async () => {
const db = dbStub({
renter: { findUnique: vi.fn().mockResolvedValue({ preferredLocale: 'fr' }) },
brandSettings: { findUnique: vi.fn().mockResolvedValue({ defaultLocale: 'ar' }) },
billingAccount: { findUnique: vi.fn().mockResolvedValue(null), findFirst: vi.fn().mockResolvedValue({ preferredLanguage: 'en' }) },
})
await expect(resolveNotificationLocale({ companyId: 'company_1', renterId: 'renter_1' }, db as any)).resolves.toBe('fr')
})
it('throws a clear error when neither requested nor fallback templates exist', async () => {
const db = dbStub()
await expect(resolveNotificationTemplate({
templateKey: 'missing.template',
channel: 'EMAIL',
locale: 'fr',
}, db as any)).rejects.toThrow('Missing notification template for missing.template/EMAIL in fr and en')
})
it('keeps unknown labels unchanged instead of fabricating translations', () => {
expect(localizeInvoiceStatus('custom_status', 'fr')).toBe('custom_status')
expect(localizeBillingPeriod('quarterly', 'ar')).toBe('quarterly')
expect(localizePlanName('enterprise', 'en')).toBe('enterprise')
expect(formatLocalizedCurrency(5000, 'MAD', 'fr')).toContain('MAD')
})
})
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('resend', () => ({ Resend: vi.fn() }))
vi.mock('twilio', () => ({ default: vi.fn(() => ({ messages: { create: vi.fn() } })) }))
vi.mock('firebase-admin', () => ({
default: {
apps: [],
initializeApp: vi.fn(),
credential: { cert: vi.fn() },
messaging: vi.fn(() => ({ send: vi.fn() })),
},
}))
vi.mock('../lib/prisma', () => ({
prisma: {
notification: {
create: vi.fn(),
update: vi.fn(),
},
},
}))
vi.mock('../lib/redis', () => ({
redis: { publish: vi.fn(), on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
}))
vi.mock('./notificationLocalizationService', async () => {
const actual = await vi.importActual<typeof import('./notificationLocalizationService')>('./notificationLocalizationService')
return {
...actual,
resolveNotificationLocale: vi.fn().mockResolvedValue('en'),
resolveNotificationTemplate: vi.fn(),
}
})
import { prisma } from '../lib/prisma'
import { redis } from '../lib/redis'
import { sendNotification } from './notificationService'
import { resolveNotificationLocale, resolveNotificationTemplate } from './notificationLocalizationService'
describe('notificationService delivery boundaries', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.notification.create).mockResolvedValue({ id: 'notification_1', title: 'Hello', body: 'Body' } as never)
vi.mocked(prisma.notification.update).mockResolvedValue({ id: 'notification_1' } as never)
vi.mocked(resolveNotificationLocale).mockResolvedValue('en')
vi.mocked(resolveNotificationTemplate).mockResolvedValue(null as never)
})
it('persists and publishes in-app notifications for employee recipients', async () => {
const result = await sendNotification({
type: 'SYSTEM_ALERT' as never,
title: 'Maintenance window',
body: 'The dashboard will be unavailable.',
companyId: 'company_1',
employeeId: 'employee_1',
channels: ['IN_APP' as never],
data: { severity: 'low' },
})
expect(result).toEqual([{ channel: 'IN_APP', success: true }])
expect(resolveNotificationLocale).toHaveBeenCalledWith(expect.objectContaining({
companyId: 'company_1',
employeeId: 'employee_1',
}))
expect(prisma.notification.create).toHaveBeenCalledWith({
data: expect.objectContaining({
type: 'SYSTEM_ALERT',
title: 'Maintenance window',
body: 'The dashboard will be unavailable.',
channel: 'IN_APP',
status: 'PENDING',
companyId: 'company_1',
employeeId: 'employee_1',
renterId: null,
}),
})
expect(redis.publish).toHaveBeenCalledWith(
'notifications:employee_1',
expect.stringContaining('"status":"DELIVERED"'),
)
expect(prisma.notification.update).toHaveBeenCalledWith({
where: { id: 'notification_1' },
data: expect.objectContaining({ status: 'SENT', providerMessageId: null }),
})
})
it('uses resolved templates and records failed channels without throwing', async () => {
vi.mocked(resolveNotificationTemplate).mockResolvedValue({
templateKey: 'reservation.confirmed',
locale: 'fr',
subject: 'Réservation confirmée',
body: 'Votre réservation est confirmée.',
usedFallback: false,
version: 2,
} as never)
const result = await sendNotification({
type: 'RESERVATION_UPDATE' as never,
templateKey: 'reservation.confirmed',
templateVariables: { firstName: 'Aya' },
companyId: 'company_1',
channels: ['EMAIL' as never],
email: 'aya@example.test',
})
expect(result).toEqual([{ channel: 'EMAIL', success: false, error: 'No email provider is configured' }])
expect(resolveNotificationTemplate).toHaveBeenCalledWith(expect.objectContaining({
templateKey: 'reservation.confirmed',
channel: 'EMAIL',
locale: 'en',
variables: { firstName: 'Aya' },
}))
expect(prisma.notification.create).toHaveBeenCalledWith({
data: expect.objectContaining({
title: 'Réservation confirmée',
body: 'Votre réservation est confirmée.',
templateKey: 'reservation.confirmed',
locale: 'fr',
}),
})
expect(prisma.notification.update).not.toHaveBeenCalled()
})
it('fails incomplete notification content before persistence', async () => {
const result = await sendNotification({
type: 'SYSTEM_ALERT' as never,
title: 'Missing body',
channels: ['IN_APP' as never],
employeeId: 'employee_1',
})
expect(result).toEqual([{ channel: 'IN_APP', success: false, error: 'Notification content is incomplete for channel IN_APP' }])
expect(prisma.notification.create).not.toHaveBeenCalled()
expect(redis.publish).not.toHaveBeenCalled()
})
})
+112
View File
@@ -0,0 +1,112 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const originalEnv = { ...process.env }
async function importService(env: Record<string, string | undefined> = {}) {
vi.resetModules()
process.env = { ...originalEnv, ...env }
return import('./paypalService')
}
describe('paypalService', () => {
afterEach(() => {
process.env = { ...originalEnv }
vi.unstubAllGlobals()
vi.resetModules()
})
it('reports unconfigured when client credentials are placeholders or missing', async () => {
const service = await importService({ PAYPAL_CLIENT_ID: 'your-paypal-client-id', PAYPAL_CLIENT_SECRET: 'secret' })
expect(service.isConfigured()).toBe(false)
})
it('creates an order after obtaining and caching an access token', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: vi.fn().mockResolvedValue({ access_token: 'access_1', expires_in: 3600 }),
})
.mockResolvedValueOnce({
ok: true,
json: vi.fn().mockResolvedValue({
id: 'paypal_order_1',
status: 'CREATED',
links: [{ rel: 'approve', href: 'https://paypal.test/approve' }],
}),
})
.mockResolvedValueOnce({
ok: true,
json: vi.fn().mockResolvedValue({ id: 'paypal_order_2', status: 'CREATED', links: [] }),
})
vi.stubGlobal('fetch', fetchMock)
const service = await importService({
PAYPAL_BASE_URL: 'https://paypal.test',
PAYPAL_CLIENT_ID: 'client_1',
PAYPAL_CLIENT_SECRET: 'secret_1',
})
await expect(service.createOrder({
amount: 12345,
currency: 'MAD',
orderId: 'reservation_1',
description: 'Reservation deposit',
returnUrl: 'https://app.test/return',
cancelUrl: 'https://app.test/cancel',
})).resolves.toEqual({
orderId: 'paypal_order_1',
approveUrl: 'https://paypal.test/approve',
status: 'CREATED',
})
await service.createOrder({
amount: 500,
currency: 'MAD',
orderId: 'reservation_2',
description: 'Second payment',
returnUrl: 'https://app.test/return',
cancelUrl: 'https://app.test/cancel',
})
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(fetchMock).toHaveBeenNthCalledWith(1, 'https://paypal.test/v1/oauth2/token', expect.objectContaining({
method: 'POST',
body: 'grant_type=client_credentials',
headers: expect.objectContaining({ Authorization: `Basic ${Buffer.from('client_1:secret_1').toString('base64')}` }),
}))
expect(fetchMock).toHaveBeenNthCalledWith(2, 'https://paypal.test/v2/checkout/orders', expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: 'Bearer access_1' }),
body: expect.stringContaining('"value":"123.45"'),
}))
})
it('captures and refunds through authenticated PayPal endpoints with cent-to-decimal formatting', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({ ok: true, json: vi.fn().mockResolvedValue({ access_token: 'access_1', expires_in: 3600 }) })
.mockResolvedValueOnce({ ok: true, json: vi.fn().mockResolvedValue({ id: 'capture_result' }) })
.mockResolvedValueOnce({ ok: true, json: vi.fn().mockResolvedValue({ id: 'refund_result' }) })
vi.stubGlobal('fetch', fetchMock)
const service = await importService({ PAYPAL_BASE_URL: 'https://paypal.test', PAYPAL_CLIENT_ID: 'client_1', PAYPAL_CLIENT_SECRET: 'secret_1' })
await expect(service.captureOrder('order_1')).resolves.toEqual({ id: 'capture_result' })
await expect(service.refundCapture('capture_1', 2500, 'MAD', 'Customer cancellation')).resolves.toEqual({ id: 'refund_result' })
expect(fetchMock).toHaveBeenNthCalledWith(2, 'https://paypal.test/v2/checkout/orders/order_1/capture', expect.objectContaining({
method: 'POST',
body: '{}',
}))
expect(fetchMock).toHaveBeenNthCalledWith(3, 'https://paypal.test/v2/payments/captures/capture_1/refund', expect.objectContaining({
method: 'POST',
body: JSON.stringify({ amount: { currency_code: 'MAD', value: '25.00' }, note_to_payer: 'Customer cancellation' }),
}))
})
it('returns false instead of throwing when webhook verification cannot be completed', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')))
const service = await importService({ PAYPAL_CLIENT_ID: 'client_1', PAYPAL_CLIENT_SECRET: 'secret_1' })
await expect(service.verifyWebhookEvent({}, '{"id":"event_1"}')).resolves.toBe(false)
})
})
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'
import os from 'os'
import path from 'path'
let tempDir: string
let cwdSpy: ReturnType<typeof vi.spyOn>
async function importFreshService() {
vi.resetModules()
return import('./platformContentService')
}
describe('platformContentService', () => {
beforeEach(async () => {
tempDir = await mkdtemp(path.join(os.tmpdir(), 'rdg-platform-content-'))
await import('fs/promises').then((fs) => fs.mkdir(path.join(tempDir, 'apps/api'), { recursive: true }))
cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(path.join(tempDir, 'apps/api'))
})
afterEach(async () => {
cwdSpy.mockRestore()
await rm(tempDir, { recursive: true, force: true })
vi.resetModules()
})
it('returns cloned default homepage content when the file does not exist or cannot be parsed', async () => {
const service = await importFreshService()
const first = await service.getMarketplaceHomepageContent()
first.en.heroTitle = 'Mutated by test'
const second = await service.getMarketplaceHomepageContent()
expect(second.en.sections).toContain('hero')
expect(second.en.heroTitle).not.toBe('Mutated by test')
})
it('saves only the marketplace homepage field while preserving unrelated platform content fields', async () => {
const dataPath = path.join(tempDir, 'apps/api/src/data/platform-content.json')
await writeFile(dataPath, JSON.stringify({ otherSetting: { keep: true } }), 'utf8').catch(async () => {
await import('fs/promises').then((fs) => fs.mkdir(path.dirname(dataPath), { recursive: true }))
await writeFile(dataPath, JSON.stringify({ otherSetting: { keep: true } }), 'utf8')
})
const service = await importFreshService()
const homepage = await service.getMarketplaceHomepageContent()
homepage.en.heroTitle = 'A better marketplace title'
await expect(service.saveMarketplaceHomepageContent(homepage)).resolves.toBe(homepage)
const raw = JSON.parse(await readFile(dataPath, 'utf8'))
expect(raw.otherSetting).toEqual({ keep: true })
expect(raw.marketplaceHomepage.en.heroTitle).toBe('A better marketplace title')
})
})
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../lib/prisma', () => ({
prisma: {
pricingRule: {
findMany: vi.fn(),
},
customer: {
findFirstOrThrow: vi.fn(),
findUniqueOrThrow: vi.fn(),
},
},
}))
import { prisma } from '../lib/prisma'
import { applyPricingRules } from './pricingRuleService'
describe('applyPricingRules', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.setSystemTime(new Date('2026-06-08T12:00:00.000Z'))
})
it('applies percentage, flat-per-day, and flat pricing rules against the rental base amount', async () => {
vi.mocked(prisma.customer.findFirstOrThrow).mockResolvedValue({
id: 'customer_1',
dateOfBirth: new Date('2008-06-09T00:00:00.000Z'),
licenseIssuedAt: new Date('2025-06-09T00:00:00.000Z'),
} as never)
vi.mocked(prisma.pricingRule.findMany).mockResolvedValue([
{
id: 'young_driver_fee',
name: 'Young driver fee',
condition: 'AGE_LESS_THAN',
conditionValue: 21,
adjustmentType: 'PERCENTAGE',
adjustmentValue: 10,
type: 'SURCHARGE',
},
{
id: 'new_license_fee',
name: 'New license fee',
condition: 'LICENSE_YEARS_LESS_THAN',
conditionValue: 2,
adjustmentType: 'FLAT_PER_DAY',
adjustmentValue: 50,
type: 'SURCHARGE',
},
{
id: 'senior_discount',
name: 'Senior discount',
condition: 'AGE_GREATER_THAN',
conditionValue: 60,
adjustmentType: 'FLAT',
adjustmentValue: 100,
type: 'DISCOUNT',
},
] as never)
const result = await applyPricingRules('company_1', 'customer_1', [
{ dateOfBirth: new Date('1950-01-01T00:00:00.000Z'), licenseIssuedAt: new Date('1980-01-01T00:00:00.000Z') },
], 200, 3)
expect(prisma.pricingRule.findMany).toHaveBeenCalledWith({ where: { companyId: 'company_1', isActive: true } })
expect(result.applied).toEqual([
{ ruleId: 'young_driver_fee', name: 'Young driver fee', type: 'SURCHARGE', amount: 60 },
{ ruleId: 'new_license_fee', name: 'New license fee', type: 'SURCHARGE', amount: 150 },
{ ruleId: 'senior_discount', name: 'Senior discount', type: 'DISCOUNT', amount: -100 },
])
expect(result.total).toBe(110)
})
it('deduplicates a rule when multiple drivers qualify', async () => {
vi.mocked(prisma.customer.findFirstOrThrow).mockResolvedValue({
id: 'customer_1',
dateOfBirth: new Date('2006-01-01T00:00:00.000Z'),
licenseIssuedAt: null,
} as never)
vi.mocked(prisma.pricingRule.findMany).mockResolvedValue([
{
id: 'young_driver_fee',
name: 'Young driver fee',
condition: 'AGE_LESS_THAN',
conditionValue: 25,
adjustmentType: 'FLAT',
adjustmentValue: 75,
type: 'SURCHARGE',
},
] as never)
const result = await applyPricingRules('company_1', 'customer_1', [
{ dateOfBirth: new Date('2007-01-01T00:00:00.000Z'), licenseIssuedAt: null },
], 100, 5)
expect(result.applied).toHaveLength(1)
expect(result.total).toBe(75)
})
it('ignores rules when required driver data is missing', async () => {
vi.mocked(prisma.customer.findFirstOrThrow).mockResolvedValue({
id: 'customer_1',
dateOfBirth: null,
licenseIssuedAt: null,
} as never)
vi.mocked(prisma.pricingRule.findMany).mockResolvedValue([
{
id: 'license_rule',
name: 'Experienced license discount',
condition: 'LICENSE_YEARS_GREATER_THAN',
conditionValue: 10,
adjustmentType: 'PERCENTAGE',
adjustmentValue: 5,
type: 'DISCOUNT',
},
] as never)
const result = await applyPricingRules('company_1', 'customer_1', [], 100, 2)
expect(result).toEqual({ applied: [], total: 0 })
})
})
+1 -1
View File
@@ -13,7 +13,7 @@ export async function applyPricingRules(
totalDays: number
): Promise<{ applied: any[]; total: number }> {
const rules = await prisma.pricingRule.findMany({ where: { companyId, isActive: true } })
const customer = await prisma.customer.findUniqueOrThrow({ where: { id: customerId } })
const customer = await prisma.customer.findFirstOrThrow({ where: { id: customerId, companyId } })
const allDrivers: DriverInfo[] = [customer, ...additionalDrivers]
const applied: any[] = []
const baseAmount = dailyRate * totalDays
+1 -1
View File
@@ -98,7 +98,7 @@ export async function listEmployees(companyId: string): Promise<TeamMember[]> {
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
})
return employees.map((employee) => ({
return employees.map((employee: any) => ({
...employee,
lastActiveAt: null,
invitationStatus: employee.passwordHash ? 'accepted' : 'pending',
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../lib/prisma', () => ({
prisma: {
vehicle: {
findFirstOrThrow: vi.fn(),
findUniqueOrThrow: vi.fn(),
},
reservation: {
findMany: vi.fn(),
},
vehicleCalendarBlock: {
findMany: vi.fn(),
},
},
}))
import { prisma } from '../lib/prisma'
import { getVehicleAvailabilitySummary } from './vehicleAvailabilityService'
const range = {
startDate: new Date('2026-07-10T10:00:00.000Z'),
endDate: new Date('2026-07-12T10:00:00.000Z'),
}
describe('getVehicleAvailabilitySummary', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.setSystemTime(new Date('2026-07-01T00:00:00.000Z'))
vi.mocked(prisma.vehicle.findFirstOrThrow).mockResolvedValue({ status: 'AVAILABLE' } as never)
vi.mocked(prisma.reservation.findMany).mockResolvedValue([] as never)
vi.mocked(prisma.vehicleCalendarBlock.findMany).mockResolvedValue([] as never)
})
it('returns unavailable immediately when the vehicle itself is out of service', async () => {
vi.mocked(prisma.vehicle.findFirstOrThrow).mockResolvedValue({ status: 'OUT_OF_SERVICE' } as never)
const result = await getVehicleAvailabilitySummary('vehicle_1', { range })
expect(result).toEqual({
available: false,
status: 'UNAVAILABLE',
nextAvailableAt: null,
blockingReason: 'STATUS',
})
expect(prisma.reservation.findMany).not.toHaveBeenCalled()
expect(prisma.vehicleCalendarBlock.findMany).not.toHaveBeenCalled()
})
it('treats overlapping confirmed reservations as reserved and computes the next free instant', async () => {
vi.mocked(prisma.reservation.findMany).mockResolvedValue([
{ startDate: new Date('2026-07-09T10:00:00.000Z'), endDate: new Date('2026-07-11T10:00:00.000Z') },
{ startDate: new Date('2026-07-11T10:00:00.000Z'), endDate: new Date('2026-07-13T10:00:00.000Z') },
] as never)
const result = await getVehicleAvailabilitySummary('vehicle_1', { range })
expect(prisma.reservation.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
vehicleId: 'vehicle_1',
status: { in: ['DRAFT', 'CONFIRMED', 'ACTIVE'] },
endDate: { gt: range.startDate },
}),
}))
expect(result).toEqual({
available: false,
status: 'RESERVED',
nextAvailableAt: new Date('2026-07-13T10:00:00.000Z'),
blockingReason: 'RESERVATION',
})
})
it('can ignore draft reservations for availability checks that only care about firm holds', async () => {
await getVehicleAvailabilitySummary('vehicle_1', { range, includeDraftReservations: false })
expect(prisma.reservation.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ status: { in: ['CONFIRMED', 'ACTIVE'] } }),
}))
})
it('prioritizes maintenance over generic blocks and reservations when several sources overlap', async () => {
vi.mocked(prisma.reservation.findMany).mockResolvedValue([
{ startDate: new Date('2026-07-10T00:00:00.000Z'), endDate: new Date('2026-07-11T00:00:00.000Z') },
] as never)
vi.mocked(prisma.vehicleCalendarBlock.findMany).mockResolvedValue([
{ startDate: new Date('2026-07-10T00:00:00.000Z'), endDate: new Date('2026-07-12T00:00:00.000Z'), type: 'BLOCK' },
{ startDate: new Date('2026-07-10T00:00:00.000Z'), endDate: new Date('2026-07-13T00:00:00.000Z'), type: 'MAINTENANCE' },
] as never)
const result = await getVehicleAvailabilitySummary('vehicle_1', { range })
expect(result.status).toBe('MAINTENANCE')
expect(result.blockingReason).toBe('MAINTENANCE')
expect(result.nextAvailableAt).toEqual(new Date('2026-07-13T00:00:00.000Z'))
})
it('returns available when no source overlaps the requested range', async () => {
vi.mocked(prisma.reservation.findMany).mockResolvedValue([
{ startDate: new Date('2026-07-01T00:00:00.000Z'), endDate: new Date('2026-07-02T00:00:00.000Z') },
] as never)
const result = await getVehicleAvailabilitySummary('vehicle_1', { range })
expect(result).toEqual({
available: true,
status: 'AVAILABLE',
nextAvailableAt: null,
blockingReason: null,
})
})
})
@@ -58,13 +58,15 @@ export async function getVehicleAvailabilitySummary(
{
range,
includeDraftReservations = true,
companyId,
}: {
range?: DateRange
includeDraftReservations?: boolean
companyId?: string
} = {},
): Promise<VehicleAvailabilitySummary> {
const vehicle = await prisma.vehicle.findUniqueOrThrow({
where: { id: vehicleId },
const vehicle = await prisma.vehicle.findFirstOrThrow({
where: { id: vehicleId, ...(companyId ? { companyId } : {}) },
select: { status: true },
})
@@ -103,12 +105,12 @@ export async function getVehicleAvailabilitySummary(
])
const sources: AvailabilitySource[] = [
...reservations.map((reservation) => ({
...reservations.map((reservation: any) => ({
startDate: reservation.startDate,
endDate: reservation.endDate,
kind: 'RESERVATION' as const,
})),
...blocks.map((block) => ({
...blocks.map((block: any) => ({
startDate: block.startDate,
endDate: block.endDate,
kind: block.type === 'MAINTENANCE' ? 'MAINTENANCE' as const : 'BLOCK' as const,