Files
carmanagement/apps/api/src/modules/marketplace/marketplace.test.ts
T
2026-05-24 23:58:54 -04:00

234 lines
11 KiB
TypeScript

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(() => '<html>'),
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)
})
})