refactor: rename marketplace to storefront across the entire monorepo
Build & Deploy / Build & Push Docker Image (push) Successful in 1m2s
Test / Type Check (all packages) (push) Failing after 28s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Storefront Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
Build & Deploy / Deploy to VPS (push) Successful in 3s

Comprehensive rename of all marketplace references to storefront:
- API module: apps/api/src/modules/marketplace/ → storefront/
- Components: MarketplaceHeader → StorefrontHeader, MarketplaceShell →
  StorefrontShell, MarketplaceFooter → StorefrontFooter
- Types: marketplace-homepage.ts → storefront-homepage.ts
- Test files: employee-marketplace-* → employee-storefront-*
- All source code identifiers, imports, route paths, and strings
- Documentation (docs/), CI config (.gitlab-ci.yml), scripts
- Dashboard, admin, storefront workspace references
- Prisma field names preserved (isListedOnMarketplace, marketplaceRating,
  marketplaceFunnelEvent) as they map to database schema

Validation:
- API type-check: 0 errors
- Storefront type-check: 0 errors
- Dashboard type-check: 0 errors
- Full monorepo type-check: only pre-existing admin TS18046
This commit is contained in:
root
2026-06-28 01:14:46 -04:00
parent bffda95a7c
commit 8aab968e09
147 changed files with 829 additions and 829 deletions
+4 -4
View File
@@ -28,7 +28,7 @@ import customersRouter from './modules/customers/customer.routes'
import vehiclesRouter from './modules/vehicles/vehicle.routes'
import companiesRouter from './modules/companies/company.routes'
import reservationsRouter from './modules/reservations/reservation.routes'
import marketplaceRouter from './modules/marketplace/marketplace.routes'
import storefrontRouter from './modules/storefront/storefront.routes'
import siteRouter from './modules/site/site.routes'
import reviewsRouter from './modules/reviews/review.routes'
import complaintsRouter from './modules/complaints/complaint.routes'
@@ -95,8 +95,8 @@ const routeDocs = [
{ method: 'GET', path: `${v1}/analytics/dashboard`, description: 'Dashboard analytics' },
{ method: 'GET', path: `${v1}/analytics/report`, description: 'Analytics report' },
{ method: 'GET', path: `${v1}/notifications/company`, description: 'Company notifications' },
{ method: 'GET', path: `${v1}/marketplace/cities`, description: 'Marketplace cities' },
{ method: 'GET', path: `${v1}/marketplace/search`, description: 'Marketplace search' },
{ method: 'GET', path: `${v1}/storefront/cities`, description: 'Storefront cities' },
{ method: 'GET', path: `${v1}/storefront/search`, description: 'Storefront search' },
{ method: 'GET', path: `${v1}/site/:slug/brand`, description: 'Public site brand config' },
{ method: 'GET', path: `${v1}/companies/me`, description: 'Current company profile' },
{ method: 'GET', path: `${v1}/subscriptions/plans`, description: 'Subscription plans' },
@@ -187,7 +187,7 @@ export function createApp() {
app.use(`${v1}/admin/auth`, authLimiter)
app.use(`${v1}/admin`, adminLimiter, adminRouter)
app.use(`${v1}/marketplace`, publicLimiter, marketplaceRouter)
app.use(`${v1}/storefront`, publicLimiter, storefrontRouter)
app.use(`${v1}/site`, publicLimiter, siteRouter)
app.use(`${v1}/subscriptions`, subscriptionPublicRouter)
app.use(`${v1}/subscriptions`, subscriptionWebhookRouter)
+25 -25
View File
@@ -1,5 +1,5 @@
{
"marketplaceHomepage": {
"storefrontHomepage": {
"en": {
"sections": [
"hero",
@@ -13,13 +13,13 @@
"closing"
],
"heroKicker": "RentalDriveGo",
"heroTitle": "Marketplace discovery with a sharper front door.",
"heroBody": "Rental companies run private operations, renters browse a shared marketplace, and every booking ends on the companys own branded checkout.",
"heroTitle": "Storefront discovery with a sharper front door.",
"heroBody": "Rental companies run private operations, renters browse a shared storefront, and every booking ends on the companys own branded checkout.",
"startTrial": "Start free trial",
"exploreVehicles": "Explore vehicles",
"surfaceLabel": "Marketplace surface",
"surfaceLabel": "Storefront surface",
"surfaceTitle": "Designed for two audiences at once.",
"surfaceBody": "Operators need control, renters need confidence. The marketplace should show both without feeling like a template.",
"surfaceBody": "Operators need control, renters need confidence. The storefront should show both without feeling like a template.",
"liveLabel": "Live network",
"trustedFleets": "Trusted fleets",
"brandedFlows": "Branded booking flows",
@@ -29,11 +29,11 @@
"companyBody": "Publish inventory once, decide what appears publicly, and keep contracts, payments, and reporting isolated per company.",
"renterKicker": "Renter experience",
"renterTitle": "Let renters compare quickly, then hand them off to the right company site.",
"renterBody": "The marketplace works as a discovery engine, not a dead-end aggregator. Search here, reserve there, and pay direct.",
"renterBody": "The storefront works as a discovery engine, not a dead-end aggregator. Search here, reserve there, and pay direct.",
"pillars": [
{
"title": "Unified publishing",
"body": "Vehicle photos, pricing, and offers flow from one admin workflow into marketplace discovery and branded booking pages."
"body": "Vehicle photos, pricing, and offers flow from one admin workflow into storefront discovery and branded booking pages."
},
{
"title": "Qualified discovery",
@@ -47,7 +47,7 @@
"metrics": [
{
"value": "01",
"label": "Shared marketplace visibility"
"label": "Shared storefront visibility"
},
{
"value": "02",
@@ -61,7 +61,7 @@
"featureLabel": "What the product covers",
"features": [
"Fleet management with multi-photo uploads",
"Marketplace offers and redirect booking flow",
"Storefront offers and redirect booking flow",
"Branded public booking site per company",
"Customer CRM, analytics, and billing controls"
],
@@ -76,7 +76,7 @@
{
"number": "2",
"title": "Add Your Fleet",
"description": "Upload vehicle photos, set prices, and create offers visible on the marketplace."
"description": "Upload vehicle photos, set prices, and create offers visible on the storefront."
},
{
"number": "3",
@@ -94,7 +94,7 @@
{
"step": "2",
"title": "Publish vehicles and offers",
"body": "Upload photos once and control what appears on the marketplace and branded site."
"body": "Upload photos once and control what appears on the storefront and branded site."
},
{
"step": "3",
@@ -104,8 +104,8 @@
],
"stepLabel": "Step",
"readyKicker": "Ready to launch",
"readyTitle": "The marketplace homepage should explain the product in seconds.",
"readyBody": "Use the marketplace to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust tied to your company.",
"readyTitle": "The storefront homepage should explain the product in seconds.",
"readyBody": "Use the storefront to attract demand, then move renters into a branded experience that keeps pricing, payments, and trust tied to your company.",
"viewPricing": "View pricing",
"createWorkspace": "Create workspace"
},
@@ -122,13 +122,13 @@
"closing"
],
"heroKicker": "RentalDriveGo",
"heroTitle": "Une vitrine marketplace plus claire et plus percutante.",
"heroBody": "Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une marketplace commune et chaque réservation se finalise sur le parcours de paiement aux couleurs de l'entreprise.",
"heroTitle": "Une vitrine storefront plus claire et plus percutante.",
"heroBody": "Les entreprises de location gèrent leurs opérations en privé, les clients parcourent une storefront commune et chaque réservation se finalise sur le parcours de paiement aux couleurs de l'entreprise.",
"startTrial": "Commencer lessai gratuit",
"exploreVehicles": "Explorer les véhicules",
"surfaceLabel": "Vitrine marketplace",
"surfaceLabel": "Vitrine storefront",
"surfaceTitle": "Pensée pour deux audiences à la fois.",
"surfaceBody": "Les opérateurs veulent du contrôle, les clients veulent de la confiance. La marketplace doit montrer les deux sans ressembler à un modèle générique.",
"surfaceBody": "Les opérateurs veulent du contrôle, les clients veulent de la confiance. La storefront doit montrer les deux sans ressembler à un modèle générique.",
"liveLabel": "Réseau actif",
"trustedFleets": "Flottes fiables",
"brandedFlows": "Parcours de marque",
@@ -138,11 +138,11 @@
"companyBody": "Publiez une seule fois, choisissez ce qui apparaît publiquement, et gardez contrats, paiements et rapports isolés par entreprise.",
"renterKicker": "Expérience client",
"renterTitle": "Laissez les clients comparer rapidement, puis redirigez-les vers le bon site dentreprise.",
"renterBody": "La marketplace sert de moteur de découverte, pas dagrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
"renterBody": "La storefront sert de moteur de découverte, pas dagrégateur sans suite. Recherche ici, réservation là-bas, paiement direct.",
"pillars": [
{
"title": "Publication unifiée",
"body": "Les photos, tarifs et offres circulent depuis un seul flux dadministration vers la découverte marketplace et les pages de réservation de marque."
"body": "Les photos, tarifs et offres circulent depuis un seul flux dadministration vers la découverte storefront et les pages de réservation de marque."
},
{
"title": "Découverte qualifiée",
@@ -156,7 +156,7 @@
"metrics": [
{
"value": "01",
"label": "Visibilité marketplace partagée"
"label": "Visibilité storefront partagée"
},
{
"value": "02",
@@ -170,7 +170,7 @@
"featureLabel": "Ce que couvre le produit",
"features": [
"Gestion de flotte avec téléversement de plusieurs photos",
"Offres marketplace et redirection vers la réservation",
"Offres storefront et redirection vers la réservation",
"Site public de réservation par entreprise",
"CRM client, analytics et contrôle de facturation"
],
@@ -185,7 +185,7 @@
{
"number": "2",
"title": "Ajouter votre flotte",
"description": "Téléversez les photos des véhicules, fixez les tarifs et créez des offres visibles sur la marketplace."
"description": "Téléversez les photos des véhicules, fixez les tarifs et créez des offres visibles sur la storefront."
},
{
"number": "3",
@@ -203,7 +203,7 @@
{
"step": "2",
"title": "Publiez véhicules et offres",
"body": "Téléversez une seule fois et contrôlez ce qui apparaît sur la marketplace et le site de marque."
"body": "Téléversez une seule fois et contrôlez ce qui apparaît sur la storefront et le site de marque."
},
{
"step": "3",
@@ -213,8 +213,8 @@
],
"stepLabel": "Étape",
"readyKicker": "Prêt à démarrer",
"readyTitle": "La page daccueil marketplace doit présenter le produit en quelques secondes.",
"readyBody": "Utilisez la marketplace pour capter la demande, puis orientez les clients vers une expérience de marque où les prix, les paiements et la confiance restent attachés à votre entreprise.",
"readyTitle": "La page daccueil storefront doit présenter le produit en quelques secondes.",
"readyBody": "Utilisez la storefront pour capter la demande, puis orientez les clients vers une expérience de marque où les prix, les paiements et la confiance restent attachés à votre entreprise.",
"viewPricing": "Voir les tarifs",
"createWorkspace": "Créer lespace"
},
+3 -3
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
formatDate,
marketplaceReservationEmail,
storefrontReservationEmail,
resetPasswordEmail,
signupEmail,
} from './emailTranslations'
@@ -40,8 +40,8 @@ describe('emailTranslations', () => {
expect(html).toContain('45')
})
it('includes optional contact phone in marketplace reservation HTML when present', () => {
const html = marketplaceReservationEmail.html({
it('includes optional contact phone in storefront reservation HTML when present', () => {
const html = storefrontReservationEmail.html({
firstName: 'Yassine',
vehicleYear: 2024,
vehicleMake: 'Dacia',
+2 -2
View File
@@ -117,9 +117,9 @@ export const resetPasswordEmail = {
}, lang),
}
// ─── Marketplace reservation request ─────────────────────────────────────────
// ─── Storefront reservation request ─────────────────────────────────────────
export const marketplaceReservationEmail = {
export const storefrontReservationEmail = {
subject: (vehicleName: string, lang: Lang) => t({
en: `Reservation Request Received — ${vehicleName}`,
fr: `Demande de réservation reçue — ${vehicleName}`,
+1 -1
View File
@@ -82,7 +82,7 @@ export const apiLimiter = rateLimit({
message: { error: 'too_many_requests', message: 'Rate limit exceeded', statusCode: 429 },
})
// Limiter for public marketplace and site endpoints (no auth)
// Limiter for public storefront and site endpoints (no auth)
export const publicLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
+4 -4
View File
@@ -559,16 +559,16 @@ router.post('/subscriptions/:subscriptionId/cancel', requireAdminAuth, requireAd
// ─── Site config ───────────────────────────────────────────────
router.get('/site-config/marketplace-homepage', requireAdminAuth, async (req, res, next) => {
router.get('/site-config/storefront-homepage', requireAdminAuth, async (req, res, next) => {
try {
ok(res, await service.getMarketplaceHomepage())
ok(res, await service.getStorefrontHomepage())
} catch (err) { next(err) }
})
router.patch('/site-config/marketplace-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
router.patch('/site-config/storefront-homepage', requireAdminAuth, requireAdminRole('SUPPORT'), async (req, res, next) => {
try {
const { homepage } = parseBody(homepageUpdateSchema, req)
ok(res, await service.updateMarketplaceHomepage(homepage, req.admin.id, req.ip))
ok(res, await service.updateStorefrontHomepage(homepage, req.admin.id, req.ip))
} catch (err) { next(err) }
})
+19 -19
View File
@@ -1,5 +1,5 @@
import { z } from 'zod'
import type { MarketplaceHomepageContent, MarketplaceHomepageHowItWorksStep, MarketplaceHomepageMetric, MarketplaceHomepagePillar, MarketplaceHomepageSectionType, MarketplaceHomepageStep, MarketplaceHomepageTestimonial } from '@rentaldrivego/types'
import type { StorefrontHomepageContent, StorefrontHomepageHowItWorksStep, StorefrontHomepageMetric, StorefrontHomepagePillar, StorefrontHomepageSectionType, StorefrontHomepageStep, StorefrontHomepageTestimonial } from '@rentaldrivego/types'
export const loginSchema = z.object({
email: z.string().email().max(255).trim().toLowerCase(),
@@ -175,35 +175,35 @@ export const adminCompanyUpdateSchema = z.object({
}).optional(),
})
const marketplaceMetricSchema: z.ZodType<MarketplaceHomepageMetric> = z.object({ value: z.string().min(1), label: z.string().min(1) })
const marketplacePillarSchema: z.ZodType<MarketplaceHomepagePillar> = z.object({ title: z.string().min(1), body: z.string().min(1) })
const marketplaceStepSchema: z.ZodType<MarketplaceHomepageStep> = z.object({ step: z.string().min(1), title: z.string().min(1), body: z.string().min(1) })
const marketplaceHowItWorksStepSchema: z.ZodType<MarketplaceHomepageHowItWorksStep> = z.object({ number: z.string().min(1), title: z.string().min(1), description: z.string().min(1) })
const marketplaceTestimonialSchema: z.ZodType<MarketplaceHomepageTestimonial> = z.object({ quote: z.string().min(1), author: z.string().min(1), role: z.string().min(1), company: z.string().optional() })
const marketplaceSectionSchema: z.ZodType<MarketplaceHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'howitworks', 'steps', 'testimonials', 'closing'])
const storefrontMetricSchema: z.ZodType<StorefrontHomepageMetric> = z.object({ value: z.string().min(1), label: z.string().min(1) })
const storefrontPillarSchema: z.ZodType<StorefrontHomepagePillar> = z.object({ title: z.string().min(1), body: z.string().min(1) })
const storefrontStepSchema: z.ZodType<StorefrontHomepageStep> = z.object({ step: z.string().min(1), title: z.string().min(1), body: z.string().min(1) })
const storefrontHowItWorksStepSchema: z.ZodType<StorefrontHomepageHowItWorksStep> = z.object({ number: z.string().min(1), title: z.string().min(1), description: z.string().min(1) })
const storefrontTestimonialSchema: z.ZodType<StorefrontHomepageTestimonial> = z.object({ quote: z.string().min(1), author: z.string().min(1), role: z.string().min(1), company: z.string().optional() })
const storefrontSectionSchema: z.ZodType<StorefrontHomepageSectionType> = z.enum(['hero', 'surface', 'pillars', 'audiences', 'features', 'howitworks', 'steps', 'testimonials', 'closing'])
const marketplaceHomepageContentSchema: z.ZodType<MarketplaceHomepageContent> = z.object({
sections: z.array(marketplaceSectionSchema).min(1),
const storefrontHomepageContentSchema: z.ZodType<StorefrontHomepageContent> = z.object({
sections: z.array(storefrontSectionSchema).min(1),
heroKicker: z.string().min(1), heroTitle: z.string().min(1), heroBody: z.string().min(1),
startTrial: z.string().min(1), exploreVehicles: z.string().min(1),
surfaceLabel: z.string().min(1), surfaceTitle: z.string().min(1), surfaceBody: z.string().min(1),
liveLabel: z.string().min(1), trustedFleets: z.string().min(1), brandedFlows: z.string().min(1), multiTenant: z.string().min(1),
companyKicker: z.string().min(1), companyTitle: z.string().min(1), companyBody: z.string().min(1),
renterKicker: z.string().min(1), renterTitle: z.string().min(1), renterBody: z.string().min(1),
pillars: z.array(marketplacePillarSchema).length(3),
metrics: z.array(marketplaceMetricSchema).length(3),
pillars: z.array(storefrontPillarSchema).length(3),
metrics: z.array(storefrontMetricSchema).length(3),
featureLabel: z.string().min(1), features: z.array(z.string().min(1)).min(1),
howitworksKicker: z.string().min(1), howitworksTitle: z.string().min(1), howitworksSteps: z.array(marketplaceHowItWorksStepSchema).min(1),
stepsTitle: z.string().min(1), steps: z.array(marketplaceStepSchema).length(3), stepLabel: z.string().min(1),
testimonialsKicker: z.string().min(1), testimonialsTitle: z.string().min(1), testimonials: z.array(marketplaceTestimonialSchema).min(1),
howitworksKicker: z.string().min(1), howitworksTitle: z.string().min(1), howitworksSteps: z.array(storefrontHowItWorksStepSchema).min(1),
stepsTitle: z.string().min(1), steps: z.array(storefrontStepSchema).length(3), stepLabel: z.string().min(1),
testimonialsKicker: z.string().min(1), testimonialsTitle: z.string().min(1), testimonials: z.array(storefrontTestimonialSchema).min(1),
readyKicker: z.string().min(1), readyTitle: z.string().min(1), readyBody: z.string().min(1),
viewPricing: z.string().min(1), createWorkspace: z.string().min(1),
})
export const marketplaceHomepageConfigSchema = z.object({
en: marketplaceHomepageContentSchema,
fr: marketplaceHomepageContentSchema,
ar: marketplaceHomepageContentSchema,
export const storefrontHomepageConfigSchema = z.object({
en: storefrontHomepageContentSchema,
fr: storefrontHomepageContentSchema,
ar: storefrontHomepageContentSchema,
})
export const idParamSchema = z.object({
@@ -300,7 +300,7 @@ export const billingRefundSchema = z.object({
})
export const homepageUpdateSchema = z.object({
homepage: marketplaceHomepageConfigSchema,
homepage: storefrontHomepageConfigSchema,
})
export const pricingUpdateSchema = z.object({
+6 -6
View File
@@ -3,7 +3,7 @@ import crypto from 'crypto'
import { authenticator } from 'otplib'
import { signActorToken } from '../../security/tokens'
import qrcode from 'qrcode'
import { getMarketplaceHomepageContent, saveMarketplaceHomepageContent } from '../../services/platformContentService'
import { getStorefrontHomepageContent, saveStorefrontHomepageContent } from '../../services/platformContentService'
import { sendTransactionalEmail } from '../../services/notificationService'
import * as presenter from './admin.presenter'
import * as repo from './admin.repo'
@@ -402,16 +402,16 @@ export function issueBillingRefund(
return billingService.issueRefund(invoiceId, data, adminId, ip)
}
export function getMarketplaceHomepage() {
return getMarketplaceHomepageContent()
export function getStorefrontHomepage() {
return getStorefrontHomepageContent()
}
export async function updateMarketplaceHomepage(homepage: any, adminId: string, ip?: string) {
const saved = await saveMarketplaceHomepageContent(homepage)
export async function updateStorefrontHomepage(homepage: any, adminId: string, ip?: string) {
const saved = await saveStorefrontHomepageContent(homepage)
await repo.createAuditLog({
adminUserId: adminId,
action: 'UPDATE',
resource: 'MarketplaceHomepage',
resource: 'StorefrontHomepage',
after: toAuditJson(saved),
ipAddress: ip,
userAgent: undefined,
@@ -10,7 +10,7 @@ vi.mock('../../lib/prisma', () => ({
planFeature: { findMany: vi.fn() },
},
}))
vi.mock('../../services/platformContentService', () => ({ getMarketplaceHomepageContent: vi.fn() }))
vi.mock('../../services/platformContentService', () => ({ getStorefrontHomepageContent: vi.fn() }))
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
vi.mock('../../services/insuranceService', () => ({ applyInsurancesToReservation: vi.fn() }))
vi.mock('../../services/additionalDriverService', () => ({ applyAdditionalDriversToReservation: vi.fn() }))
+2 -2
View File
@@ -3,7 +3,7 @@ import { AppError, NotFoundError } from '../../http/errors'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { applyPricingRules } from '../../services/pricingRuleService'
import { validateLicense } from '../../services/licenseValidationService'
import { getMarketplaceHomepageContent } from '../../services/platformContentService'
import { getStorefrontHomepageContent } from '../../services/platformContentService'
import { prisma } from '../../lib/prisma'
import * as amanpay from '../../services/amanpayService'
import * as paypal from '../../services/paypalService'
@@ -53,7 +53,7 @@ function generateBookingReference() {
}
export async function getPlatformHomepage() {
return getMarketplaceHomepageContent()
return getStorefrontHomepageContent()
}
export async function getPlatformPricing() {
+1 -1
View File
@@ -66,7 +66,7 @@ vi.mock('../../services/paypalService', () => ({
}))
vi.mock('../../services/platformContentService', () => ({
getMarketplaceHomepageContent: vi.fn(),
getStorefrontHomepageContent: vi.fn(),
}))
import * as repo from './site.repo'
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { presentVehicleWithAvailability } from './marketplace.presenter'
import { presentVehicleWithAvailability } from './storefront.presenter'
describe('marketplace.presenter', () => {
describe('storefront.presenter', () => {
it('adds public availability fields without mutating the original vehicle shape', () => {
const nextAvailableAt = new Date('2026-08-01T10:00:00.000Z')
const vehicle = { id: 'vehicle_1', make: 'Dacia', model: 'Logan', dailyRate: 25000 }
@@ -11,9 +11,9 @@ const prismaMock = vi.hoisted(() => ({
vi.mock('../../lib/prisma', () => ({ prisma: prismaMock }))
import * as repo from './marketplace.repo'
import * as repo from './storefront.repo'
describe('marketplace.repo public query and write boundaries', () => {
describe('storefront.repo public query and write boundaries', () => {
it('finds public offers using active/public/current-window filters and featured ordering', async () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-09T10:00:00.000Z'))
@@ -33,7 +33,7 @@ describe('marketplace.repo public query and write boundaries', () => {
vi.useRealTimers()
})
it('lists marketplace cities only from active listed companies with a public city', async () => {
it('lists storefront cities only from active listed companies with a public city', async () => {
await repo.findCitiesFromCompanies()
expect(prismaMock.company.findMany).toHaveBeenCalledWith({
@@ -45,8 +45,8 @@ describe('marketplace.repo public query and write boundaries', () => {
})
})
it('requires published vehicles and an active company when loading marketplace vehicle details', async () => {
await repo.findVehicleForMarketplace('vehicle_1', 'atlas')
it('requires published vehicles and an active company when loading storefront vehicle details', async () => {
await repo.findVehicleForStorefront('vehicle_1', 'atlas')
expect(prismaMock.vehicle.findFirst).toHaveBeenCalledWith({
where: {
@@ -58,7 +58,7 @@ describe('marketplace.repo public query and write boundaries', () => {
})
})
it('patches marketplace customer address fields without deleting existing address metadata', async () => {
it('patches storefront customer address fields without deleting existing address metadata', async () => {
prismaMock.customer.findUnique.mockResolvedValueOnce({
id: 'customer_1',
address: { fullAddress: 'Old address', loyaltyNote: 'keep', internationalLicenseNumber: 'INT-1' },
@@ -86,11 +86,11 @@ describe('marketplace.repo public query and write boundaries', () => {
})
})
it('creates marketplace reservations as draft marketplace-sourced records', async () => {
it('creates storefront reservations as draft storefront-sourced records', async () => {
const startDate = new Date('2026-07-01T10:00:00.000Z')
const endDate = new Date('2026-07-05T10:00:00.000Z')
await repo.createMarketplaceReservation({
await repo.createStorefrontReservation({
companyId: 'company_1',
vehicleId: 'vehicle_1',
customerId: 'customer_1',
@@ -41,14 +41,14 @@ export async function findPublishedVehicles(where: any) {
})
}
export async function findVehicleForMarketplace(vehicleId: string, companySlug: string) {
export async function findVehicleForStorefront(vehicleId: string, companySlug: string) {
return prisma.vehicle.findFirst({
where: { id: vehicleId, isPublished: true, company: { slug: companySlug, status: { in: ['ACTIVE', 'TRIALING'] } } },
include: { company: { include: { brand: true } } },
})
}
export async function findVehicleForMarketplaceById(vehicleId: string) {
export async function findVehicleForStorefrontById(vehicleId: string) {
return prisma.vehicle.findFirst({
where: {
id: vehicleId,
@@ -115,7 +115,7 @@ export async function upsertMarketplaceCustomer(companyId: string, data: {
return prisma.customer.update({ where: { id: existing.id }, data: payload })
}
export async function createMarketplaceReservation(data: {
export async function createStorefrontReservation(data: {
companyId: string; vehicleId: string; customerId: string
startDate: Date; endDate: Date; pickupLocation?: string | null; returnLocation?: string | null
dailyRate: number; totalDays: number; totalAmount: number; notes?: string; bookingReference?: string
@@ -125,7 +125,7 @@ export async function createMarketplaceReservation(data: {
})
}
export async function createMarketplaceFunnelEvent(data: {
export async function createStorefrontFunnelEvent(data: {
eventName: string
companySlug: string
vehicleId: string
@@ -3,12 +3,12 @@ import { optionalRenterAuth } from '../../middleware/requireRenterAuth'
import { parseBody, parseParams, parseQuery } from '../../http/validate'
import { ok, created } from '../../http/respond'
import { isDatabaseUnavailableError } from '../../lib/isDatabaseUnavailable'
import * as service from './marketplace.service'
import * as service from './storefront.service'
import {
paginationSchema, searchSchema, companiesQuerySchema, marketplaceReservationSchema,
paginationSchema, searchSchema, companiesQuerySchema, storefrontReservationSchema,
reviewBodySchema, slugParamSchema, vehicleParamSchema, reviewTokenSchema, offerCodeParamSchema,
vehicleAvailabilityQuerySchema, marketplaceFunnelEventSchema,
} from './marketplace.schemas'
} from './storefront.schemas'
const router = Router()
router.use(optionalRenterAuth)
@@ -55,8 +55,8 @@ router.get('/search', async (req, res, next) => {
router.post('/reservations', async (req, res, next) => {
try {
const body = parseBody(marketplaceReservationSchema, req)
const result = await service.createMarketplaceReservation(body)
const body = parseBody(storefrontReservationSchema, req)
const result = await service.createStorefrontReservation(body)
created(res, result)
} catch (err) {
if (isDatabaseUnavailableError(err)) {
@@ -69,7 +69,7 @@ router.post('/reservations', async (req, res, next) => {
router.post('/events', async (req, res, next) => {
try {
const body = parseBody(marketplaceFunnelEventSchema, req)
ok(res, await service.trackMarketplaceFunnelEvent(body, req.renterId))
ok(res, await service.trackStorefrontFunnelEvent(body, req.renterId))
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Analytics events are temporarily unavailable', statusCode: 503 })
@@ -112,7 +112,7 @@ router.get('/:slug', async (req, res, next) => {
ok(res, await service.getCompanyPage(slug))
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Marketplace data is temporarily unavailable', statusCode: 503 })
return res.status(503).json({ error: 'database_unavailable', message: 'Storefront data is temporarily unavailable', statusCode: 503 })
}
next(err)
}
@@ -154,7 +154,7 @@ router.get('/:slug/vehicles/:id/availability', async (req, res, next) => {
try {
const { slug, id } = parseParams(vehicleParamSchema, req)
const { startDate, endDate } = parseQuery(vehicleAvailabilityQuerySchema, req)
ok(res, await service.getMarketplaceVehicleAvailability(slug, id, { startDate, endDate }))
ok(res, await service.getStorefrontVehicleAvailability(slug, id, { startDate, endDate }))
} catch (err) {
if (isDatabaseUnavailableError(err)) {
return res.status(503).json({ error: 'database_unavailable', message: 'Availability checks are temporarily unavailable', statusCode: 503 })
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { marketplaceReservationSchema, paginationSchema, reviewBodySchema, searchSchema } from './marketplace.schemas'
import { storefrontReservationSchema, paginationSchema, reviewBodySchema, searchSchema } from './storefront.schemas'
describe('marketplace.schemas', () => {
describe('storefront.schemas', () => {
it('coerces pagination and caps untrusted public page sizes', () => {
expect(paginationSchema.parse({ page: '2', pageSize: '40' })).toEqual({ page: 2, pageSize: 40 })
expect(paginationSchema.parse({})).toEqual({ page: 1, pageSize: 20 })
@@ -18,8 +18,8 @@ describe('marketplace.schemas', () => {
expect(() => searchSchema.parse({ maxPrice: '-1' })).toThrow()
})
it('defaults marketplace reservation language while keeping date and email validation strict', () => {
const parsed = marketplaceReservationSchema.parse({
it('defaults storefront reservation language while keeping date and email validation strict', () => {
const parsed = storefrontReservationSchema.parse({
vehicleId: 'ckvvehicle000000000000001',
companySlug: 'atlas-cars',
firstName: 'Aya',
@@ -30,8 +30,8 @@ describe('marketplace.schemas', () => {
})
expect(parsed.language).toBe('fr')
expect(() => marketplaceReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow()
expect(() => marketplaceReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow()
expect(() => storefrontReservationSchema.parse({ ...parsed, email: 'bad-email' })).toThrow()
expect(() => storefrontReservationSchema.parse({ ...parsed, startDate: '2026-07-01' })).toThrow()
})
it('validates review ratings as bounded integers', () => {
@@ -29,7 +29,7 @@ export const vehicleAvailabilityQuerySchema = z.object({
endDate: z.string().datetime(),
})
export const marketplaceReservationSchema = z.object({
export const storefrontReservationSchema = z.object({
vehicleId: z.string().cuid(),
companySlug: z.string().trim().max(100).optional(),
firstName: z.string().min(1).max(100),
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('./marketplace.repo', () => ({
vi.mock('./storefront.repo', () => ({
findCompanyPage: vi.fn(),
findCompanyBySlug: vi.fn(),
findCompanyReviews: vi.fn(),
@@ -12,16 +12,16 @@ vi.mock('./marketplace.repo', () => ({
vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn() }))
vi.mock('../../services/notificationService', () => ({ sendNotification: vi.fn(), sendTransactionalEmail: vi.fn() }))
vi.mock('../../lib/emailTranslations', () => ({
marketplaceReservationEmail: { subject: vi.fn(), html: vi.fn(), text: vi.fn() },
storefrontReservationEmail: { subject: vi.fn(), html: vi.fn(), text: vi.fn() },
}))
import * as repo from './marketplace.repo'
import * as repo from './storefront.repo'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { getCompanyOffers, getCompanyPage, getCompanyReviews, getCompanyVehicles, getVehicleDetail, validateOfferCode } from './marketplace.service'
import { getCompanyOffers, getCompanyPage, getCompanyReviews, getCompanyVehicles, getVehicleDetail, validateOfferCode } from './storefront.service'
beforeEach(() => vi.clearAllMocks())
describe('marketplace.service public page edges', () => {
describe('storefront.service public page edges', () => {
it('enriches company page vehicles with availability without changing company metadata', async () => {
const nextAvailableAt = new Date('2026-07-14T09:00:00.000Z')
vi.mocked(repo.findCompanyPage).mockResolvedValue({
@@ -1,8 +1,8 @@
import { AppError, NotFoundError, ConflictError } from '../../http/errors'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import { sendNotification, sendTransactionalEmail } from '../../services/notificationService'
import { marketplaceReservationEmail, type Lang } from '../../lib/emailTranslations'
import * as repo from './marketplace.repo'
import { storefrontReservationEmail, type Lang } from '../../lib/emailTranslations'
import * as repo from './storefront.repo'
function normalizeLocation(value?: string | null) {
const normalized = value?.trim()
@@ -120,12 +120,12 @@ export async function searchVehicles(params: {
}))
}
export async function getMarketplaceVehicleAvailability(
export async function getStorefrontVehicleAvailability(
slug: string,
vehicleId: string,
params: { startDate: string; endDate: string },
) {
const vehicle = await repo.findVehicleForMarketplace(vehicleId, slug)
const vehicle = await repo.findVehicleForStorefront(vehicleId, slug)
if (!vehicle) throw new NotFoundError('Vehicle not found')
const startDate = new Date(params.startDate)
@@ -154,7 +154,7 @@ function generateBookingReference() {
return `BK-${year}-${random}`
}
export async function createMarketplaceReservation(body: {
export async function createStorefrontReservation(body: {
vehicleId: string; companySlug?: string; firstName: string; lastName: string
email: string; phone?: string; dateOfBirth?: string; nationality?: string
identityDocumentNumber?: string; fullAddress?: string
@@ -170,7 +170,7 @@ export async function createMarketplaceReservation(body: {
throw new AppError('End date must be after start date', 400, 'invalid_dates')
}
const vehicle = await repo.findVehicleForMarketplaceById(body.vehicleId)
const vehicle = await repo.findVehicleForStorefrontById(body.vehicleId)
if (!vehicle) throw new NotFoundError('Vehicle not found')
const pickupLocation = normalizeLocation(body.pickupLocation)
@@ -218,7 +218,7 @@ export async function createMarketplaceReservation(body: {
const totalDays = Math.max(1, Math.ceil((endDate.getTime() - startDate.getTime()) / 86_400_000))
const totalAmount = vehicle.dailyRate * totalDays
const reservation = await repo.createMarketplaceReservation({
const reservation = await repo.createStorefrontReservation({
companyId: vehicle.companyId,
vehicleId: body.vehicleId,
customerId: customer.id,
@@ -245,14 +245,14 @@ export async function createMarketplaceReservation(body: {
sendTransactionalEmail({
to: body.email,
subject: marketplaceReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
html: marketplaceReservationEmail.html(emailOpts, lang),
text: marketplaceReservationEmail.text(emailOpts, lang),
subject: storefrontReservationEmail.subject(`${vehicle.make} ${vehicle.model}`, lang),
html: storefrontReservationEmail.html(emailOpts, lang),
text: storefrontReservationEmail.text(emailOpts, lang),
}).catch(() => null)
sendNotification({
type: 'NEW_BOOKING',
title: 'New Marketplace Reservation Request',
title: 'New Storefront Reservation Request',
body: `${body.firstName} ${body.lastName} requested ${vehicle.make} ${vehicle.model} from ${startDate.toISOString().slice(0, 10)} to ${endDate.toISOString().slice(0, 10)}.`,
companyId: vehicle.companyId,
channels: ['IN_APP'],
@@ -270,7 +270,7 @@ export async function createMarketplaceReservation(body: {
}
}
export async function trackMarketplaceFunnelEvent(body: {
export async function trackStorefrontFunnelEvent(body: {
eventName: string
companySlug: string
vehicleId: string
@@ -278,7 +278,7 @@ export async function trackMarketplaceFunnelEvent(body: {
path?: string
metadata?: Record<string, string | number | boolean | null>
}, renterId?: string) {
await repo.createMarketplaceFunnelEvent({
await repo.createStorefrontFunnelEvent({
...body,
renterId: renterId ?? null,
})
@@ -1,15 +1,15 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NotFoundError, ConflictError, AppError } from '../../http/errors'
vi.mock('./marketplace.repo', () => ({
vi.mock('./storefront.repo', () => ({
findPublicOffers: vi.fn(),
findCitiesFromCompanies: vi.fn(),
findListedCompanies: vi.fn(),
findPublishedVehicles: vi.fn(),
findVehicleForMarketplace: vi.fn(),
findVehicleForMarketplaceById: vi.fn(),
findVehicleForStorefront: vi.fn(),
findVehicleForStorefrontById: vi.fn(),
upsertMarketplaceCustomer: vi.fn(),
createMarketplaceReservation: vi.fn(),
createStorefrontReservation: vi.fn(),
findCompanyPage: vi.fn(),
findCompanyBySlug: vi.fn(),
findCompanyReviews: vi.fn(),
@@ -33,19 +33,19 @@ vi.mock('../../services/notificationService', () => ({
}))
vi.mock('../../lib/emailTranslations', () => ({
marketplaceReservationEmail: {
storefrontReservationEmail: {
subject: vi.fn(() => 'Subject'),
html: vi.fn(() => '<html>'),
text: vi.fn(() => 'text'),
},
}))
import * as repo from './marketplace.repo'
import * as repo from './storefront.repo'
import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService'
import {
getCities, searchVehicles, createMarketplaceReservation,
getCities, searchVehicles, createStorefrontReservation,
getReviewContext, submitReview, validateOfferCode,
} from './marketplace.service'
} from './storefront.service'
const SLUG = 'test-company'
@@ -119,7 +119,7 @@ describe('searchVehicles', () => {
})
// ────────────────────────────────────────────────────────────────────────────
describe('createMarketplaceReservation', () => {
describe('createStorefrontReservation', () => {
const baseBody = {
vehicleId: 'v-1', companySlug: SLUG,
firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com',
@@ -137,12 +137,12 @@ describe('createMarketplaceReservation', () => {
}
it('creates reservation and returns reservationId', async () => {
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(makeVehicle() as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null })
vi.mocked(repo.upsertMarketplaceCustomer).mockResolvedValue({ id: 'c-1' } as any)
vi.mocked(repo.createMarketplaceReservation).mockResolvedValue({ id: 'r-1', bookingReference: 'BK-2026-ABC123' } as any)
vi.mocked(repo.createStorefrontReservation).mockResolvedValue({ id: 'r-1', bookingReference: 'BK-2026-ABC123' } as any)
const result = await createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
const result = await createStorefrontReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' })
expect(result.reservationId).toBe('r-1')
expect(result.bookingReference).toBe('BK-2026-ABC123')
expect(repo.upsertMarketplaceCustomer).toHaveBeenCalledWith('co-1', expect.objectContaining({
@@ -150,36 +150,36 @@ describe('createMarketplaceReservation', () => {
fullAddress: '123 Avenue Hassan II, Casablanca',
driverLicense: 'DL-123456',
}))
expect(repo.createMarketplaceReservation).toHaveBeenCalledWith(expect.objectContaining({
expect(repo.createStorefrontReservation).toHaveBeenCalledWith(expect.objectContaining({
pickupLocation: 'Casablanca',
returnLocation: 'Casablanca',
}))
})
it('throws NotFoundError when vehicle not found', async () => {
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(null)
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(null)
await expect(createMarketplaceReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError)
await expect(createStorefrontReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError)
})
it('throws AppError when vehicle is unavailable', async () => {
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(makeVehicle() as any)
vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' })
await expect(createMarketplaceReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
await expect(createStorefrontReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' })
})
it('rejects different return locations when the vehicle requires same drop-off', async () => {
vi.mocked(repo.findVehicleForMarketplaceById).mockResolvedValue(makeVehicle() as any)
vi.mocked(repo.findVehicleForStorefrontById).mockResolvedValue(makeVehicle() as any)
await expect(
createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
createStorefrontReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Rabat' }),
).rejects.toMatchObject({ error: 'same_dropoff_required' })
})
it('throws AppError for invalid date range', async () => {
await expect(
createMarketplaceReservation({ ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
createStorefrontReservation({ ...baseBody, startDate: '2025-06-04T00:00:00.000Z', endDate: '2025-06-01T00:00:00.000Z' }),
).rejects.toMatchObject({ error: 'invalid_dates' })
})
})
@@ -27,15 +27,15 @@ describe('platformContentService', () => {
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()
const first = await service.getStorefrontHomepageContent()
first.en.heroTitle = 'Mutated by test'
const second = await service.getMarketplaceHomepageContent()
const second = await service.getStorefrontHomepageContent()
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 () => {
it('saves only the storefront 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 }))
@@ -43,13 +43,13 @@ describe('platformContentService', () => {
})
const service = await importFreshService()
const homepage = await service.getMarketplaceHomepageContent()
homepage.en.heroTitle = 'A better marketplace title'
const homepage = await service.getStorefrontHomepageContent()
homepage.en.heroTitle = 'A better storefront title'
await expect(service.saveMarketplaceHomepageContent(homepage)).resolves.toBe(homepage)
await expect(service.saveStorefrontHomepageContent(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')
expect(raw.storefrontHomepage.en.heroTitle).toBe('A better storefront title')
})
})
@@ -1,6 +1,6 @@
import { mkdir, readFile, writeFile } from 'fs/promises'
import path from 'path'
import { cloneMarketplaceHomepageContent, type MarketplaceHomepageConfig } from '@rentaldrivego/types'
import { cloneStorefrontHomepageContent, type StorefrontHomepageConfig } from '@rentaldrivego/types'
function resolveContentPath() {
const cwd = process.cwd()
@@ -12,7 +12,7 @@ function resolveContentPath() {
const platformContentPath = resolveContentPath()
type PlatformContentFile = {
marketplaceHomepage?: MarketplaceHomepageConfig
storefrontHomepage?: StorefrontHomepageConfig
}
async function readPlatformContentFile(): Promise<PlatformContentFile> {
@@ -29,16 +29,16 @@ async function writePlatformContentFile(content: PlatformContentFile) {
await writeFile(platformContentPath, JSON.stringify(content, null, 2))
}
export async function getMarketplaceHomepageContent() {
export async function getStorefrontHomepageContent() {
const content = await readPlatformContentFile()
return content.marketplaceHomepage ?? cloneMarketplaceHomepageContent()
return content.storefrontHomepage ?? cloneStorefrontHomepageContent()
}
export async function saveMarketplaceHomepageContent(homepage: MarketplaceHomepageConfig) {
export async function saveStorefrontHomepageContent(homepage: StorefrontHomepageConfig) {
const content = await readPlatformContentFile()
await writePlatformContentFile({
...content,
marketplaceHomepage: homepage,
storefrontHomepage: homepage,
})
return homepage
}
@@ -16,7 +16,7 @@ describe('OpenAPI document boundary contract', () => {
}),
}))
expect(openApiDocument.tags).toEqual(expect.arrayContaining([
expect.objectContaining({ name: 'Marketplace' }),
expect.objectContaining({ name: 'Storefront' }),
expect.objectContaining({ name: 'Subscriptions' }),
expect.objectContaining({ name: 'Admin' }),
]))
+26 -26
View File
@@ -119,7 +119,7 @@ export const openApiDocument: JsonObject = {
{ name: 'Companies', description: 'Company profile & settings' },
{ name: 'Team', description: 'Employee management' },
{ name: 'Subscriptions', description: 'Plan management & billing' },
{ name: 'Marketplace', description: 'Public booking portal' },
{ name: 'Storefront', description: 'Public booking portal' },
{ name: 'Site', description: 'White-label site API' },
{ name: 'Admin', description: 'Platform administration' },
],
@@ -998,18 +998,18 @@ export const openApiDocument: JsonObject = {
// ════════════════════════════════════════════════════════════════
// MARKETPLACE (public — no auth required)
// ════════════════════════════════════════════════════════════════
'/marketplace/cities': {
get: { tags: ['Marketplace'], summary: 'Cities with active companies', security: [], responses: { '200': ok } },
'/storefront/cities': {
get: { tags: ['Storefront'], summary: 'Cities with active companies', security: [], responses: { '200': ok } },
},
'/marketplace/companies': {
get: { tags: ['Marketplace'], summary: 'List companies', security: [], responses: { '200': ok } },
'/storefront/companies': {
get: { tags: ['Storefront'], summary: 'List companies', security: [], responses: { '200': ok } },
},
'/marketplace/offers': {
get: { tags: ['Marketplace'], summary: 'Public offers', security: [], responses: { '200': ok } },
'/storefront/offers': {
get: { tags: ['Storefront'], summary: 'Public offers', security: [], responses: { '200': ok } },
},
'/marketplace/search': {
'/storefront/search': {
get: {
tags: ['Marketplace'],
tags: ['Storefront'],
summary: 'Search available vehicles',
security: [],
parameters: [
@@ -1021,30 +1021,30 @@ export const openApiDocument: JsonObject = {
responses: { '200': ok },
},
},
'/marketplace/reservations': {
post: { tags: ['Marketplace'], summary: 'Create marketplace booking', security: [], responses: { '201': ok } },
'/storefront/reservations': {
post: { tags: ['Storefront'], summary: 'Create storefront booking', security: [], responses: { '201': ok } },
},
'/marketplace/offers/{code}/validate': {
post: { tags: ['Marketplace'], summary: 'Validate promo code', security: [], parameters: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
'/storefront/offers/{code}/validate': {
post: { tags: ['Storefront'], summary: 'Validate promo code', security: [], parameters: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
},
'/marketplace/review/{token}': {
get: { tags: ['Marketplace'], summary: 'Get review form by token', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
post: { tags: ['Marketplace'], summary: 'Submit review', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
'/storefront/review/{token}': {
get: { tags: ['Storefront'], summary: 'Get review form by token', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
post: { tags: ['Storefront'], summary: 'Submit review', security: [], parameters: [{ name: 'token', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
},
'/marketplace/{slug}': {
get: { tags: ['Marketplace'], summary: 'Company public page', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
'/storefront/{slug}': {
get: { tags: ['Storefront'], summary: 'Company public page', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
},
'/marketplace/{slug}/reviews': {
get: { tags: ['Marketplace'], summary: 'Company reviews', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
'/storefront/{slug}/reviews': {
get: { tags: ['Storefront'], summary: 'Company reviews', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
},
'/marketplace/{slug}/vehicles': {
get: { tags: ['Marketplace'], summary: 'Company vehicles', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
'/storefront/{slug}/vehicles': {
get: { tags: ['Storefront'], summary: 'Company vehicles', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
},
'/marketplace/{slug}/vehicles/{id}': {
get: { tags: ['Marketplace'], summary: 'Vehicle details', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }, idPath()], responses: { '200': ok } },
'/storefront/{slug}/vehicles/{id}': {
get: { tags: ['Storefront'], summary: 'Vehicle details', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }, idPath()], responses: { '200': ok } },
},
'/marketplace/{slug}/offers': {
get: { tags: ['Marketplace'], summary: 'Company offers', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
'/storefront/{slug}/offers': {
get: { tags: ['Storefront'], summary: 'Company offers', security: [], parameters: [{ name: 'slug', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': ok } },
},
// ════════════════════════════════════════════════════════════════
@@ -40,12 +40,12 @@ vi.mock('../../modules/notifications/notification.service', () => ({
getRenterPreferences: vi.fn(),
setRenterPreferences: vi.fn(),
}))
vi.mock('../../modules/marketplace/marketplace.service', () => ({
vi.mock('../../modules/storefront/storefront.service', () => ({
getCities: vi.fn(),
getListedCompanies: vi.fn(),
searchVehicles: vi.fn(),
getVehicleDetail: vi.fn(),
createMarketplaceReservation: vi.fn(),
createStorefrontReservation: vi.fn(),
getCompanyPage: vi.fn(),
getCompanyReviews: vi.fn(),
getCompanyVehicles: vi.fn(),
@@ -61,11 +61,11 @@ import request from 'supertest'
import { createApp } from '../../app'
import * as employeeService from '../../modules/auth/auth.employee.service'
import * as notificationService from '../../modules/notifications/notification.service'
import * as marketplaceService from '../../modules/marketplace/marketplace.service'
import * as storefrontService from '../../modules/storefront/storefront.service'
const app = createApp()
describe('employee, notification, and marketplace API validation contracts', () => {
describe('employee, notification, and storefront API validation contracts', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(employeeService.login).mockResolvedValue({ token: 'jwt', employee: { id: 'employee_1' } } as never)
@@ -73,8 +73,8 @@ describe('employee, notification, and marketplace 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(marketplaceService.searchVehicles).mockResolvedValue({ companies: [], vehicles: [], pagination: { page: 1, pageSize: 20, total: 0 } } as never)
vi.mocked(marketplaceService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
vi.mocked(storefrontService.searchVehicles).mockResolvedValue({ companies: [], vehicles: [], pagination: { page: 1, pageSize: 20, total: 0 } } as never)
vi.mocked(storefrontService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
})
it('normalizes employee login email before service execution', async () => {
@@ -110,11 +110,11 @@ describe('employee, notification, and marketplace API validation contracts', ()
expect(notificationService.setPreferences).not.toHaveBeenCalled()
})
it('defaults marketplace search pagination at the public route boundary', async () => {
const res = await request(app).get('/api/v1/marketplace/search?city=Casablanca')
it('defaults storefront search pagination at the public route boundary', async () => {
const res = await request(app).get('/api/v1/storefront/search?city=Casablanca')
expect(res.status).toBe(200)
expect(marketplaceService.searchVehicles).toHaveBeenCalledWith(expect.objectContaining({
expect(storefrontService.searchVehicles).toHaveBeenCalledWith(expect.objectContaining({
city: 'Casablanca',
page: 1,
pageSize: 20,
@@ -122,7 +122,7 @@ describe('employee, notification, and marketplace API validation contracts', ()
})
it('rejects out-of-range public review ratings before service execution', async () => {
const res = await request(app).post('/api/v1/marketplace/review/token_123').send({
const res = await request(app).post('/api/v1/storefront/review/token_123').send({
overallRating: 6,
vehicleRating: 5,
serviceRating: 5,
@@ -130,6 +130,6 @@ describe('employee, notification, and marketplace API validation contracts', ()
})
expect(res.status).toBe(400)
expect(marketplaceService.submitReview).not.toHaveBeenCalled()
expect(storefrontService.submitReview).not.toHaveBeenCalled()
})
})
@@ -30,12 +30,12 @@ vi.mock('../../modules/site/site.service', () => ({
handleContact: vi.fn(),
}))
vi.mock('../../modules/marketplace/marketplace.service', () => ({
vi.mock('../../modules/storefront/storefront.service', () => ({
getPublicOffers: vi.fn(),
getCities: vi.fn(),
getListedCompanies: vi.fn(),
searchVehicles: vi.fn(),
createMarketplaceReservation: vi.fn(),
createStorefrontReservation: vi.fn(),
getReviewContext: vi.fn(),
submitReview: vi.fn(),
validateOfferCode: vi.fn(),
@@ -49,7 +49,7 @@ vi.mock('../../modules/marketplace/marketplace.service', () => ({
import request from 'supertest'
import { createApp } from '../../app'
import * as siteService from '../../modules/site/site.service'
import * as marketplaceService from '../../modules/marketplace/marketplace.service'
import * as storefrontService from '../../modules/storefront/storefront.service'
const app = createApp()
@@ -59,9 +59,9 @@ 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(marketplaceService.searchVehicles).mockResolvedValue({ data: [], pagination: { page: 1, pageSize: 20 } } as never)
vi.mocked(marketplaceService.createMarketplaceReservation).mockResolvedValue({ id: 'reservation_1' } as never)
vi.mocked(marketplaceService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
vi.mocked(storefrontService.searchVehicles).mockResolvedValue({ data: [], pagination: { page: 1, pageSize: 20 } } as never)
vi.mocked(storefrontService.createStorefrontReservation).mockResolvedValue({ id: 'reservation_1' } as never)
vi.mocked(storefrontService.submitReview).mockResolvedValue({ id: 'review_1' } as never)
})
it('rejects malformed public availability checks before site service execution', async () => {
@@ -120,11 +120,11 @@ describe('public validation API contracts', () => {
})
})
it('coerces marketplace search pagination and price filters', async () => {
const res = await request(app).get('/api/v1/marketplace/search?city=Rabat&maxPrice=500&page=2&pageSize=30')
it('coerces storefront search pagination and price filters', async () => {
const res = await request(app).get('/api/v1/storefront/search?city=Rabat&maxPrice=500&page=2&pageSize=30')
expect(res.status).toBe(200)
expect(marketplaceService.searchVehicles).toHaveBeenCalledWith({
expect(storefrontService.searchVehicles).toHaveBeenCalledWith({
city: 'Rabat',
maxPrice: 500,
page: 2,
@@ -132,15 +132,15 @@ describe('public validation API contracts', () => {
})
})
it('rejects oversized marketplace pagination before search execution', async () => {
const res = await request(app).get('/api/v1/marketplace/search?pageSize=500')
it('rejects oversized storefront pagination before search execution', async () => {
const res = await request(app).get('/api/v1/storefront/search?pageSize=500')
expect(res.status).toBe(400)
expect(marketplaceService.searchVehicles).not.toHaveBeenCalled()
expect(storefrontService.searchVehicles).not.toHaveBeenCalled()
})
it('rejects marketplace reservations with invalid public contact data before service execution', async () => {
const res = await request(app).post('/api/v1/marketplace/reservations').send({
it('rejects storefront reservations with invalid public contact data before service execution', async () => {
const res = await request(app).post('/api/v1/storefront/reservations').send({
vehicleId: 'ckvvehicle000000000000001',
companySlug: 'atlas-cars',
firstName: 'Aya',
@@ -151,13 +151,13 @@ describe('public validation API contracts', () => {
})
expect(res.status).toBe(400)
expect(marketplaceService.createMarketplaceReservation).not.toHaveBeenCalled()
expect(storefrontService.createStorefrontReservation).not.toHaveBeenCalled()
})
it('rejects invalid public review ratings before creating reviews', async () => {
const res = await request(app).post('/api/v1/marketplace/review/token_1').send({ overallRating: 6 })
const res = await request(app).post('/api/v1/storefront/review/token_1').send({ overallRating: 6 })
expect(res.status).toBe(400)
expect(marketplaceService.submitReview).not.toHaveBeenCalled()
expect(storefrontService.submitReview).not.toHaveBeenCalled()
})
})
@@ -10,7 +10,7 @@ import { createApp } from '../../app'
const app = createApp()
describe('employee and marketplace public boundary smoke', () => {
describe('employee and storefront public boundary smoke', () => {
it('rejects malformed employee login payloads without leaking internals', async () => {
const res = await request(app).post('/api/v1/auth/employee/login').send({ email: 'not-an-email', password: 'x' })
@@ -19,7 +19,7 @@ describe('employee and marketplace public boundary smoke', () => {
})
it('rejects invalid public review payloads without executing private infrastructure', async () => {
const res = await request(app).post('/api/v1/marketplace/review/token_123').send({ overallRating: 0 })
const res = await request(app).post('/api/v1/storefront/review/token_123').send({ overallRating: 0 })
expect(res.status).toBe(400)
expect(JSON.stringify(res.body)).not.toContain('Prisma')
@@ -24,12 +24,12 @@ vi.mock('../../modules/site/site.service', () => ({
capturePaypal: vi.fn(),
handleContact: vi.fn(),
}))
vi.mock('../../modules/marketplace/marketplace.service', () => ({
vi.mock('../../modules/storefront/storefront.service', () => ({
getPublicOffers: vi.fn(),
getCities: vi.fn(),
getListedCompanies: vi.fn(),
searchVehicles: vi.fn(),
createMarketplaceReservation: vi.fn(),
createStorefrontReservation: vi.fn(),
getReviewContext: vi.fn(),
submitReview: vi.fn(),
validateOfferCode: vi.fn(),
@@ -43,7 +43,7 @@ vi.mock('../../modules/marketplace/marketplace.service', () => ({
import request from 'supertest'
import { createApp } from '../../app'
import * as siteService from '../../modules/site/site.service'
import * as marketplaceService from '../../modules/marketplace/marketplace.service'
import * as storefrontService from '../../modules/storefront/storefront.service'
const app = createApp()
@@ -61,8 +61,8 @@ describe('public validation e2e smoke', () => {
expect(payment.status).toBe(400)
expect(siteService.initPayment).not.toHaveBeenCalled()
const review = await request(app).post('/api/v1/marketplace/review/token_1').send({ overallRating: 0 })
const review = await request(app).post('/api/v1/storefront/review/token_1').send({ overallRating: 0 })
expect(review.status).toBe(400)
expect(marketplaceService.submitReview).not.toHaveBeenCalled()
expect(storefrontService.submitReview).not.toHaveBeenCalled()
})
})
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest'
describe('billing, marketplace, and vehicle integration boundaries', () => {
describe('billing, storefront, and vehicle integration boundaries', () => {
it('documents the Batch-10 DB-backed regression targets for the real integration environment', () => {
expect([
'admin billing invoice lifecycle uses persisted line items and audit logs',
'marketplace company pages enrich vehicles with real availability records',
'storefront company pages enrich vehicles with real availability records',
'vehicle location settings persist through create/update/list flows',
]).toHaveLength(3)
})
@@ -1,14 +1,14 @@
import { describe, expect, it } from 'vitest'
describe('employee, marketplace, and notification integration boundaries', () => {
describe('employee, storefront, and notification integration boundaries', () => {
it('documents the Batch-17 DB-backed integration boundary', () => {
expect({
employeeAuth: 'password reset/login flows require generated Prisma and mail-provider test doubles',
marketplace: 'public booking/review flows require seeded company, vehicle, reservation, and token records',
storefront: 'public booking/review flows require seeded company, vehicle, reservation, and token records',
notifications: 'preference persistence requires notificationPreference composite indexes',
}).toEqual(expect.objectContaining({
employeeAuth: expect.stringContaining('Prisma'),
marketplace: expect.stringContaining('seeded'),
storefront: expect.stringContaining('seeded'),
notifications: expect.stringContaining('composite'),
}))
})
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { parseBody, parseQuery } from '../../http/validate'
import { marketplaceReservationSchema, paginationSchema } from '../../modules/marketplace/marketplace.schemas'
import { storefrontReservationSchema, paginationSchema } from '../../modules/storefront/storefront.schemas'
import { paySchema } from '../../modules/site/site.schemas'
function reqWith(body: unknown, query: unknown = {}) {
@@ -8,7 +8,7 @@ function reqWith(body: unknown, query: unknown = {}) {
}
describe('public validation boundaries', () => {
it('keeps marketplace pagination coercion consistent with route parsing', () => {
it('keeps storefront pagination coercion consistent with route parsing', () => {
expect(parseQuery(paginationSchema, reqWith({}, { page: '4', pageSize: '25' }))).toEqual({
page: 4,
pageSize: 25,
@@ -24,8 +24,8 @@ describe('public validation boundaries', () => {
}))).toThrow('Invalid enum value')
})
it('requires enough anonymous marketplace reservation identity to create a booking request', () => {
const parsed = parseBody(marketplaceReservationSchema, reqWith({
it('requires enough anonymous storefront reservation identity to create a booking request', () => {
const parsed = parseBody(storefrontReservationSchema, reqWith({
vehicleId: 'ckvvehicle000000000000001',
companySlug: 'atlas-cars',
firstName: 'Aya',