Files
TREK/client/src/components/Planner/TripExportModal.test.tsx
T
Maurice 2a51a1a842 test: hold the branch's tests to the type and lint gates
The booking save helper answers a Reservation the way the dialogs' onSave
does, and takes the payload the sheets read back. The export tests resolve
their routing promise without non-null assertions, the autocomplete test
reads its own console spy, and the Photon tally test asks its five dropped
questions together instead of awaiting in a loop. The Photon address line
falls back to empty when a feature has none.

The repair test now expects the dashboard to place the cleared beach in
Germany through the borders, as the Atlas map does since #2875. The client
lint baseline follows the counts this branch lowered.
2026-10-29 15:24:49 +01:00

332 lines
16 KiB
TypeScript

// FE-PLANNER-EXPORTMODAL-001 to FE-PLANNER-EXPORTMODAL-020
import { act, render, screen, waitFor } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { downloadTripPDF } from '../PDF/TripPDF'
import { buildDay, buildDayNote, buildTrip, buildUser } from '../../../tests/helpers/factories'
import { TripExportModal } from './TripExportModal'
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
import { useAuthStore } from '../../store/authStore'
import type { AssignmentsMap } from '../../types'
vi.mock('../PDF/TripPDF', () => ({ downloadTripPDF: vi.fn().mockResolvedValue(undefined) }))
// The subscribe dialog fetches its feed token on mount; it is exercised in its
// own test, here we only care that the entry mounts it. Portalled to the body
// like the real one, so the stacking order can be checked.
vi.mock('./IcsSubscribeModal', async () => {
const { createPortal } = await import('react-dom')
return {
IcsSubscribeModal: ({ title, onClose }: { title: string; onClose: () => void }) => createPortal(
<div data-testid="ics-subscribe-modal">
{title}
<button onClick={onClose}>close-subscribe</button>
</div>,
document.body,
),
}
})
const t = (key: string, params?: Record<string, unknown>) =>
params ? `${key}|${Object.values(params).join('|')}` : key
const trip = buildTrip({ id: 1, title: 'Roadtrip' })
function makeToast() {
return {
success: vi.fn((_m: string) => {}),
error: vi.fn((_m: string) => {}),
warning: vi.fn((_m: string) => {}),
info: vi.fn((_m: string) => {}),
}
}
function makeProps(overrides: Partial<React.ComponentProps<typeof TripExportModal>> = {}) {
return {
isOpen: true,
onClose: vi.fn(),
tripId: 1,
trip,
days: [],
places: [],
categories: [],
assignments: {},
reservations: [],
dayNotes: {},
t,
locale: 'en-US',
toast: makeToast(),
...overrides,
} as React.ComponentProps<typeof TripExportModal>
}
let clickedHref: string | null
beforeEach(() => {
vi.clearAllMocks()
clickedHref = null
// jsdom has neither of these, and an anchor click would navigate.
globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock')
globalThis.URL.revokeObjectURL = vi.fn()
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
clickedHref = this.href
})
})
afterEach(() => vi.restoreAllMocks())
const okResponse = (body = '<gpx/>') =>
({ ok: true, status: 200, blob: async () => new Blob([body]) }) as unknown as Response
describe('TripExportModal', () => {
it('FE-PLANNER-EXPORTMODAL-001: closed, it renders nothing', () => {
render(<TripExportModal {...makeProps({ isOpen: false })} />)
expect(screen.queryByText('dayplan.export')).not.toBeInTheDocument()
})
it('FE-PLANNER-EXPORTMODAL-002: open, every export sits in its own section', () => {
render(<TripExportModal {...makeProps()} />)
expect(screen.getByRole('dialog', { name: 'dayplan.export' })).toBeInTheDocument()
expect(screen.getByText('dayplan.exportIntro')).toBeInTheDocument()
expect(screen.getByText('dayplan.exportDocument')).toBeInTheDocument()
expect(screen.getByText('dayplan.exportCalendar')).toBeInTheDocument()
// The format stands beside the plain-language heading as a pill, not behind a "·".
expect(screen.getByText('dayplan.exportMaps')).toBeInTheDocument()
expect(screen.getByText('GPX')).toBeInTheDocument()
expect(document.body.textContent).not.toContain('·')
for (const label of ['dayplan.pdf', 'mobileTrip.icsDownload', 'mobileTrip.icsSubscribe',
'dayplan.gpxAll', 'dayplan.gpxPlaces', 'dayplan.gpxDays']) {
expect(screen.getByText(label)).toBeInTheDocument()
}
})
it('FE-PLANNER-EXPORTMODAL-015: Escape and the head band close button close the dialog', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(<TripExportModal {...makeProps({ onClose })} />)
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalledTimes(1)
await user.click(screen.getByRole('button', { name: 'Close' }))
expect(onClose).toHaveBeenCalledTimes(2)
})
it('FE-PLANNER-EXPORTMODAL-016: while one export works, every row waits', async () => {
const user = userEvent.setup()
let finish = () => {}
vi.mocked(downloadTripPDF).mockImplementationOnce(() => new Promise<void>(res => { finish = () => res() }))
render(<TripExportModal {...makeProps()} />)
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(screen.getByRole('button', { name: /dayplan\.gpxAll/ })).toBeDisabled())
expect(screen.getByRole('button', { name: /mobileTrip\.icsDownload/ })).toBeDisabled()
expect(screen.getByRole('button', { name: /dayplan\.pdf/ })).toBeDisabled()
finish()
await waitFor(() => expect(screen.getByRole('button', { name: /dayplan\.gpxAll/ })).toBeEnabled())
})
// ── PDF ───────────────────────────────────────────────────────────────────
it('FE-PLANNER-EXPORTMODAL-003: the PDF row exports the trip with the day notes flattened', async () => {
const user = userEvent.setup()
const days = [buildDay({ id: 10, title: 'Day 1' })]
const dayNotes = { '10': [buildDayNote({ id: 1, text: 'Bring cash' })] }
render(<TripExportModal {...makeProps({ days, dayNotes })} />)
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({
trip, days, showServiceStops: true,
dayNotes: [expect.objectContaining({ id: 1, text: 'Bring cash', day_id: 10 })],
})
})
it('FE-PLANNER-EXPORTMODAL-004: a finished PDF export closes the dialog', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(<TripExportModal {...makeProps({ onClose })} />)
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1))
})
it('FE-PLANNER-EXPORTMODAL-005: a failing PDF export toasts the error and keeps the dialog open', async () => {
const user = userEvent.setup()
vi.mocked(downloadTripPDF).mockRejectedValueOnce(new Error('font missing'))
const toast = makeToast()
const onClose = vi.fn()
render(<TripExportModal {...makeProps({ toast, onClose })} />)
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: font missing'))
expect(onClose).not.toHaveBeenCalled()
})
it('FE-PLANNER-EXPORTMODAL-006: an aborted PDF export without an Error still reaches the toast', async () => {
const user = userEvent.setup()
vi.mocked(downloadTripPDF).mockRejectedValueOnce('boom')
const toast = makeToast()
render(<TripExportModal {...makeProps({ toast })} />)
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.pdfError: boom'))
})
// ── Calendar ──────────────────────────────────────────────────────────────
it('FE-PLANNER-EXPORTMODAL-007: the ICS row fetches the export and hands it to a download link', async () => {
const user = userEvent.setup()
const fetchMock = vi.fn(async () => okResponse('BEGIN:VCALENDAR'))
vi.stubGlobal('fetch', fetchMock)
render(<TripExportModal {...makeProps()} />)
await user.click(screen.getByText('mobileTrip.icsDownload'))
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('/api/trips/1/export.ics', { credentials: 'include' }))
await waitFor(() => expect(clickedHref).toBe('blob:mock'))
})
it('FE-PLANNER-EXPORTMODAL-008: a rejected ICS export shows the failure toast', async () => {
const user = userEvent.setup()
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response))
const toast = makeToast()
render(<TripExportModal {...makeProps({ toast })} />)
await user.click(screen.getByText('mobileTrip.icsDownload'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('planner.icsExportFailed'))
expect(clickedHref).toBeNull()
})
it('FE-PLANNER-EXPORTMODAL-009: the subscribe row opens the subscribe dialog and closes again', async () => {
const user = userEvent.setup()
render(<TripExportModal {...makeProps()} />)
await user.click(screen.getByText('mobileTrip.icsSubscribe'))
expect(screen.getByTestId('ics-subscribe-modal')).toBeInTheDocument()
await user.click(screen.getByText('close-subscribe'))
expect(screen.queryByTestId('ics-subscribe-modal')).not.toBeInTheDocument()
})
// The subscription dialog used to sit under the export dialog (9999 against
// 10000), and one Escape closed the export behind it as well.
it('FE-PLANNER-EXPORTMODAL-017: the subscribe dialog stacks on top, and Escape leaves the export alone while it is open', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(<TripExportModal {...makeProps({ onClose })} />)
await user.click(screen.getByText('mobileTrip.icsSubscribe'))
const exportBackdrop = screen.getByRole('dialog', { name: 'dayplan.export' }).parentElement as HTMLElement
const subscribe = screen.getByTestId('ics-subscribe-modal')
// Both hang off the body; the later one paints above the earlier.
expect(exportBackdrop.compareDocumentPosition(subscribe) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
await user.keyboard('{Escape}')
expect(onClose).not.toHaveBeenCalled()
// A press on the export's backdrop is spent on nothing either.
await user.click(exportBackdrop)
expect(onClose).not.toHaveBeenCalled()
await user.click(screen.getByText('close-subscribe'))
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalledTimes(1)
})
// The subscription mints a link that reads the trip without an account, so it
// needs share_manage; the one-off download is a file this member may already
// read. Leaving the entry visible would only produce a dialog whose enable
// button the server refuses.
it('FE-PLANNER-EXPORTMODAL-010: without share_manage the download stays and the subscription goes', () => {
render(<TripExportModal {...makeProps({ canManageShare: false })} />)
expect(screen.getByText('mobileTrip.icsDownload')).toBeInTheDocument()
expect(screen.queryByText('mobileTrip.icsSubscribe')).not.toBeInTheDocument()
})
// ── GPX (#1442) ───────────────────────────────────────────────────────────
it('FE-PLANNER-EXPORTMODAL-011: each scope asks the server for exactly its own selection', async () => {
const user = userEvent.setup()
const fetchMock = vi.fn(async () => okResponse())
vi.stubGlobal('fetch', fetchMock)
for (const [label, expected] of [
['dayplan.gpxAll', '/api/trips/1/places/export.gpx'],
['dayplan.gpxPlaces', '/api/trips/1/places/export.gpx?dayRoutes=false'],
['dayplan.gpxDays', '/api/trips/1/places/export.gpx?waypoints=false&tracks=false'],
] as const) {
const view = render(<TripExportModal {...makeProps()} />)
await user.click(screen.getByText(label))
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expected, { credentials: 'include' }))
fetchMock.mockClear()
view.unmount()
}
})
it('FE-PLANNER-EXPORTMODAL-012: a download names the file after the trip', async () => {
const user = userEvent.setup()
vi.stubGlobal('fetch', vi.fn(async () => okResponse()))
render(<TripExportModal {...makeProps()} />)
await user.click(screen.getByText('dayplan.gpxAll'))
await waitFor(() => expect(clickedHref).toBe('blob:mock'))
await waitFor(() => expect(globalThis.URL.revokeObjectURL).toHaveBeenCalled())
})
it('FE-PLANNER-EXPORTMODAL-013: an empty trip says so instead of reporting a failure', async () => {
const user = userEvent.setup()
const toast = makeToast()
const onClose = vi.fn()
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 404 }) as unknown as Response))
render(<TripExportModal {...makeProps({ toast, onClose })} />)
await user.click(screen.getByText('dayplan.gpxAll'))
await waitFor(() => expect(toast.info).toHaveBeenCalledWith('dayplan.gpxEmpty'))
expect(toast.error).not.toHaveBeenCalled()
expect(clickedHref).toBeNull()
// Nothing was downloaded, so the dialog stays put.
expect(onClose).not.toHaveBeenCalled()
})
it('FE-PLANNER-EXPORTMODAL-014: a real failure toasts the error', async () => {
const user = userEvent.setup()
const toast = makeToast()
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response))
render(<TripExportModal {...makeProps({ toast })} />)
await user.click(screen.getByText('dayplan.gpxAll'))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('dayplan.gpxFailed'))
})
it('FE-PLANNER-EXPORTMODAL-018: "My plan" appears once people are assigned, and prints only mine (#2168)', async () => {
const user = userEvent.setup()
resetAllStores()
seedStore(useAuthStore, { user: buildUser({ id: 7 }) })
const { rerender } = render(<TripExportModal {...makeProps()} />)
expect(screen.queryByText('dayplan.pdfMine')).toBeNull()
const assignments = { 10: [{ id: 1, participants: [{ user_id: 7, username: 'me' }] }] } as unknown as AssignmentsMap
rerender(<TripExportModal {...makeProps({ assignments })} />)
await user.click(screen.getByText('dayplan.pdfMine'))
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
expect(vi.mocked(downloadTripPDF).mock.calls[0][0]).toMatchObject({ onlyUserId: 7 })
})
it('FE-PLANNER-EXPORTMODAL-019: while the PDF routes the row counts the legs, and the rest can be left out (#2876)', async () => {
const user = userEvent.setup()
let finish = () => {}
vi.mocked(downloadTripPDF).mockImplementationOnce(() => new Promise<void>(res => { finish = () => res() }))
const roadtripViasByDay = { 10: [] }
const tourStageStops = new Map()
render(<TripExportModal {...makeProps({ roadtripViasByDay, tourStageStops })} />)
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
const { route } = vi.mocked(downloadTripPDF).mock.calls[0][0]
expect(route).toMatchObject({ viasByDay: roadtripViasByDay, tourStageStops })
act(() => route?.onProgress?.(3, 10))
expect(screen.getByText('dayplan.pdfRouting|3|10')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'dayplan.pdfSkipRouting' }))
expect(route?.signal?.aborted).toBe(true)
expect(screen.queryByRole('button', { name: 'dayplan.pdfSkipRouting' })).toBeNull()
await act(async () => finish())
})
it('FE-PLANNER-EXPORTMODAL-020: closing the dialog while the PDF routes gives the export up', async () => {
const user = userEvent.setup()
let finish = () => {}
vi.mocked(downloadTripPDF).mockImplementationOnce(() => new Promise<void>(res => { finish = () => res() }))
const props = makeProps()
const { rerender } = render(<TripExportModal {...props} />)
await user.click(screen.getByText('dayplan.pdf'))
await waitFor(() => expect(downloadTripPDF).toHaveBeenCalledTimes(1))
const { route, signal } = vi.mocked(downloadTripPDF).mock.calls[0][0]
rerender(<TripExportModal {...props} isOpen={false} />)
expect(route?.signal?.aborted).toBe(true)
expect(signal?.aborted).toBe(true)
await act(async () => finish())
expect(props.onClose).not.toHaveBeenCalled()
})
})