fix search feature
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

This commit is contained in:
root
2026-07-26 01:18:05 -04:00
parent f6fcd7ce54
commit 2b422ca197
23 changed files with 880 additions and 51 deletions
@@ -31,9 +31,10 @@ describe('reservation schemas edge cases', () => {
})
it('coerces list pagination and rejects unsafe update/extension payloads', () => {
expect(listQuerySchema.parse({ page: '3', pageSize: '50', status: 'CONFIRMED' })).toMatchObject({ page: 3, pageSize: 50 })
expect(listQuerySchema.parse({ page: '3', pageSize: '50', status: 'CONFIRMED', search: ' c4 ' })).toMatchObject({ page: 3, pageSize: 50, search: 'c4' })
expect(listQuerySchema.parse({})).toMatchObject({ page: 1, pageSize: 20 })
expect(listQuerySchema.safeParse({ pageSize: 101 }).success).toBe(false)
expect(listQuerySchema.safeParse({ search: 'x'.repeat(101) }).success).toBe(false)
expect(updateSchema.safeParse({ depositAmount: -1 }).success).toBe(false)
expect(extendSchema.safeParse({ newEndDate: '2026-07-05T10:00:00.000Z', reason: '' }).success).toBe(false)
expect(approvalSchema.parse({ approved: true })).toEqual({ approved: true })
@@ -63,6 +63,7 @@ export const listQuerySchema = z.object({
status: z.string().optional(),
vehicleId: z.string().optional(),
source: z.string().optional(),
search: z.string().trim().max(100).optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
page: z.coerce.number().int().min(1).default(1),
@@ -7,8 +7,12 @@ import { applyAdditionalDriversToReservation } from './reservation.additional-dr
import { parseReservationExtras, normalizeOptionalString, serializeReservationForDashboard, buildReservationWorkflow } from './reservation.presenter'
import * as repo from './reservation.repo'
const RESERVATION_STATUSES = ['DRAFT', 'CONFIRMED', 'ACTIVE', 'COMPLETED', 'CANCELLED', 'NO_SHOW'] as const
const BOOKING_SOURCES = ['DASHBOARD', 'PUBLIC_SITE', 'CARPLACE', 'API'] as const
export async function listReservations(companyId: string, query: {
status?: string; vehicleId?: string; source?: string
search?: string
startDate?: string; endDate?: string; page?: number; pageSize?: number
}) {
const page = query.page ?? 1
@@ -19,6 +23,24 @@ export async function listReservations(companyId: string, query: {
if (query.source) where.source = query.source
if (query.startDate) where.startDate = { gte: new Date(query.startDate) }
if (query.endDate) where.endDate = { lte: new Date(query.endDate) }
const search = query.search?.trim()
if (search) {
const normalizedEnumSearch = search.toUpperCase().replace(/[\s-]+/g, '_')
where.OR = [
{ contractNumber: { contains: search, mode: 'insensitive' } },
{ vehicle: { is: { make: { contains: search, mode: 'insensitive' } } } },
{ vehicle: { is: { model: { contains: search, mode: 'insensitive' } } } },
{ customer: { is: { firstName: { contains: search, mode: 'insensitive' } } } },
{ customer: { is: { lastName: { contains: search, mode: 'insensitive' } } } },
{ customer: { is: { email: { contains: search, mode: 'insensitive' } } } },
]
if ((RESERVATION_STATUSES as readonly string[]).includes(normalizedEnumSearch)) {
where.OR.push({ status: normalizedEnumSearch })
}
if ((BOOKING_SOURCES as readonly string[]).includes(normalizedEnumSearch)) {
where.OR.push({ source: normalizedEnumSearch })
}
}
const [reservations, total] = await repo.findMany(where, (page - 1) * pageSize, pageSize)
return {
@@ -74,7 +74,7 @@ import * as additionalDriverService from './reservation.additional-driver.servic
import { validateLicense } from '../../services/licenseValidationService'
import { sendNotification } from '../../services/notificationService'
import { buildReservationWorkflow } from './reservation.presenter'
import { createReservation } from './reservation.service'
import { createReservation, listReservations } from './reservation.service'
import { confirmReservation, checkinReservation, checkoutReservation, closeReservation } from './reservation.lifecycle.service'
import { approveAdditionalDriver } from './reservation.additional-driver.service'
@@ -112,6 +112,47 @@ 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 () => {
@@ -0,0 +1,21 @@
import { Router } from 'express'
import { requireCompanyAuth } from '../../middleware/requireCompanyAuth'
import { requireTenant } from '../../middleware/requireTenant'
import { requireSubscriptionRead } from '../../middleware/requireSubscription'
import { parseQuery } from '../../http/validate'
import { ok } from '../../http/respond'
import { globalSearchQuerySchema } from './search.schemas'
import * as service from './search.service'
const router = Router()
router.use(requireCompanyAuth, requireTenant, requireSubscriptionRead)
router.get('/', async (req, res, next) => {
try {
const { search, limit } = parseQuery(globalSearchQuerySchema, req)
ok(res, await service.globalSearch(req.companyId, search, limit))
} catch (err) { next(err) }
})
export default router
@@ -0,0 +1,6 @@
import { z } from 'zod'
export const globalSearchQuerySchema = z.object({
search: z.string().trim().max(100).optional().default(''),
limit: z.coerce.number().int().min(1).max(10).optional().default(5),
})
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../../lib/prisma', () => ({
prisma: {
vehicle: { findMany: vi.fn() },
customer: { findMany: vi.fn() },
reservation: { findMany: vi.fn() },
rentalPayment: { findMany: vi.fn() },
billingInvoice: { findMany: vi.fn() },
offer: { findMany: vi.fn() },
employee: { findMany: vi.fn() },
},
}))
import { prisma } from '../../lib/prisma'
import { globalSearch } from './search.service'
describe('globalSearch', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(prisma.vehicle.findMany).mockResolvedValue([] as never)
vi.mocked(prisma.customer.findMany).mockResolvedValue([] as never)
vi.mocked(prisma.reservation.findMany).mockResolvedValue([] as never)
vi.mocked(prisma.rentalPayment.findMany).mockResolvedValue([] as never)
vi.mocked(prisma.billingInvoice.findMany).mockResolvedValue([] as never)
vi.mocked(prisma.offer.findMany).mockResolvedValue([] as never)
vi.mocked(prisma.employee.findMany).mockResolvedValue([] as never)
})
it('searches tenant-scoped account records and returns normalized vehicle hits', async () => {
vi.mocked(prisma.vehicle.findMany).mockResolvedValue([
{
id: 'vehicle_1',
make: 'Citroen',
model: 'C4',
licensePlate: '123-A-45',
status: 'AVAILABLE',
category: 'COMPACT',
year: 2024,
},
] as never)
const result = await globalSearch('company_1', 'c4', 5)
expect(prisma.vehicle.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
companyId: 'company_1',
OR: expect.arrayContaining([
{ model: { contains: 'c4', mode: 'insensitive' } },
{ licensePlate: { contains: 'c4', mode: 'insensitive' } },
{ features: { hasSome: ['c4', 'C4'] } },
{ pickupLocations: { hasSome: ['c4', 'C4'] } },
{ dropoffLocations: { hasSome: ['c4', 'C4'] } },
]),
}),
take: 5,
}))
expect(result.groups.vehicle).toEqual([
expect.objectContaining({
title: 'Citroen C4',
href: '/fleet/vehicle_1',
}),
])
expect(result.total).toBeGreaterThan(0)
})
it('returns page hits and exact enum matches', async () => {
await globalSearch('company_1', 'manual', 3)
expect(prisma.vehicle.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
OR: expect.arrayContaining([{ transmission: 'MANUAL' }]),
}),
}))
})
it('does not query data tables for empty search text', async () => {
const result = await globalSearch('company_1', ' ', 5)
expect(result.total).toBe(0)
expect(prisma.vehicle.findMany).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,327 @@
import { prisma } from '../../lib/prisma'
export type GlobalSearchResultType =
| 'page'
| 'vehicle'
| 'customer'
| 'reservation'
| 'contract'
| 'invoice'
| 'payment'
| 'offer'
| 'team'
export type GlobalSearchResult = {
id: string
type: GlobalSearchResultType
title: string
subtitle: string
href: string
meta?: string
}
export type GlobalSearchResponse = {
query: string
results: GlobalSearchResult[]
groups: Record<GlobalSearchResultType, GlobalSearchResult[]>
total: number
}
const RESERVATION_STATUSES = ['DRAFT', 'CONFIRMED', 'ACTIVE', 'COMPLETED', 'CANCELLED', 'NO_SHOW'] as const
const BOOKING_SOURCES = ['DASHBOARD', 'PUBLIC_SITE', 'CARPLACE', 'API'] as const
const VEHICLE_STATUSES = ['AVAILABLE', 'RESERVED', 'READY', 'RENTED', 'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE'] as const
const VEHICLE_CATEGORIES = ['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const
const VEHICLE_TRANSMISSIONS = ['AUTOMATIC', 'MANUAL'] as const
const VEHICLE_FUEL_TYPES = ['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const
const DASHBOARD_PAGES: Array<Omit<GlobalSearchResult, 'type'> & { keywords: string[] }> = [
{ id: 'page-dashboard', title: 'Dashboard', subtitle: 'KPIs, recent reservations, and analytics', href: '/', keywords: ['dashboard', 'overview', 'analytics', 'kpi', 'home'] },
{ id: 'page-reservations', title: 'Reservations', subtitle: 'Bookings, rental workflow, and reservation status', href: '/reservations', keywords: ['reservation', 'reservations', 'booking', 'bookings', 'rental'] },
{ id: 'page-fleet', title: 'Fleet', subtitle: 'Vehicles, status, pricing, and maintenance', href: '/fleet', keywords: ['fleet', 'vehicle', 'vehicles', 'car', 'cars', 'maintenance', 'pricing'] },
{ id: 'page-customers', title: 'Customers', subtitle: 'Customer records, license details, and flags', href: '/customers', keywords: ['customer', 'customers', 'license', 'driver', 'renter'] },
{ id: 'page-contracts', title: 'Contracts', subtitle: 'Rental agreements and contract documents', href: '/contracts', keywords: ['contract', 'contracts', 'agreement', 'agreements'] },
{ id: 'page-billing', title: 'Billing', subtitle: 'Invoices, payments, deposits, and balances', href: '/billing', keywords: ['billing', 'invoice', 'invoices', 'payment', 'payments', 'deposit', 'balance'] },
{ id: 'page-offers', title: 'Offers', subtitle: 'Promotions, promo codes, and public offers', href: '/offers', keywords: ['offer', 'offers', 'promo', 'promotion', 'discount', 'coupon'] },
{ id: 'page-team', title: 'Team', subtitle: 'Employees, roles, seats, and invitations', href: '/team', keywords: ['team', 'employee', 'employees', 'member', 'members', 'role', 'roles'] },
{ id: 'page-notifications', title: 'Notifications', subtitle: 'Inbox, alerts, and message preferences', href: '/notifications', keywords: ['notification', 'notifications', 'alert', 'alerts', 'inbox'] },
{ id: 'page-settings', title: 'Settings', subtitle: 'Company profile, contract, billing, insurance, and pricing settings', href: '/settings', keywords: ['settings', 'company', 'profile', 'brand', 'insurance', 'accounting'] },
]
function compact(parts: Array<string | number | null | undefined>) {
return parts.filter((part) => part !== null && part !== undefined && String(part).trim() !== '').join(' · ')
}
function normalizeEnumSearch(query: string) {
return query.toUpperCase().replace(/[\s-]+/g, '_')
}
function textContains(query: string) {
return { contains: query, mode: 'insensitive' as const }
}
function listTextVariants(value: string) {
const lower = value.toLowerCase()
const upper = value.toUpperCase()
const title = lower.replace(/\b\w/g, (char) => char.toUpperCase())
return Array.from(new Set([value, lower, upper, title]))
}
function pageResults(query: string, limit: number): GlobalSearchResult[] {
const normalized = query.toLowerCase()
return DASHBOARD_PAGES
.filter((page) =>
page.title.toLowerCase().includes(normalized) ||
page.subtitle.toLowerCase().includes(normalized) ||
page.keywords.some((keyword) => keyword.includes(normalized)),
)
.slice(0, limit)
.map(({ keywords: _keywords, ...page }) => ({ ...page, type: 'page' as const }))
}
function emptyGroups(): Record<GlobalSearchResultType, GlobalSearchResult[]> {
return {
page: [],
vehicle: [],
customer: [],
reservation: [],
contract: [],
invoice: [],
payment: [],
offer: [],
team: [],
}
}
export async function globalSearch(companyId: string, query: string, limit = 5): Promise<GlobalSearchResponse> {
const search = query.trim().slice(0, 100)
const groups = emptyGroups()
if (!search) return { query: search, results: [], groups, total: 0 }
const enumSearch = normalizeEnumSearch(search)
const exactTextMatches = listTextVariants(search)
const numericSearch = Number(search)
const vehicleOr: any[] = [
{ make: textContains(search) },
{ model: textContains(search) },
{ licensePlate: textContains(search) },
{ vin: textContains(search) },
{ color: textContains(search) },
{ notes: textContains(search) },
{ features: { hasSome: exactTextMatches } },
{ pickupLocations: { hasSome: exactTextMatches } },
{ dropoffLocations: { hasSome: exactTextMatches } },
]
if ((VEHICLE_STATUSES as readonly string[]).includes(enumSearch)) vehicleOr.push({ status: enumSearch })
if ((VEHICLE_CATEGORIES as readonly string[]).includes(enumSearch)) vehicleOr.push({ category: enumSearch })
if ((VEHICLE_TRANSMISSIONS as readonly string[]).includes(enumSearch)) vehicleOr.push({ transmission: enumSearch })
if ((VEHICLE_FUEL_TYPES as readonly string[]).includes(enumSearch)) vehicleOr.push({ fuelType: enumSearch })
if (Number.isInteger(numericSearch)) {
vehicleOr.push(
{ year: numericSearch },
{ seats: numericSearch },
{ mileage: numericSearch },
{ dailyRate: numericSearch },
)
}
const reservationOr: any[] = [
{ contractNumber: textContains(search) },
{ invoiceNumber: textContains(search) },
{ bookingReference: textContains(search) },
{ carplaceRef: textContains(search) },
{ paymentStatus: textContains(search) },
{ promoCodeUsed: textContains(search) },
{ pickupLocation: textContains(search) },
{ returnLocation: textContains(search) },
{ notes: textContains(search) },
{ vehicle: { is: { make: textContains(search) } } },
{ vehicle: { is: { model: textContains(search) } } },
{ vehicle: { is: { licensePlate: textContains(search) } } },
{ customer: { is: { firstName: textContains(search) } } },
{ customer: { is: { lastName: textContains(search) } } },
{ customer: { is: { email: textContains(search) } } },
{ customer: { is: { phone: textContains(search) } } },
]
if ((RESERVATION_STATUSES as readonly string[]).includes(enumSearch)) reservationOr.push({ status: enumSearch })
if ((BOOKING_SOURCES as readonly string[]).includes(enumSearch)) reservationOr.push({ source: enumSearch })
const [
vehicles,
customers,
reservations,
payments,
billingInvoices,
offers,
employees,
] = await Promise.all([
prisma.vehicle.findMany({
where: { companyId, OR: vehicleOr },
orderBy: { updatedAt: 'desc' },
take: limit,
}),
prisma.customer.findMany({
where: {
companyId,
OR: [
{ firstName: textContains(search) },
{ lastName: textContains(search) },
{ email: textContains(search) },
{ phone: textContains(search) },
{ driverLicense: textContains(search) },
{ licenseNumber: textContains(search) },
{ nationality: textContains(search) },
{ notes: textContains(search) },
{ flagReason: textContains(search) },
],
},
orderBy: { updatedAt: 'desc' },
take: limit,
}),
prisma.reservation.findMany({
where: { companyId, OR: reservationOr },
include: { vehicle: true, customer: true },
orderBy: { updatedAt: 'desc' },
take: limit,
}),
prisma.rentalPayment.findMany({
where: {
companyId,
OR: [
{ reference: textContains(search) },
{ note: textContains(search) },
{ paymentMethod: textContains(search) },
{ amanpayTransactionId: textContains(search) },
{ paypalCaptureId: textContains(search) },
{ reservation: { is: { invoiceNumber: textContains(search) } } },
{ reservation: { is: { contractNumber: textContains(search) } } },
{ reservation: { is: { customer: { is: { email: textContains(search) } } } } },
],
},
include: { reservation: { include: { customer: true, vehicle: true } } },
orderBy: { updatedAt: 'desc' },
take: limit,
}),
prisma.billingInvoice.findMany({
where: {
companyId,
OR: [
{ invoiceNumber: textContains(search) },
{ billingName: textContains(search) },
{ billingEmail: textContains(search) },
{ providerInvoiceId: textContains(search) },
{ adminReason: textContains(search) },
],
},
orderBy: { updatedAt: 'desc' },
take: limit,
}),
prisma.offer.findMany({
where: {
companyId,
OR: [
{ title: textContains(search) },
{ description: textContains(search) },
{ termsAndConds: textContains(search) },
{ promoCode: textContains(search) },
],
},
orderBy: { updatedAt: 'desc' },
take: limit,
}),
prisma.employee.findMany({
where: {
companyId,
OR: [
{ firstName: textContains(search) },
{ lastName: textContains(search) },
{ email: textContains(search) },
{ phone: textContains(search) },
],
},
orderBy: { updatedAt: 'desc' },
take: limit,
}),
])
groups.page = pageResults(search, limit)
groups.vehicle = vehicles.map((vehicle: any) => ({
id: vehicle.id,
type: 'vehicle',
title: `${vehicle.make} ${vehicle.model}`,
subtitle: compact([vehicle.licensePlate, vehicle.status, vehicle.category]),
href: `/fleet/${vehicle.id}`,
meta: vehicle.year ? String(vehicle.year) : undefined,
}))
groups.customer = customers.map((customer: any) => ({
id: customer.id,
type: 'customer',
title: `${customer.firstName} ${customer.lastName}`,
subtitle: compact([customer.email, customer.phone, customer.flagged ? 'Flagged' : null]),
href: `/customers?search=${encodeURIComponent(customer.email || `${customer.firstName} ${customer.lastName}`)}`,
meta: customer.licenseValidationStatus,
}))
groups.reservation = reservations.map((reservation: any) => ({
id: reservation.id,
type: 'reservation',
title: reservation.bookingReference || `${reservation.customer.firstName} ${reservation.customer.lastName}`,
subtitle: compact([`${reservation.vehicle.make} ${reservation.vehicle.model}`, reservation.status, reservation.source]),
href: `/reservations/${reservation.id}`,
meta: compact([reservation.contractNumber, reservation.invoiceNumber]),
}))
groups.contract = reservations
.filter((reservation: any) => reservation.contractNumber)
.map((reservation: any) => ({
id: `contract-${reservation.id}`,
type: 'contract',
title: reservation.contractNumber,
subtitle: compact([`${reservation.customer.firstName} ${reservation.customer.lastName}`, `${reservation.vehicle.make} ${reservation.vehicle.model}`]),
href: `/contracts/${reservation.id}`,
meta: reservation.status,
}))
groups.invoice = [
...reservations
.filter((reservation: any) => reservation.invoiceNumber)
.map((reservation: any) => ({
id: `reservation-invoice-${reservation.id}`,
type: 'invoice' as const,
title: reservation.invoiceNumber,
subtitle: compact([`${reservation.customer.firstName} ${reservation.customer.lastName}`, `${reservation.vehicle.make} ${reservation.vehicle.model}`]),
href: `/billing?search=${encodeURIComponent(reservation.invoiceNumber)}`,
meta: reservation.paymentStatus,
})),
...billingInvoices.map((invoice: any) => ({
id: invoice.id,
type: 'invoice' as const,
title: invoice.invoiceNumber || invoice.id,
subtitle: compact([invoice.billingName, invoice.billingEmail, invoice.status]),
href: `/billing?search=${encodeURIComponent(invoice.invoiceNumber || invoice.billingEmail || invoice.id)}`,
meta: invoice.amountDue != null ? `${invoice.amountDue} ${invoice.currency}` : undefined,
})),
].slice(0, limit)
groups.payment = payments.map((payment: any) => ({
id: payment.id,
type: 'payment',
title: payment.reference || payment.amanpayTransactionId || payment.paypalCaptureId || payment.id,
subtitle: compact([payment.status, payment.type, payment.reservation?.customer?.email]),
href: `/billing?search=${encodeURIComponent(payment.reference || payment.reservation?.invoiceNumber || payment.id)}`,
meta: `${payment.amount} ${payment.currency}`,
}))
groups.offer = offers.map((offer: any) => ({
id: offer.id,
type: 'offer',
title: offer.title,
subtitle: compact([offer.promoCode, offer.isActive ? 'Active' : 'Inactive', offer.isPublic ? 'Public' : 'Private']),
href: `/offers?search=${encodeURIComponent(offer.promoCode || offer.title)}`,
meta: offer.type,
}))
groups.team = employees.map((employee: any) => ({
id: employee.id,
type: 'team',
title: `${employee.firstName} ${employee.lastName}`,
subtitle: compact([employee.email, employee.role, employee.isActive ? 'Active' : 'Inactive']),
href: `/team?search=${encodeURIComponent(employee.email)}`,
}))
const results = Object.values(groups).flat()
return { query: search, results, groups, total: results.length }
}
@@ -52,14 +52,16 @@ describe('vehicle.schemas', () => {
})
it('coerces list and calendar query params while enforcing page and month bounds', () => {
expect(listQuerySchema.parse({ page: '3', pageSize: '50', status: 'AVAILABLE' })).toEqual({
expect(listQuerySchema.parse({ page: '3', pageSize: '50', status: 'AVAILABLE', search: ' c4 ' })).toEqual({
page: 3,
pageSize: 50,
status: 'AVAILABLE',
search: 'c4',
})
expect(calendarQuerySchema.parse({ year: '2026', month: '6' })).toEqual({ year: 2026, month: 6 })
expect(() => calendarQuerySchema.parse({ year: '2026', month: '13' })).toThrow()
expect(() => listQuerySchema.parse({ page: '0' })).toThrow()
expect(() => listQuerySchema.parse({ search: 'x'.repeat(101) })).toThrow()
})
it('accepts date-only and offset timestamps for calendar blocks', () => {
@@ -28,6 +28,7 @@ export const listQuerySchema = z.object({
status: z.string().optional(),
category: z.string().optional(),
published: z.string().optional(),
search: z.string().trim().max(100).optional(),
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
})
@@ -345,7 +345,19 @@ function presentVehiclePricing(vehicle: any, configuration: any) {
}
}
export async function listVehicles(companyId: string, query: { status?: string; category?: string; published?: string; page?: number; pageSize?: number }) {
const VEHICLE_STATUSES = ['AVAILABLE', 'RESERVED', 'READY', 'RENTED', 'RETURNED', 'NEEDS_CLEANING', 'MAINTENANCE', 'DAMAGE_REVIEW', 'OUT_OF_SERVICE'] as const
const VEHICLE_CATEGORIES = ['ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK'] as const
const VEHICLE_TRANSMISSIONS = ['AUTOMATIC', 'MANUAL'] as const
const VEHICLE_FUEL_TYPES = ['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID'] as const
function listTextVariants(value: string) {
const lower = value.toLowerCase()
const upper = value.toUpperCase()
const title = lower.replace(/\b\w/g, (char) => char.toUpperCase())
return Array.from(new Set([value, lower, upper, title]))
}
export async function listVehicles(companyId: string, query: { status?: string; category?: string; published?: string; search?: string; page?: number; pageSize?: number }) {
const page = query.page ?? 1
const pageSize = query.pageSize ?? 20
const { status, category, published } = query
@@ -353,6 +365,43 @@ export async function listVehicles(companyId: string, query: { status?: string;
if (status) where.status = status
if (category) where.category = category
if (published !== undefined) where.isPublished = published === 'true'
const search = query.search?.trim()
if (search) {
const normalizedEnumSearch = search.toUpperCase().replace(/[\s-]+/g, '_')
const exactTextMatches = listTextVariants(search)
const numericSearch = Number(search)
where.OR = [
{ make: { contains: search, mode: 'insensitive' } },
{ model: { contains: search, mode: 'insensitive' } },
{ licensePlate: { contains: search, mode: 'insensitive' } },
{ vin: { contains: search, mode: 'insensitive' } },
{ color: { contains: search, mode: 'insensitive' } },
{ notes: { contains: search, mode: 'insensitive' } },
{ features: { hasSome: exactTextMatches } },
{ pickupLocations: { hasSome: exactTextMatches } },
{ dropoffLocations: { hasSome: exactTextMatches } },
]
if ((VEHICLE_STATUSES as readonly string[]).includes(normalizedEnumSearch)) {
where.OR.push({ status: normalizedEnumSearch })
}
if ((VEHICLE_CATEGORIES as readonly string[]).includes(normalizedEnumSearch)) {
where.OR.push({ category: normalizedEnumSearch })
}
if ((VEHICLE_TRANSMISSIONS as readonly string[]).includes(normalizedEnumSearch)) {
where.OR.push({ transmission: normalizedEnumSearch })
}
if ((VEHICLE_FUEL_TYPES as readonly string[]).includes(normalizedEnumSearch)) {
where.OR.push({ fuelType: normalizedEnumSearch })
}
if (Number.isInteger(numericSearch)) {
where.OR.push(
{ year: numericSearch },
{ seats: numericSearch },
{ mileage: numericSearch },
{ dailyRate: numericSearch },
)
}
}
const [vehicles, total] = await repo.findMany(where, (page - 1) * pageSize, pageSize)
return presentVehicleList(vehicles.map(presentVehicle), { total, page, pageSize, totalPages: Math.ceil(total / pageSize) })
}
@@ -28,6 +28,65 @@ beforeEach(() => {
})
describe('vehicle.service', () => {
describe('listVehicles', () => {
it('passes fleet search across vehicle identity, feature, location, and numeric fields', async () => {
vi.mocked(repo.findMany).mockResolvedValue([[{ ...mockVehicle, make: 'Citroen', model: 'C4' }], 1] as any)
const result = await service.listVehicles('comp_1', { search: 'c4', pageSize: 100 })
expect(repo.findMany).toHaveBeenCalledWith(
expect.objectContaining({
companyId: 'comp_1',
OR: expect.arrayContaining([
{ make: { contains: 'c4', mode: 'insensitive' } },
{ model: { contains: 'c4', mode: 'insensitive' } },
{ licensePlate: { contains: 'c4', mode: 'insensitive' } },
{ vin: { contains: 'c4', mode: 'insensitive' } },
{ features: { hasSome: ['c4', 'C4'] } },
{ pickupLocations: { hasSome: ['c4', 'C4'] } },
{ dropoffLocations: { hasSome: ['c4', 'C4'] } },
]),
}),
0,
100,
)
expect(result.data[0]).toMatchObject({ make: 'Citroen', model: 'C4' })
})
it('searches numeric vehicle fields when the query is an integer', async () => {
vi.mocked(repo.findMany).mockResolvedValue([[], 0] as any)
await service.listVehicles('comp_1', { search: '2024' })
expect(repo.findMany).toHaveBeenCalledWith(
expect.objectContaining({
OR: expect.arrayContaining([
{ year: 2024 },
{ seats: 2024 },
{ mileage: 2024 },
{ dailyRate: 2024 },
]),
}),
0,
20,
)
})
it('adds exact enum matches for fleet search terms', async () => {
vi.mocked(repo.findMany).mockResolvedValue([[], 0] as any)
await service.listVehicles('comp_1', { search: 'electric' })
expect(repo.findMany).toHaveBeenCalledWith(
expect.objectContaining({
OR: expect.arrayContaining([{ fuelType: 'ELECTRIC' }]),
}),
0,
20,
)
})
})
describe('createVehicle', () => {
it('creates a vehicle with companyId', async () => {
vi.mocked(repo.create).mockResolvedValue(mockVehicle as any)