diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 8e938d7..660291c 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -133,8 +133,8 @@ jobs: - run: npm run build --workspace @rentaldrivego/types - run: npm run test:homepage - storefront-tests: - name: Storefront Unit Tests + carplace-tests: + name: Carplace Unit Tests runs-on: ubuntu-latest needs: type-check steps: @@ -169,7 +169,7 @@ jobs: node -e "require('@rollup/rollup-linux-arm64-gnu')" fi - run: npm run build --workspace @rentaldrivego/types - - run: npm run test:storefront + - run: npm run test:carplace admin-tests: name: Admin Unit Tests diff --git a/apps/api/src/modules/carplace/carplace.repo.edge.test.ts b/apps/api/src/modules/carplace/carplace.repo.edge.test.ts index d419cc2..fcc6857 100644 --- a/apps/api/src/modules/carplace/carplace.repo.edge.test.ts +++ b/apps/api/src/modules/carplace/carplace.repo.edge.test.ts @@ -52,7 +52,10 @@ describe('carplace.repo public query and write boundaries', () => { where: { id: 'vehicle_1', isPublished: true, - company: { slug: 'atlas', status: { in: ['ACTIVE', 'TRIALING'] } }, + company: { + OR: [{ slug: 'atlas' }, { brand: { subdomain: 'atlas' } }], + status: { in: ['ACTIVE', 'TRIALING'] }, + }, }, include: { company: { include: { brand: true } } }, }) diff --git a/apps/api/src/tests/api/employee-carplace-notification.api.test.ts b/apps/api/src/tests/api/employee-carplace-notification.api.test.ts index 128ca7e..6877c14 100644 --- a/apps/api/src/tests/api/employee-carplace-notification.api.test.ts +++ b/apps/api/src/tests/api/employee-carplace-notification.api.test.ts @@ -44,6 +44,7 @@ vi.mock('../../modules/carplace/carplace.service', () => ({ getCities: vi.fn(), getListedCompanies: vi.fn(), searchVehicles: vi.fn(), + searchVehiclesPage: vi.fn(), getVehicleDetail: vi.fn(), createCarplaceReservation: vi.fn(), getCompanyPage: vi.fn(), @@ -73,7 +74,11 @@ describe('employee, notification, and carplace API validation contracts', () => vi.mocked(employeeService.updateLanguage).mockResolvedValue({ language: 'ar' } as never) vi.mocked(employeeService.resetPassword).mockResolvedValue({ message: 'Password updated successfully. You can now sign in.' } as never) vi.mocked(notificationService.setPreferences).mockResolvedValue({ success: true } as never) - vi.mocked(carplaceService.searchVehicles).mockResolvedValue({ companies: [], vehicles: [], pagination: { page: 1, pageSize: 20, total: 0 } } as never) + vi.mocked(carplaceService.searchVehiclesPage).mockResolvedValue({ + items: [], + pagination: { page: 1, pageSize: 20, totalItems: 0, totalPages: 0 }, + facets: { categories: [], transmissions: [], makes: [], price: { min: null, max: null } }, + } as never) vi.mocked(carplaceService.submitReview).mockResolvedValue({ id: 'review_1' } as never) }) @@ -114,11 +119,12 @@ describe('employee, notification, and carplace API validation contracts', () => const res = await request(app).get('/api/v1/carplace/search?city=Casablanca') expect(res.status).toBe(200) - expect(carplaceService.searchVehicles).toHaveBeenCalledWith(expect.objectContaining({ + expect(carplaceService.searchVehiclesPage).toHaveBeenCalledWith(expect.objectContaining({ city: 'Casablanca', page: 1, pageSize: 20, })) + expect(res.body.data.pagination).toEqual({ page: 1, pageSize: 20, totalItems: 0, totalPages: 0 }) }) it('rejects out-of-range public review ratings before service execution', async () => { diff --git a/apps/api/src/tests/api/public-validation.api.test.ts b/apps/api/src/tests/api/public-validation.api.test.ts index 966f9d2..8cde4dd 100644 --- a/apps/api/src/tests/api/public-validation.api.test.ts +++ b/apps/api/src/tests/api/public-validation.api.test.ts @@ -35,6 +35,7 @@ vi.mock('../../modules/carplace/carplace.service', () => ({ getCities: vi.fn(), getListedCompanies: vi.fn(), searchVehicles: vi.fn(), + searchVehiclesPage: vi.fn(), createCarplaceReservation: vi.fn(), getReviewContext: vi.fn(), submitReview: vi.fn(), @@ -59,7 +60,11 @@ describe('public validation API contracts', () => { vi.mocked(siteService.checkAvailability).mockResolvedValue({ available: true, nextAvailableAt: null } as never) vi.mocked(siteService.initPayment).mockResolvedValue({ checkoutUrl: 'https://pay.example.test' } as never) vi.mocked(siteService.handleContact).mockResolvedValue({ success: true } as never) - vi.mocked(carplaceService.searchVehicles).mockResolvedValue({ data: [], pagination: { page: 1, pageSize: 20 } } as never) + vi.mocked(carplaceService.searchVehiclesPage).mockResolvedValue({ + items: [], + pagination: { page: 2, pageSize: 30, totalItems: 0, totalPages: 0 }, + facets: { categories: [], transmissions: [], makes: [], price: { min: null, max: null } }, + } as never) vi.mocked(carplaceService.createCarplaceReservation).mockResolvedValue({ id: 'reservation_1' } as never) vi.mocked(carplaceService.submitReview).mockResolvedValue({ id: 'review_1' } as never) }) @@ -124,19 +129,20 @@ describe('public validation API contracts', () => { const res = await request(app).get('/api/v1/carplace/search?city=Rabat&maxPrice=500&page=2&pageSize=30') expect(res.status).toBe(200) - expect(carplaceService.searchVehicles).toHaveBeenCalledWith({ + expect(carplaceService.searchVehiclesPage).toHaveBeenCalledWith({ city: 'Rabat', maxPrice: 500, page: 2, pageSize: 30, }) + expect(res.body.data.pagination).toEqual({ page: 2, pageSize: 30, totalItems: 0, totalPages: 0 }) }) it('rejects oversized carplace pagination before search execution', async () => { const res = await request(app).get('/api/v1/carplace/search?pageSize=500') expect(res.status).toBe(400) - expect(carplaceService.searchVehicles).not.toHaveBeenCalled() + expect(carplaceService.searchVehiclesPage).not.toHaveBeenCalled() }) it('rejects carplace reservations with invalid public contact data before service execution', async () => { diff --git a/apps/carplace/src/lib/api.test.ts b/apps/carplace/src/lib/api.test.ts index 53e871f..9192ae8 100644 --- a/apps/carplace/src/lib/api.test.ts +++ b/apps/carplace/src/lib/api.test.ts @@ -1,11 +1,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { CarplaceApiError, carplaceFetch, carplaceFetchOrDefault, carplacePost } from './api' afterEach(() => { delete process.env.NEXT_PUBLIC_API_URL + delete process.env.NEXT_PUBLIC_CARPLACE_DIRECT_API delete process.env.API_INTERNAL_URL vi.restoreAllMocks() + Reflect.deleteProperty(globalThis, 'window') Reflect.deleteProperty(globalThis, 'fetch') + vi.resetModules() }) describe('carplace API helpers', () => { @@ -13,6 +15,7 @@ describe('carplace API helpers', () => { const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: [{ id: 'vehicle_1' }] }) })) Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock }) + const { carplaceFetch } = await import('./api') await expect(carplaceFetch('/carplace/vehicles')).resolves.toEqual([{ id: 'vehicle_1' }]) expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/carplace\/vehicles$/), { cache: 'no-store', @@ -30,6 +33,7 @@ describe('carplace API helpers', () => { })), }) + const { carplaceFetch } = await import('./api') await expect(carplaceFetch('/carplace/book')).rejects.toMatchObject({ name: 'CarplaceApiError', message: 'Vehicle unavailable', @@ -45,14 +49,18 @@ describe('carplace API helpers', () => { value: vi.fn(async () => ({ ok: false, status: 500, json: async () => ({ message: 'Down' }) })), }) + const { carplaceFetchOrDefault } = await import('./api') await expect(carplaceFetchOrDefault('/carplace/homepage', { hero: null })).resolves.toEqual({ hero: null }) }) it('posts JSON payloads and unwraps successful responses', async () => { process.env.NEXT_PUBLIC_API_URL = 'https://api.example.com/api/v1' + process.env.NEXT_PUBLIC_CARPLACE_DIRECT_API = 'true' + Object.defineProperty(globalThis, 'window', { configurable: true, value: {} }) const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { id: 'reservation_1' } }) })) Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock }) + const { carplacePost } = await import('./api') await expect(carplacePost('/site/reservations', { vehicleId: 'vehicle_1' })).resolves.toEqual({ id: 'reservation_1' }) expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/v1/site/reservations', expect.objectContaining({ method: 'POST', @@ -68,6 +76,7 @@ describe('carplace API helpers', () => { value: vi.fn(async () => ({ ok: false, status: 502, json: async () => { throw new Error('bad json') } })), }) + const { carplaceFetch } = await import('./api') await expect(carplaceFetch('/site/homepage')).rejects.toMatchObject({ name: 'CarplaceApiError', message: 'Request failed', diff --git a/apps/dashboard/src/lib/api.test.ts b/apps/dashboard/src/lib/api.test.ts index cc972f2..c0917bc 100644 --- a/apps/dashboard/src/lib/api.test.ts +++ b/apps/dashboard/src/lib/api.test.ts @@ -16,6 +16,7 @@ afterEach(() => { Reflect.deleteProperty(globalThis, 'window') Reflect.deleteProperty(globalThis, 'fetch') delete process.env.NEXT_PUBLIC_API_URL + delete process.env.API_INTERNAL_URL vi.resetModules() }) @@ -54,6 +55,59 @@ describe('dashboard apiFetch', () => { expect(fetchMock).toHaveBeenCalledWith('https://api.rentaldrivego.ma/api/v1/auth/account/start', expect.any(Object)) }) + it.each([ + ['https://api.rentaldrivego.ma', 'https://api.rentaldrivego.ma/api/v1/auth/account/start'], + ['https://api.rentaldrivego.ma/', 'https://api.rentaldrivego.ma/api/v1/auth/account/start'], + ['https://api.rentaldrivego.ma/api', 'https://api.rentaldrivego.ma/api/v1/auth/account/start'], + ['https://api.rentaldrivego.ma/api/v1', 'https://api.rentaldrivego.ma/api/v1/auth/account/start'], + ['/dashboard/api/v1', '/dashboard/api/v1/auth/account/start'], + ])('normalizes API base %s', async (configuredBase, expectedUrl) => { + installBrowser() + process.env.NEXT_PUBLIC_API_URL = configuredBase + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ data: { ok: true } }), + })) + Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock }) + + const { apiFetch } = await import('./api') + await apiFetch('/auth/account/start', { method: 'POST', body: JSON.stringify({}) }) + + expect(fetchMock).toHaveBeenCalledWith(expectedUrl, expect.any(Object)) + }) + + it('falls back to the dashboard proxy when no public API URL is configured', async () => { + installBrowser() + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ data: { ok: true } }), + })) + Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock }) + + const { apiFetch } = await import('./api') + await apiFetch('/auth/account/start') + + expect(fetchMock).toHaveBeenCalledWith('/dashboard/api/v1/auth/account/start', expect.any(Object)) + }) + + it('does not add a dashboard prefix when an absolute API origin is configured', async () => { + installBrowser() + process.env.NEXT_PUBLIC_API_URL = 'https://api.rentaldrivego.ma/api/v1' + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ data: { ok: true } }), + })) + Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock }) + + const { apiFetch } = await import('./api') + await apiFetch('/auth/account/start') + + const calledUrl = (fetchMock as any).mock.calls[0]?.[0] + expect(calledUrl).toBe('https://api.rentaldrivego.ma/api/v1/auth/account/start') + expect(calledUrl).not.toContain('/dashboard') + expect(calledUrl).not.toContain('/api/v1/api/v1') + }) + it('does not force JSON content type for FormData payloads', async () => { installBrowser() const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { uploaded: true } }) })) diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index b35ba32..dd8d974 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -1,15 +1,18 @@ -function normalizeApiBase(value: string): string { +export function normalizeApiBase(value: string): string { const base = value.replace(/\/+$/, '') + if (/\/api$/.test(base)) return `${base}/v1` return /\/api\/v1$/.test(base) ? base : `${base}/api/v1` } -export const API_BASE = normalizeApiBase( - typeof window === 'undefined' +export function resolveApiBase(): string { + const value = typeof window === 'undefined' ? (process.env.API_INTERNAL_URL || 'http://localhost:4000/api/v1') - : (process.env.NEXT_PUBLIC_DASHBOARD_DIRECT_API === 'true' && process.env.NEXT_PUBLIC_API_URL - ? process.env.NEXT_PUBLIC_API_URL - : '/dashboard/api/v1'), -) + : (process.env.NEXT_PUBLIC_API_URL || '/dashboard/api/v1') + + return normalizeApiBase(value) +} + +export const API_BASE = resolveApiBase() export const EMPLOYEE_PROFILE_KEY = 'employee_profile' @@ -24,7 +27,7 @@ export async function apiFetch(path: string, options?: RequestInit): Promise< headers['Content-Type'] = 'application/json' } - const res = await fetch(`${API_BASE}${path}`, { + const res = await fetch(`${resolveApiBase()}${path}`, { ...options, headers, credentials: 'include', @@ -49,7 +52,7 @@ export async function apiFetch(path: string, options?: RequestInit): Promise< export async function apiFetchServer(path: string, token: string, options?: RequestInit): Promise { const isFormData = typeof FormData !== 'undefined' && options?.body instanceof FormData - const res = await fetch(`${API_BASE}${path}`, { + const res = await fetch(`${resolveApiBase()}${path}`, { ...options, headers: { ...(isFormData ? {} : { 'Content-Type': 'application/json' }), diff --git a/apps/homepage/src/lib/demo/schema.ts b/apps/homepage/src/lib/demo/schema.ts index cfe3633..3eed0fd 100644 --- a/apps/homepage/src/lib/demo/schema.ts +++ b/apps/homepage/src/lib/demo/schema.ts @@ -38,12 +38,12 @@ export const demoLeadSchema = z .regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'invalid_email'), ), company: compactText(160), - fleetSize: z.enum(fleetSizeValues, { error: 'invalid_fleet_size' }), + fleetSize: z.enum(fleetSizeValues, { message: 'invalid_fleet_size' }), market: optionalCompactText(100), preferredLanguage: z.enum(['en', 'fr', 'ar']).optional(), message: optionalCompactText(1_000), idempotencyKey: z.string().uuid('invalid_idempotency_key'), - source: z.enum(demoSourceValues, { error: 'invalid_source' }), + source: z.enum(demoSourceValues, { message: 'invalid_source' }), }) .strict();