// FE-COMP-TRIPPDF-001 to FE-COMP-TRIPPDF-010 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { http, HttpResponse } from 'msw' import { downloadTripPDF } from './TripPDF' import { server } from '../../../tests/helpers/msw/server' import { clearExchangeRateCache } from '../../hooks/useExchangeRates' import { getMergedItems, getTransportForDay } from '../../utils/dayMerge' // ── Helpers ─────────────────────────────────────────────────────────────────── const minimalArgs = { trip: { id: 1, title: 'My Trip', description: null, cover_image: null } as any, days: [{ id: 1, day_number: 1, title: null, date: '2025-06-01' }] as any[], places: [], assignments: {}, categories: [], dayNotes: [], reservations: [], t: (key: string, params?: any) => { if (params?.n !== undefined) return `Day ${params.n}` return key }, locale: 'en-US', } function getOverlay(): HTMLElement | null { return document.getElementById('pdf-preview-overlay') } function getIframe(): HTMLIFrameElement | null { return document.querySelector('#pdf-preview-overlay iframe') } /** The cover's "Planned" figure. The stat is read off its label, since days and places print the same markup. */ function plannedStat(html: string): number | null { const match = /
| 9:00 | ') expect(html).not.toContain('**Colosseum**') expect(html).not.toContain('') }) it('FE-COMP-TRIPPDF-016b: formats price chip and totals in the trip currency, not EUR', async () => { const usdArgs = { ...richArgs, trip: { ...richArgs.trip, currency: 'USD' }, } await downloadTripPDF(usdArgs) const iframe = getIframe() // Place price chip: place has no currency override, falls back to trip.currency (USD). expect(iframe!.srcdoc).toContain('$15.00') // No literal "EUR" text should leak into a USD trip's export. expect(iframe!.srcdoc).not.toContain('EUR') // Price chip icon must stay currency-neutral — not the euro-shaped glyph. expect(iframe!.srcdoc).not.toContain('M14 5c-3.87 0-7 3.13-7 7s3.13 7 7 7c2.17 0 4.1-.99 5.4-2.55') }) it('FE-COMP-TRIPPDF-016c: a place with its own currency overrides the trip currency for its price chip', async () => { const mixedArgs = { ...richArgs, trip: { ...richArgs.trip, currency: 'EUR' }, assignments: { '10': [{ ...assignmentForDay, place: { ...placeWithDetails, currency: 'JPY', price: '1500' }, }], } as any, } await downloadTripPDF(mixedArgs) const iframe = getIframe() // JPY is a zero-decimal currency (currencyDecimals) and uses its own symbol, not EUR. // Note: Intl renders JPY with the fullwidth yen sign (U+FFE5 "¥"), not U+00A5 "¥". expect(iframe!.srcdoc).toContain('¥1,500') }) it('FE-COMP-TRIPPDF-016d: converts foreign-currency prices into the trip currency for day and cover totals (#1561)', async () => { server.use(http.get('https://api.frankfurter.dev/v2/rates', ({ request }) => { expect(new URL(request.url).searchParams.get('base')).toBe('NOK') return HttpResponse.json([{ quote: 'USD', rate: 0.1 }]) // 1 NOK = 0.1 USD })) const mixedArgs = { ...richArgs, trip: { ...richArgs.trip, currency: 'NOK' }, assignments: { '10': [ { ...assignmentForDay, place: { ...placeWithDetails, currency: 'USD', price: '273' } }, { ...assignmentForDay, id: 201, place: { ...placeWithDetails, id: 101, name: 'Museum', currency: null, price: '2500' } }, ], } as any, } await downloadTripPDF(mixedArgs) const srcdoc = getIframe()!.srcdoc.replace(/[\u00A0\u202F]/g, ' ') // 2500 NOK + 273 USD / 0.1 = 5230 NOK, marked approximate, in day header AND cover stat. expect(srcdoc).toContain('≈ 5 230,00 kr') expect((srcdoc.match(/≈ 5 230,00 kr/g) || []).length).toBeGreaterThanOrEqual(2) }) it('FE-COMP-TRIPPDF-016e: falls back to per-currency breakdowns when the FX fetch fails (#1561)', async () => { server.use(http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.error())) const mixedArgs = { ...richArgs, trip: { ...richArgs.trip, currency: 'NOK' }, assignments: { '10': [ { ...assignmentForDay, place: { ...placeWithDetails, currency: 'USD', price: '2730.27' } }, { ...assignmentForDay, id: 201, place: { ...placeWithDetails, id: 101, name: 'Museum', currency: null, price: '2500' } }, ], } as any, } // Export still resolves — a dead FX endpoint must never break the PDF. await expect(downloadTripPDF(mixedArgs)).resolves.not.toThrow() const srcdoc = getIframe()!.srcdoc.replace(/[\u00A0\u202F]/g, ' ') // Honest breakdown, base currency first; the USD amount is never NOK-labeled. expect(srcdoc).toContain('2 500,00 kr + $2,730.27') expect(srcdoc).not.toContain('≈') expect(srcdoc).not.toMatch(/5 ?230/) }) it('FE-COMP-TRIPPDF-016g: with Costs on, the day and cover totals are the expenses, not the place prices (#2551)', async () => { const args = { ...richArgs, trip: { ...richArgs.trip, currency: 'USD' }, costsEnabled: true, budgetItems: [ { id: 1, trip_id: 10, category: 'activities', name: 'Tickets', total_price: 42, currency: null, place_id: 100 }, { id: 2, trip_id: 10, category: 'other', name: 'Insurance', total_price: 8, currency: null }, ], } await downloadTripPDF(args) const srcdoc = getIframe()!.srcdoc // The day carries the ticket linked to its place, the cover the whole trip. expect(srcdoc).toContain('$42.00') expect(srcdoc).toContain('$50.00') document.getElementById('pdf-preview-overlay')?.remove() // Every expense deleted: nothing is left to add up, whatever price the place carries. await downloadTripPDF({ ...args, budgetItems: [] }) const after = getIframe()!.srcdoc expect(after).not.toContain('$42.00') expect(after).not.toContain('$50.00') expect(after).toContain('$15.00') // the place's own price chip stays on its card }) it('FE-COMP-TRIPPDF-016f: an all-same-currency trip makes no FX request', async () => { let fxCalled = false server.use(http.get('https://api.frankfurter.dev/v2/rates', () => { fxCalled = true return HttpResponse.json([]) })) await downloadTripPDF({ ...richArgs, trip: { ...richArgs.trip, currency: 'EUR' } }) expect(fxCalled).toBe(false) // Totals render exactly as before for the single-currency case. const srcdoc = getIframe()!.srcdoc.replace(/[\u00A0\u202F]/g, ' ') expect(srcdoc).toContain('15,00 €') expect(srcdoc).not.toContain('≈') }) it('FE-COMP-TRIPPDF-017: renders trip description on cover', async () => { await downloadTripPDF(richArgs) const iframe = getIframe() expect(iframe!.srcdoc).toContain('Summer adventure') }) it('FE-COMP-TRIPPDF-018: renders place with direct image URL', async () => { const argsWithImg = { ...richArgs, assignments: { '10': [{ ...assignmentForDay, place: { ...placeWithDetails, image_url: '/uploads/colosseum.jpg' }, }], } as any, } await downloadTripPDF(argsWithImg) const iframe = getIframe() expect(iframe!.srcdoc).toContain('colosseum.jpg') }) it('FE-COMP-TRIPPDF-018b: renders a persisted place-photo proxy image_url as anItem | ') expect(html).toContain('Socks | ') expect(html).toContain('Bare') }) }) // FE-W5PDF-021 to FE-W5PDF-026 — the defaulting arms of the exporter. describe('downloadTripPDF defaults', () => { type Args = Parameters
|---|