import { describe, it, expect, vi, beforeEach } from 'vitest' import { NotFoundError, ConflictError, AppError } from '../../http/errors' vi.mock('./marketplace.repo', () => ({ findPublicOffers: vi.fn(), findCitiesFromCompanies: vi.fn(), findListedCompanies: vi.fn(), findPublishedVehicles: vi.fn(), findVehicleForMarketplace: vi.fn(), upsertMarketplaceCustomer: vi.fn(), createMarketplaceReservation: vi.fn(), findCompanyPage: vi.fn(), findCompanyBySlug: vi.fn(), findCompanyReviews: vi.fn(), findCompanyVehicles: vi.fn(), findVehicleById: vi.fn(), findCompanyOffers: vi.fn(), findReservationByReviewToken: vi.fn(), findReservationForReviewSubmit: vi.fn(), createReview: vi.fn(), invalidateReviewToken: vi.fn(), findOfferByCode: vi.fn(), })) vi.mock('../../services/vehicleAvailabilityService', () => ({ getVehicleAvailabilitySummary: vi.fn(), })) vi.mock('../../services/notificationService', () => ({ sendNotification: vi.fn().mockResolvedValue(undefined), sendTransactionalEmail: vi.fn().mockResolvedValue(undefined), })) vi.mock('../../lib/emailTranslations', () => ({ marketplaceReservationEmail: { subject: vi.fn(() => 'Subject'), html: vi.fn(() => ''), text: vi.fn(() => 'text'), }, })) import * as repo from './marketplace.repo' import { getVehicleAvailabilitySummary } from '../../services/vehicleAvailabilityService' import { getCities, searchVehicles, createMarketplaceReservation, getReviewContext, submitReview, validateOfferCode, } from './marketplace.service' const SLUG = 'test-company' function makeVehicle(overrides: object = {}) { return { id: 'v-1', dailyRate: 10000, year: 2022, make: 'Toyota', model: 'Camry', isPublished: true, status: 'AVAILABLE', companyId: 'co-1', pickupLocations: ['Casablanca'], allowDifferentDropoff: false, dropoffLocations: [], company: { name: 'Test Co', brand: { displayName: 'Test Co' } }, ...overrides, } } beforeEach(() => { vi.clearAllMocks() }) // ──────────────────────────────────────────────────────────────────────────── describe('getCities', () => { it('deduplicates and sorts cities case-insensitively', async () => { vi.mocked(repo.findCitiesFromCompanies).mockResolvedValue([ { brand: { publicCity: 'Casablanca' } }, { brand: { publicCity: 'casablanca' } }, { brand: { publicCity: 'Rabat' } }, { brand: null }, ] as any) const result = await getCities() expect(result).toEqual(['Casablanca', 'Rabat']) }) }) // ──────────────────────────────────────────────────────────────────────────── describe('searchVehicles', () => { it('returns vehicles enriched with availability data', async () => { vi.mocked(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any) vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null }) const result = await searchVehicles({ city: 'Casablanca' }) expect(repo.findPublishedVehicles).toHaveBeenCalledOnce() expect(result[0].availability).toBe(true) expect(result[0].availabilityStatus).toBe('AVAILABLE') }) it('passes date range to availability service when provided', async () => { vi.mocked(repo.findPublishedVehicles).mockResolvedValue([makeVehicle()] as any) vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: new Date('2025-07-01'), blockingReason: 'RESERVATION' }) const result = await searchVehicles({ startDate: '2025-06-01T00:00:00.000Z', endDate: '2025-06-04T00:00:00.000Z' }) expect(getVehicleAvailabilitySummary).toHaveBeenCalledWith('v-1', { range: { startDate: new Date('2025-06-01T00:00:00.000Z'), endDate: new Date('2025-06-04T00:00:00.000Z') }, }) expect(result[0].availability).toBe(false) }) it('filters out vehicles that do not support different drop-off when requested', async () => { vi.mocked(repo.findPublishedVehicles).mockResolvedValue([ makeVehicle({ id: 'same-only' }), makeVehicle({ id: 'one-way', allowDifferentDropoff: true, dropoffLocations: ['Rabat'] }), ] as any) vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: true, status: 'AVAILABLE', nextAvailableAt: null, blockingReason: null }) const result = await searchVehicles({ pickupLocation: 'Casablanca', dropoffLocation: 'Rabat', dropoffMode: 'different' }) expect(result).toHaveLength(1) expect(result[0].id).toBe('one-way') }) }) // ──────────────────────────────────────────────────────────────────────────── describe('createMarketplaceReservation', () => { const baseBody = { vehicleId: 'v-1', companySlug: SLUG, firstName: 'Ali', lastName: 'Ben', email: 'ali@test.com', startDate: '2025-06-01T00:00:00.000Z', endDate: '2025-06-04T00:00:00.000Z', } it('creates reservation and returns reservationId', async () => { vi.mocked(repo.findVehicleForMarketplace).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' } as any) const result = await createMarketplaceReservation({ ...baseBody, pickupLocation: 'Casablanca', returnLocation: 'Casablanca' }) expect(result.reservationId).toBe('r-1') expect(repo.createMarketplaceReservation).toHaveBeenCalledWith(expect.objectContaining({ pickupLocation: 'Casablanca', returnLocation: 'Casablanca', })) }) it('throws NotFoundError when vehicle not found', async () => { vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(null) await expect(createMarketplaceReservation(baseBody)).rejects.toBeInstanceOf(NotFoundError) }) it('throws AppError when vehicle is unavailable', async () => { vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(makeVehicle() as any) vi.mocked(getVehicleAvailabilitySummary).mockResolvedValue({ available: false, status: 'RESERVED', nextAvailableAt: null, blockingReason: 'RESERVATION' }) await expect(createMarketplaceReservation(baseBody)).rejects.toMatchObject({ error: 'unavailable' }) }) it('rejects different return locations when the vehicle requires same drop-off', async () => { vi.mocked(repo.findVehicleForMarketplace).mockResolvedValue(makeVehicle() as any) await expect( createMarketplaceReservation({ ...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' }), ).rejects.toMatchObject({ error: 'invalid_dates' }) }) }) // ──────────────────────────────────────────────────────────────────────────── describe('getReviewContext', () => { it('returns review context for a valid unreviewd token', async () => { vi.mocked(repo.findReservationByReviewToken).mockResolvedValue({ id: 'r-1', companyId: 'co-1', renterId: null, reviewToken: 'tok', vehicle: { year: 2022, make: 'Toyota', model: 'Camry', photos: ['photo.jpg'] }, company: { name: 'Test Co', brand: { displayName: 'Test Co', logoUrl: 'logo.png' } }, review: null, } as any) const result = await getReviewContext('tok') expect(result.reservationId).toBe('r-1') expect(result.vehiclePhoto).toBe('photo.jpg') }) it('throws NotFoundError for unknown token', async () => { vi.mocked(repo.findReservationByReviewToken).mockResolvedValue(null) await expect(getReviewContext('bad')).rejects.toBeInstanceOf(NotFoundError) }) it('throws ConflictError when already reviewed', async () => { vi.mocked(repo.findReservationByReviewToken).mockResolvedValue({ id: 'r-1', companyId: 'co-1', renterId: null, vehicle: { year: 2022, make: 'Toyota', model: 'Camry', photos: [] }, company: { name: 'Test Co', brand: null }, review: { id: 'rev-1' }, } as any) await expect(getReviewContext('tok')).rejects.toBeInstanceOf(ConflictError) }) }) // ──────────────────────────────────────────────────────────────────────────── describe('submitReview', () => { it('creates review and invalidates token', async () => { vi.mocked(repo.findReservationForReviewSubmit).mockResolvedValue({ id: 'r-1', companyId: 'co-1', renterId: null, review: null } as any) vi.mocked(repo.createReview).mockResolvedValue({ id: 'rev-1' } as any) vi.mocked(repo.invalidateReviewToken).mockResolvedValue(undefined as any) const result = await submitReview('tok', { overallRating: 5 }) expect(repo.createReview).toHaveBeenCalledOnce() expect(repo.invalidateReviewToken).toHaveBeenCalledWith('r-1') expect(result.reviewId).toBe('rev-1') }) }) // ──────────────────────────────────────────────────────────────────────────── describe('validateOfferCode', () => { it('returns offer when code is valid and not exhausted', async () => { vi.mocked(repo.findOfferByCode).mockResolvedValue({ id: 'o-1', maxRedemptions: null, redemptionCount: 0 } as any) const result = await validateOfferCode('PROMO10') expect((result as any).id).toBe('o-1') }) it('throws NotFoundError for unknown code', async () => { vi.mocked(repo.findOfferByCode).mockResolvedValue(null) await expect(validateOfferCode('BAD')).rejects.toBeInstanceOf(NotFoundError) }) it('throws ConflictError when max redemptions reached', async () => { vi.mocked(repo.findOfferByCode).mockResolvedValue({ id: 'o-1', maxRedemptions: 10, redemptionCount: 10 } as any) await expect(validateOfferCode('PROMO10')).rejects.toBeInstanceOf(ConflictError) }) })