fix test issues
Build & Deploy / Build & Push Docker Image (push) Successful in 3m1s
Test / Type Check (all packages) (push) Successful in 55s
Build & Deploy / Deploy to VPS (push) Successful in 5s
Test / API Unit Tests (push) Successful in 47s
Test / Homepage Unit Tests (push) Successful in 42s
Test / Carplace Unit Tests (push) Successful in 42s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Successful in 41s
Test / API Integration Tests (push) Successful in 1m0s

This commit is contained in:
root
2026-07-02 18:50:03 -04:00
parent e3f40410e9
commit f330efb1ad
8 changed files with 102 additions and 21 deletions
+3 -3
View File
@@ -133,8 +133,8 @@ jobs:
- run: npm run build --workspace @rentaldrivego/types - run: npm run build --workspace @rentaldrivego/types
- run: npm run test:homepage - run: npm run test:homepage
storefront-tests: carplace-tests:
name: Storefront Unit Tests name: Carplace Unit Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: type-check needs: type-check
steps: steps:
@@ -169,7 +169,7 @@ jobs:
node -e "require('@rollup/rollup-linux-arm64-gnu')" node -e "require('@rollup/rollup-linux-arm64-gnu')"
fi fi
- run: npm run build --workspace @rentaldrivego/types - run: npm run build --workspace @rentaldrivego/types
- run: npm run test:storefront - run: npm run test:carplace
admin-tests: admin-tests:
name: Admin Unit Tests name: Admin Unit Tests
@@ -52,7 +52,10 @@ describe('carplace.repo public query and write boundaries', () => {
where: { where: {
id: 'vehicle_1', id: 'vehicle_1',
isPublished: true, 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 } } }, include: { company: { include: { brand: true } } },
}) })
@@ -44,6 +44,7 @@ vi.mock('../../modules/carplace/carplace.service', () => ({
getCities: vi.fn(), getCities: vi.fn(),
getListedCompanies: vi.fn(), getListedCompanies: vi.fn(),
searchVehicles: vi.fn(), searchVehicles: vi.fn(),
searchVehiclesPage: vi.fn(),
getVehicleDetail: vi.fn(), getVehicleDetail: vi.fn(),
createCarplaceReservation: vi.fn(), createCarplaceReservation: vi.fn(),
getCompanyPage: 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.updateLanguage).mockResolvedValue({ language: 'ar' } as never)
vi.mocked(employeeService.resetPassword).mockResolvedValue({ message: 'Password updated successfully. You can now sign in.' } 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(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) 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') const res = await request(app).get('/api/v1/carplace/search?city=Casablanca')
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(carplaceService.searchVehicles).toHaveBeenCalledWith(expect.objectContaining({ expect(carplaceService.searchVehiclesPage).toHaveBeenCalledWith(expect.objectContaining({
city: 'Casablanca', city: 'Casablanca',
page: 1, page: 1,
pageSize: 20, 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 () => { it('rejects out-of-range public review ratings before service execution', async () => {
@@ -35,6 +35,7 @@ vi.mock('../../modules/carplace/carplace.service', () => ({
getCities: vi.fn(), getCities: vi.fn(),
getListedCompanies: vi.fn(), getListedCompanies: vi.fn(),
searchVehicles: vi.fn(), searchVehicles: vi.fn(),
searchVehiclesPage: vi.fn(),
createCarplaceReservation: vi.fn(), createCarplaceReservation: vi.fn(),
getReviewContext: vi.fn(), getReviewContext: vi.fn(),
submitReview: 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.checkAvailability).mockResolvedValue({ available: true, nextAvailableAt: null } as never)
vi.mocked(siteService.initPayment).mockResolvedValue({ checkoutUrl: 'https://pay.example.test' } 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(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.createCarplaceReservation).mockResolvedValue({ id: 'reservation_1' } as never)
vi.mocked(carplaceService.submitReview).mockResolvedValue({ id: 'review_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') const res = await request(app).get('/api/v1/carplace/search?city=Rabat&maxPrice=500&page=2&pageSize=30')
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(carplaceService.searchVehicles).toHaveBeenCalledWith({ expect(carplaceService.searchVehiclesPage).toHaveBeenCalledWith({
city: 'Rabat', city: 'Rabat',
maxPrice: 500, maxPrice: 500,
page: 2, page: 2,
pageSize: 30, 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 () => { it('rejects oversized carplace pagination before search execution', async () => {
const res = await request(app).get('/api/v1/carplace/search?pageSize=500') const res = await request(app).get('/api/v1/carplace/search?pageSize=500')
expect(res.status).toBe(400) 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 () => { it('rejects carplace reservations with invalid public contact data before service execution', async () => {
+10 -1
View File
@@ -1,11 +1,13 @@
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { CarplaceApiError, carplaceFetch, carplaceFetchOrDefault, carplacePost } from './api'
afterEach(() => { afterEach(() => {
delete process.env.NEXT_PUBLIC_API_URL delete process.env.NEXT_PUBLIC_API_URL
delete process.env.NEXT_PUBLIC_CARPLACE_DIRECT_API
delete process.env.API_INTERNAL_URL delete process.env.API_INTERNAL_URL
vi.restoreAllMocks() vi.restoreAllMocks()
Reflect.deleteProperty(globalThis, 'window')
Reflect.deleteProperty(globalThis, 'fetch') Reflect.deleteProperty(globalThis, 'fetch')
vi.resetModules()
}) })
describe('carplace API helpers', () => { 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' }] }) })) const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: [{ id: 'vehicle_1' }] }) }))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock }) Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock })
const { carplaceFetch } = await import('./api')
await expect(carplaceFetch('/carplace/vehicles')).resolves.toEqual([{ id: 'vehicle_1' }]) await expect(carplaceFetch('/carplace/vehicles')).resolves.toEqual([{ id: 'vehicle_1' }])
expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/carplace\/vehicles$/), { expect(fetchMock).toHaveBeenCalledWith(expect.stringMatching(/\/api\/v1\/carplace\/vehicles$/), {
cache: 'no-store', cache: 'no-store',
@@ -30,6 +33,7 @@ describe('carplace API helpers', () => {
})), })),
}) })
const { carplaceFetch } = await import('./api')
await expect(carplaceFetch('/carplace/book')).rejects.toMatchObject({ await expect(carplaceFetch('/carplace/book')).rejects.toMatchObject({
name: 'CarplaceApiError', name: 'CarplaceApiError',
message: 'Vehicle unavailable', message: 'Vehicle unavailable',
@@ -45,14 +49,18 @@ describe('carplace API helpers', () => {
value: vi.fn(async () => ({ ok: false, status: 500, json: async () => ({ message: 'Down' }) })), 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 }) await expect(carplaceFetchOrDefault('/carplace/homepage', { hero: null })).resolves.toEqual({ hero: null })
}) })
it('posts JSON payloads and unwraps successful responses', async () => { 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_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' } }) })) const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { id: 'reservation_1' } }) }))
Object.defineProperty(globalThis, 'fetch', { configurable: true, value: fetchMock }) 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' }) 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({ expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/api/v1/site/reservations', expect.objectContaining({
method: 'POST', 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') } })), 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({ await expect(carplaceFetch('/site/homepage')).rejects.toMatchObject({
name: 'CarplaceApiError', name: 'CarplaceApiError',
message: 'Request failed', message: 'Request failed',
+54
View File
@@ -16,6 +16,7 @@ afterEach(() => {
Reflect.deleteProperty(globalThis, 'window') Reflect.deleteProperty(globalThis, 'window')
Reflect.deleteProperty(globalThis, 'fetch') Reflect.deleteProperty(globalThis, 'fetch')
delete process.env.NEXT_PUBLIC_API_URL delete process.env.NEXT_PUBLIC_API_URL
delete process.env.API_INTERNAL_URL
vi.resetModules() vi.resetModules()
}) })
@@ -54,6 +55,59 @@ describe('dashboard apiFetch', () => {
expect(fetchMock).toHaveBeenCalledWith('https://api.rentaldrivego.ma/api/v1/auth/account/start', expect.any(Object)) 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 () => { it('does not force JSON content type for FormData payloads', async () => {
installBrowser() installBrowser()
const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { uploaded: true } }) })) const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: { uploaded: true } }) }))
+12 -9
View File
@@ -1,15 +1,18 @@
function normalizeApiBase(value: string): string { export function normalizeApiBase(value: string): string {
const base = value.replace(/\/+$/, '') const base = value.replace(/\/+$/, '')
if (/\/api$/.test(base)) return `${base}/v1`
return /\/api\/v1$/.test(base) ? base : `${base}/api/v1` return /\/api\/v1$/.test(base) ? base : `${base}/api/v1`
} }
export const API_BASE = normalizeApiBase( export function resolveApiBase(): string {
typeof window === 'undefined' const value = typeof window === 'undefined'
? (process.env.API_INTERNAL_URL || 'http://localhost:4000/api/v1') ? (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' export const EMPLOYEE_PROFILE_KEY = 'employee_profile'
@@ -24,7 +27,7 @@ export async function apiFetch<T>(path: string, options?: RequestInit): Promise<
headers['Content-Type'] = 'application/json' headers['Content-Type'] = 'application/json'
} }
const res = await fetch(`${API_BASE}${path}`, { const res = await fetch(`${resolveApiBase()}${path}`, {
...options, ...options,
headers, headers,
credentials: 'include', credentials: 'include',
@@ -49,7 +52,7 @@ export async function apiFetch<T>(path: string, options?: RequestInit): Promise<
export async function apiFetchServer<T>(path: string, token: string, options?: RequestInit): Promise<T> { export async function apiFetchServer<T>(path: string, token: string, options?: RequestInit): Promise<T> {
const isFormData = typeof FormData !== 'undefined' && options?.body instanceof FormData const isFormData = typeof FormData !== 'undefined' && options?.body instanceof FormData
const res = await fetch(`${API_BASE}${path}`, { const res = await fetch(`${resolveApiBase()}${path}`, {
...options, ...options,
headers: { headers: {
...(isFormData ? {} : { 'Content-Type': 'application/json' }), ...(isFormData ? {} : { 'Content-Type': 'application/json' }),
+2 -2
View File
@@ -38,12 +38,12 @@ export const demoLeadSchema = z
.regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'invalid_email'), .regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'invalid_email'),
), ),
company: compactText(160), company: compactText(160),
fleetSize: z.enum(fleetSizeValues, { error: 'invalid_fleet_size' }), fleetSize: z.enum(fleetSizeValues, { message: 'invalid_fleet_size' }),
market: optionalCompactText(100), market: optionalCompactText(100),
preferredLanguage: z.enum(['en', 'fr', 'ar']).optional(), preferredLanguage: z.enum(['en', 'fr', 'ar']).optional(),
message: optionalCompactText(1_000), message: optionalCompactText(1_000),
idempotencyKey: z.string().uuid('invalid_idempotency_key'), idempotencyKey: z.string().uuid('invalid_idempotency_key'),
source: z.enum(demoSourceValues, { error: 'invalid_source' }), source: z.enum(demoSourceValues, { message: 'invalid_source' }),
}) })
.strict(); .strict();