234 lines
11 KiB
TypeScript
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)
|
|
})
|
|
})
|