import { beforeEach, describe, expect, it, vi } from 'vitest' import MTransportFormSheet from '../../../../src/mobile/screens/trip/sheets/MTransportFormSheet' import type { TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell' import { useAddonStore } from '../../../../src/store/addonStore' import { useTripStore } from '../../../../src/store/tripStore' import { useAuthStore } from '../../../../src/store/authStore' import { server } from '../../../helpers/msw/server' import { http, HttpResponse } from 'msw' import { buildBudgetItem, buildUser } from '../../../helpers/factories' import { useSettingsStore } from '../../../../src/store/settingsStore' import { isBlurred } from '../../../helpers/bookingCodeBlur' import type { Day, Reservation, TripMember } from '../../../../src/types' import { buildPlanner } from '../../../helpers/mobileTrip' import { resetAllStores, seedStore } from '../../../helpers/store' import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render' // FE-MOB-TRFRM-001 to FE-MOB-TRFRM-059 // // The sheet's own pickers (airport/location search, day select, time picker) and // the embedded transit panel are replaced by minimal controlled stand-ins so the // form's own state machine and payload assembly are what the tests exercise. const { AIRPORTS, LOCATIONS } = vi.hoisted(() => ({ AIRPORTS: { FRA: { iata: 'FRA', icao: null, name: 'Frankfurt Airport', city: 'Frankfurt', country: 'DE', lat: 50.03, lng: 8.57, tz: 'Europe/Berlin' }, IST: { iata: 'IST', icao: null, name: 'Istanbul Airport', city: 'Istanbul', country: 'TR', lat: 41.27, lng: 28.75, tz: 'Europe/Istanbul' }, HND: { iata: 'HND', icao: null, name: 'Haneda Airport', city: 'Tokyo', country: 'JP', lat: 35.55, lng: 139.78, tz: 'Asia/Tokyo' }, } as Record, LOCATIONS: { osaka: { name: 'Osaka Station', lat: 34.7, lng: 135.5, address: null }, kyoto: { name: 'Kyoto Station', lat: 34.98, lng: 135.75, address: null }, nara: { name: 'Nara Station', lat: 34.68, lng: 135.82, address: null }, } as Record, })) vi.mock('../../../../src/components/shared/CustomSelect', () => ({ default: ({ value, onChange, options = [] }: { value: string | number onChange: (v: string | number) => void options?: { value: string | number; label: string }[] }) => ( ), })) vi.mock('../../../../src/components/shared/CustomTimePicker', () => ({ default: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => ( onChange(e.target.value)} /> ), })) vi.mock('../../../../src/components/Planner/AirportSelect', () => ({ default: ({ value, onChange }: { value: { iata: string } | null; onChange: (a: unknown) => void }) => ( onChange(AIRPORTS[e.target.value] ?? null)} /> ), })) vi.mock('../../../../src/components/Planner/LocationSelect', () => ({ default: ({ value, onChange, places }: { value: { name: string } | null; onChange: (l: unknown) => void; places?: { name: string }[] }) => ( p.name).join('|')} value={value?.name ?? ''} onChange={e => onChange(LOCATIONS[e.target.value] ?? null)} /> ), })) vi.mock('../../../../src/components/Planner/TransitSearchPanel', () => ({ default: ({ day, places, onAdd, initialFrom, initialTime }: { day: { id: number } places: { name: string }[] onAdd: (p: Record) => void initialFrom: { name: string } | null initialTime: string | null }) => (
{day.id} {places.map(p => p.name).join(',')} {initialFrom?.name ?? ''} {initialTime ?? ''}
), })) vi.mock('../../../../src/mobile/screens/trip/sheets/PlFileAttach', () => ({ default: ({ files, onAdd, onRemove, canAttach = true, attached = [], linkable = [], onLink }: { files: File[] onAdd: (f: File[]) => void onRemove: (i: number) => void canAttach?: boolean attached?: { id: number; original_name: string }[] linkable?: { id: number; original_name: string }[] onLink?: (f: { id: number; original_name: string }) => Promise }) => (
{canAttach && } {files.map((f, i) => ( ))} {attached.map(f => {`attached-${f.original_name}`})} {linkable.map(f => ( ))}
), })) const DAYS = [ { id: 11, trip_id: 1, day_number: 1, date: '2026-05-01', title: null }, { id: 12, trip_id: 1, day_number: 2, date: '2026-05-02', title: 'Kyoto' }, { id: 13, trip_id: 1, day_number: 3, date: '2026-05-03', title: null }, ] as unknown as Day[] const MEMBERS = [ { id: 4, username: 'maurice', avatar_url: null }, { id: 5, username: 'julien', avatar_url: '/uploads/avatars/j.png', is_guest: true }, ] as unknown as TripMember[] type SavePayload = Record & { title: string } function makeSave(result: unknown = { id: 99 } as unknown) { return vi.fn(async (_payload: SavePayload) => result) } function makePlanner(overrides: Record = {}): TripPlanner { return buildPlanner({ days: DAYS, showTransportModal: true, trip: { id: 1, title: 'Japan 2026', start_date: '2026-05-01', end_date: '2026-05-03', currency: 'EUR' }, ...overrides, } as unknown as Parameters[0]) } function renderSheet(planner: TripPlanner, onOpenExpense = vi.fn()) { const view = render() return { ...view, planner, onOpenExpense } } const daySelects = () => screen.getAllByLabelText('day-select') as HTMLSelectElement[] const timeInputs = () => screen.getAllByLabelText('time-input') as HTMLInputElement[] const airportInputs = () => screen.getAllByLabelText('airport-select') as HTMLInputElement[] const locationInputs = () => screen.getAllByLabelText('location-select') as HTMLInputElement[] const setValue = (el: HTMLElement, value: string) => fireEvent.change(el, { target: { value } }) const typeTitle = (value: string) => setValue(screen.getByPlaceholderText('reservations.titlePlaceholder'), value) async function submit(label = 'common.add') { await act(async () => { fireEvent.click(screen.getByRole('button', { name: label })) }) } describe('MTransportFormSheet', () => { beforeEach(() => { resetAllStores() seedStore(useAddonStore, { addons: [{ id: 'budget', name: 'Budget', type: 'addon', icon: '', enabled: true }] }) }) it('FE-MOB-TRFRM-001: renders nothing while the planner has the editor closed', () => { renderSheet(makePlanner({ showTransportModal: false })) expect(screen.queryByRole('dialog')).not.toBeInTheDocument() }) it('FE-MOB-TRFRM-002: opens in create mode with a disabled save and the manual/automated switch', () => { renderSheet(makePlanner()) expect(screen.getByRole('dialog', { name: 'transport.modalTitle.create' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.add' })).toBeDisabled() expect(screen.getByRole('button', { name: 'transport.modeManual' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'transport.modeAutomated' })).toBeInTheDocument() // Nothing to delete on a fresh booking. expect(screen.queryByRole('button', { name: 'common.delete' })).not.toBeInTheDocument() }) it('FE-MOB-TRFRM-003: hides the automated switch when the trip has no dates', () => { renderSheet(makePlanner({ trip: { id: 1, title: 'Someday', start_date: null, end_date: null } })) expect(screen.queryByRole('button', { name: 'transport.modeAutomated' })).not.toBeInTheDocument() }) it('FE-MOB-TRFRM-004: offers every transport type and marks the active one', () => { renderSheet(makePlanner()) for (const type of ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other']) { expect(screen.getByRole('button', { name: `reservations.type.${type}` })).toBeInTheDocument() } expect(screen.getByRole('button', { name: 'reservations.type.flight' })).toHaveAttribute('aria-pressed', 'true') fireEvent.click(screen.getByRole('button', { name: 'reservations.type.bus' })) expect(screen.getByRole('button', { name: 'reservations.type.bus' })).toHaveAttribute('aria-pressed', 'true') expect(screen.getByRole('button', { name: 'reservations.type.flight' })).toHaveAttribute('aria-pressed', 'false') }) it('FE-MOB-TRFRM-005: a car booking relabels the date rows as pick-up and return', () => { renderSheet(makePlanner()) fireEvent.click(screen.getByRole('button', { name: 'reservations.type.bus' })) expect(screen.getByText('reservations.date')).toBeInTheDocument() expect(screen.getByText('reservations.startTime')).toBeInTheDocument() expect(screen.getByText('reservations.endDate')).toBeInTheDocument() expect(screen.getByText('reservations.endTime')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'reservations.type.car' })) expect(screen.getByText('reservations.pickupDate')).toBeInTheDocument() expect(screen.getByText('reservations.pickupTime')).toBeInTheDocument() expect(screen.getByText('reservations.returnDate')).toBeInTheDocument() expect(screen.getByText('reservations.returnTime')).toBeInTheDocument() }) it('FE-MOB-TRFRM-006: saves a simple two-endpoint booking with the day-anchored times', async () => { const handleSaveTransport = makeSave() renderSheet(makePlanner({ handleSaveTransport })) fireEvent.click(screen.getByRole('button', { name: 'reservations.type.bus' })) typeTitle('Bus to Kyoto') setValue(locationInputs()[0], 'osaka') setValue(locationInputs()[1], 'kyoto') setValue(daySelects()[0], '11') setValue(timeInputs()[0], '08:15') setValue(daySelects()[1], '12') setValue(timeInputs()[1], '11:00') setValue(screen.getByPlaceholderText('reservations.confirmationPlaceholder'), 'BUS-4711') setValue(screen.getByPlaceholderText('reservations.notesPlaceholder'), 'Seat 4 reserved') fireEvent.click(screen.getByRole('button', { name: 'reservations.confirmed' })) await submit() expect(handleSaveTransport).toHaveBeenCalledTimes(1) expect(handleSaveTransport.mock.calls[0][0]).toEqual({ title: 'Bus to Kyoto', type: 'bus', status: 'confirmed', day_id: 11, end_day_id: 12, reservation_time: '2026-05-01T08:15', reservation_end_time: '2026-05-02T11:00', location: null, confirmation_number: 'BUS-4711', notes: 'Seat 4 reserved', metadata: null, endpoints: [ { role: 'from', sequence: 0, name: 'Osaka Station', code: null, lat: 34.7, lng: 135.5, timezone: null, local_date: '2026-05-01', local_time: '08:15' }, { role: 'to', sequence: 1, name: 'Kyoto Station', code: null, lat: 34.98, lng: 135.75, timezone: null, local_date: '2026-05-02', local_time: '11:00' }, ], needs_review: false, }) }) it('FE-MOB-TRFRM-007: falls back to the start day for the end date and keeps a bare time', async () => { const handleSaveTransport = makeSave() renderSheet(makePlanner({ handleSaveTransport, days: [] })) fireEvent.click(screen.getByRole('button', { name: 'reservations.type.taxi' })) typeTitle('Airport transfer') setValue(timeInputs()[0], '05:40') await submit() const payload = handleSaveTransport.mock.calls[0][0] expect(payload.day_id).toBeNull() expect(payload.end_day_id).toBeNull() // No day means no date prefix — the bare clock time is stored. expect(payload.reservation_time).toBe('05:40') expect(payload.reservation_end_time).toBeNull() expect(payload.endpoints).toEqual([]) }) it('FE-MOB-TRFRM-008: saves a single-leg flight with the flat airline metadata', async () => { const handleSaveTransport = makeSave() renderSheet(makePlanner({ handleSaveTransport })) typeTitle('LH 716') setValue(airportInputs()[0], 'FRA') setValue(airportInputs()[1], 'HND') setValue(daySelects()[0], '11') setValue(timeInputs()[0], '10:20') setValue(daySelects()[1], '12') setValue(timeInputs()[1], '06:45') setValue(screen.getByPlaceholderText('Lufthansa'), 'Lufthansa') setValue(screen.getByPlaceholderText('LH 123'), 'LH 716') setValue(screen.getByPlaceholderText('12A'), '31A') await submit() expect(handleSaveTransport.mock.calls[0][0]).toEqual({ title: 'LH 716', type: 'flight', status: 'pending', day_id: 11, end_day_id: 12, reservation_time: '2026-05-01T10:20', reservation_end_time: '2026-05-02T06:45', location: null, confirmation_number: null, notes: null, metadata: { airline: 'Lufthansa', flight_number: 'LH 716', seat: '31A', departure_airport: 'FRA', departure_timezone: 'Europe/Berlin', arrival_airport: 'HND', arrival_timezone: 'Asia/Tokyo', }, endpoints: [ { role: 'from', sequence: 0, name: 'Frankfurt (FRA)', code: 'FRA', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin', local_date: '2026-05-01', local_time: '10:20' }, { role: 'to', sequence: 1, name: 'Tokyo (HND)', code: 'HND', lat: 35.55, lng: 139.78, timezone: 'Asia/Tokyo', local_date: '2026-05-02', local_time: '06:45' }, ], needs_review: false, }) }) it('FE-MOB-TRFRM-009: adds a layover and writes one metadata leg per segment', async () => { const handleSaveTransport = makeSave() renderSheet(makePlanner({ handleSaveTransport })) typeTitle('FRA-IST-HND') fireEvent.click(screen.getByRole('button', { name: 'reservations.layover.addStop' })) expect(screen.getAllByLabelText('airport-select')).toHaveLength(3) expect(screen.getByText('reservations.layover.stop')).toBeInTheDocument() setValue(airportInputs()[0], 'FRA') setValue(airportInputs()[1], 'IST') setValue(airportInputs()[2], 'HND') // selects: dep(0) | arr(1) dep(1) | arr(2) setValue(daySelects()[0], '11') setValue(timeInputs()[0], '10:20') setValue(daySelects()[1], '11') setValue(timeInputs()[1], '14:05') setValue(daySelects()[2], '11') setValue(timeInputs()[2], '16:30') setValue(daySelects()[3], '12') setValue(timeInputs()[3], '09:00') const airlines = screen.getAllByPlaceholderText('Lufthansa') setValue(airlines[0], 'Lufthansa') setValue(airlines[1], 'Turkish Airlines') const flightNumbers = screen.getAllByPlaceholderText('LH 123') setValue(flightNumbers[0], 'LH 1300') setValue(flightNumbers[1], 'TK 46') setValue(screen.getAllByPlaceholderText('12A')[0], '2A') await submit() const payload = handleSaveTransport.mock.calls[0][0] expect(payload.metadata).toEqual({ airline: 'Lufthansa', flight_number: 'LH 1300', seat: '2A', departure_airport: 'FRA', departure_timezone: 'Europe/Berlin', arrival_airport: 'HND', arrival_timezone: 'Asia/Tokyo', legs: [ { from: 'FRA', to: 'IST', airline: 'Lufthansa', flight_number: 'LH 1300', seat: '2A', dep_day_id: 11, dep_time: '10:20', arr_day_id: 11, arr_time: '14:05' }, { from: 'IST', to: 'HND', airline: 'Turkish Airlines', flight_number: 'TK 46', dep_day_id: 11, dep_time: '16:30', arr_day_id: 12, arr_time: '09:00' }, ], }) expect(payload.endpoints).toEqual([ expect.objectContaining({ role: 'from', sequence: 0, code: 'FRA', local_time: '10:20' }), expect.objectContaining({ role: 'stop', sequence: 1, code: 'IST', local_time: '16:30' }), expect.objectContaining({ role: 'to', sequence: 2, code: 'HND', local_time: '09:00' }), ]) expect(payload.day_id).toBe(11) expect(payload.end_day_id).toBe(12) }) it('FE-MOB-TRFRM-010: removing a layover falls back to the flat single-leg shape', async () => { const handleSaveTransport = makeSave() renderSheet(makePlanner({ handleSaveTransport })) typeTitle('FRA-HND') fireEvent.click(screen.getByRole('button', { name: 'reservations.layover.addStop' })) expect(airportInputs()).toHaveLength(3) fireEvent.click(screen.getByRole('button', { name: 'common.delete' })) expect(airportInputs()).toHaveLength(2) setValue(airportInputs()[0], 'FRA') setValue(airportInputs()[1], 'HND') await submit() expect(handleSaveTransport.mock.calls[0][0].metadata).not.toHaveProperty('legs') }) it('FE-MOB-TRFRM-011: a flight without any picked airport saves no metadata and no endpoints', async () => { const handleSaveTransport = makeSave() renderSheet(makePlanner({ handleSaveTransport })) typeTitle('Placeholder flight') await submit() const payload = handleSaveTransport.mock.calls[0][0] expect(payload.metadata).toBeNull() expect(payload.endpoints).toEqual([]) expect(payload.day_id).toBeNull() }) it('FE-MOB-TRFRM-012: saves a single-leg train with train number, platform and seat', async () => { const handleSaveTransport = makeSave() renderSheet(makePlanner({ handleSaveTransport })) fireEvent.click(screen.getByRole('button', { name: 'reservations.type.train' })) typeTitle('ICE 599') setValue(locationInputs()[0], 'osaka') setValue(locationInputs()[1], 'kyoto') setValue(daySelects()[0], '12') setValue(timeInputs()[0], '07:45') setValue(daySelects()[1], '12') setValue(timeInputs()[1], '09:12') setValue(screen.getByPlaceholderText('ICE 123'), 'ICE 599') setValue(screen.getByPlaceholderText('12'), '7') setValue(screen.getByPlaceholderText('42A'), '12C') await submit() const payload = handleSaveTransport.mock.calls[0][0] expect(payload.metadata).toEqual({ train_number: 'ICE 599', platform: '7', seat: '12C' }) expect(payload.day_id).toBe(12) expect(payload.end_day_id).toBe(12) expect(payload.reservation_time).toBe('2026-05-02T07:45') expect(payload.reservation_end_time).toBe('2026-05-02T09:12') expect(payload.endpoints).toEqual([ { role: 'from', sequence: 0, name: 'Osaka Station', code: null, lat: 34.7, lng: 135.5, timezone: null, local_date: '2026-05-02', local_time: '07:45' }, { role: 'to', sequence: 1, name: 'Kyoto Station', code: null, lat: 34.98, lng: 135.75, timezone: null, local_date: '2026-05-02', local_time: '09:12' }, ]) }) it('FE-MOB-TRFRM-013: a multi-stop train writes per-leg train numbers and stop endpoints', async () => { const handleSaveTransport = makeSave() renderSheet(makePlanner({ handleSaveTransport })) fireEvent.click(screen.getByRole('button', { name: 'reservations.type.train' })) typeTitle('Osaka via Nara') fireEvent.click(screen.getByRole('button', { name: 'reservations.layover.addStop' })) setValue(locationInputs()[0], 'osaka') setValue(locationInputs()[1], 'nara') setValue(locationInputs()[2], 'kyoto') setValue(daySelects()[0], '12') setValue(timeInputs()[0], '08:00') setValue(daySelects()[1], '12') setValue(timeInputs()[1], '08:40') setValue(daySelects()[2], '12') setValue(timeInputs()[2], '09:00') setValue(daySelects()[3], '12') setValue(timeInputs()[3], '09:35') const trainNumbers = screen.getAllByPlaceholderText('ICE 123') setValue(trainNumbers[0], 'JR 12') setValue(trainNumbers[1], 'JR 88') setValue(screen.getAllByPlaceholderText('12')[0], '3') await submit() const payload = handleSaveTransport.mock.calls[0][0] expect(payload.metadata).toEqual({ train_number: 'JR 12', platform: '3', legs: [ { from: 'Osaka Station', to: 'Nara Station', train_number: 'JR 12', platform: '3', dep_day_id: 12, dep_time: '08:00', arr_day_id: 12, arr_time: '08:40' }, { from: 'Nara Station', to: 'Kyoto Station', train_number: 'JR 88', dep_day_id: 12, dep_time: '09:00', arr_day_id: 12, arr_time: '09:35' }, ], }) expect(payload.endpoints).toEqual([ expect.objectContaining({ role: 'from', sequence: 0, name: 'Osaka Station' }), expect.objectContaining({ role: 'stop', sequence: 1, name: 'Nara Station' }), expect.objectContaining({ role: 'to', sequence: 2, name: 'Kyoto Station' }), ]) }) it('FE-MOB-TRFRM-046: a train row without a picked station no longer anchors the arrival', async () => { const handleSaveTransport = makeSave() renderSheet(makePlanner({ handleSaveTransport })) fireEvent.click(screen.getByRole('button', { name: 'reservations.type.train' })) typeTitle('Osaka departure') setValue(locationInputs()[0], 'osaka') setValue(daySelects()[0], '12') setValue(timeInputs()[0], '07:45') // The arrival row carries a day and a time but never gets a station. setValue(daySelects()[1], '13') setValue(timeInputs()[1], '09:12') await submit() const payload = handleSaveTransport.mock.calls[0][0] expect(payload.endpoints).toEqual([expect.objectContaining({ role: 'from', name: 'Osaka Station' })]) expect(payload.day_id).toBe(12) expect(payload.reservation_time).toBe('2026-05-02T07:45') expect(payload.end_day_id).toBeNull() expect(payload.reservation_end_time).toBeNull() }) it('FE-MOB-TRFRM-043: removing a train stop restores the two-station route', () => { renderSheet(makePlanner()) fireEvent.click(screen.getByRole('button', { name: 'reservations.type.train' })) fireEvent.click(screen.getAllByRole('button', { name: 'reservations.layover.addStop' })[0]) expect(locationInputs()).toHaveLength(3) fireEvent.click(screen.getByRole('button', { name: 'common.delete' })) expect(locationInputs()).toHaveLength(2) }) it('FE-MOB-TRFRM-044: seeds an edited train route from its ordered station endpoints', () => { const editingTransport = { id: 51, trip_id: 1, type: 'train', title: 'Osaka via Nara', status: 'pending', day_id: 12, end_day_id: 13, metadata: { train_number: 'JR 12', platform: '3', seat: '5B' }, endpoints: [ { id: 3, reservation_id: 51, role: 'to', sequence: 2, name: 'Kyoto Station', code: null, lat: 34.98, lng: 135.75, timezone: null, local_date: '2026-05-03', local_time: '09:35' }, { id: 1, reservation_id: 51, role: 'from', sequence: 0, name: 'Osaka Station', code: null, lat: 34.7, lng: 135.5, timezone: null, local_date: '2026-05-02', local_time: '08:00' }, { id: 2, reservation_id: 51, role: 'stop', sequence: 1, name: 'Nara Station', code: null, lat: 34.68, lng: 135.82, timezone: null, local_date: '2026-05-02', local_time: '08:40' }, ], } as unknown as Reservation renderSheet(makePlanner({ editingTransport })) expect(locationInputs().map(i => i.value)).toEqual(['Osaka Station', 'Nara Station', 'Kyoto Station']) // The stop's own local_time and local_date seed both its arrival and its departure — // without metadata.legs the endpoint is the only record of the stop's day. expect(timeInputs().map(i => i.value)).toEqual(['08:00', '08:40', '08:40', '09:35']) expect(daySelects().map(s => s.value)).toEqual(['12', '12', '12', '13']) expect(screen.getAllByPlaceholderText('ICE 123')[0]).toHaveValue('JR 12') expect(screen.getAllByPlaceholderText('42A')[0]).toHaveValue('5B') }) it('FE-MOB-TRFRM-045: picking a day in the automated tab reveals the transit search', () => { renderSheet(makePlanner({ transportModalAutomated: true })) expect(screen.getByText('transit.pickDay')).toBeInTheDocument() setValue(daySelects()[0], '13') expect(screen.queryByText('transit.pickDay')).not.toBeInTheDocument() expect(screen.getByTestId('transit-day')).toHaveTextContent('13') }) it('FE-MOB-TRFRM-014: reports a failing save through the toast', async () => { const planner = makePlanner({ handleSaveTransport: vi.fn(async () => { throw new Error('server said no') }) }) renderSheet(planner) fireEvent.click(screen.getByRole('button', { name: 'reservations.type.bus' })) typeTitle('Broken bus') await submit() expect(planner.toast.error).toHaveBeenCalledWith('server said no') expect(screen.getByRole('button', { name: 'common.add' })).toBeEnabled() }) it('FE-MOB-TRFRM-015: falls back to the generic error text for a non-Error rejection', async () => { const planner = makePlanner({ handleSaveTransport: vi.fn(async () => { throw 'nope' }) }) renderSheet(planner) typeTitle('Broken flight') await submit() expect(planner.toast.error).toHaveBeenCalledWith('common.unknownError') }) it('FE-MOB-TRFRM-016: disables and relabels the save button while the request is in flight', async () => { let release: (v: { id: number }) => void = () => {} const handleSaveTransport = vi.fn(() => new Promise<{ id: number }>(resolve => { release = resolve })) renderSheet(makePlanner({ handleSaveTransport })) typeTitle('Slow flight') fireEvent.click(screen.getByRole('button', { name: 'common.add' })) const saving = await screen.findByRole('button', { name: 'common.saving' }) expect(saving).toBeDisabled() await act(async () => { release({ id: 99 }) }) expect(screen.getByRole('button', { name: 'common.add' })).toBeInTheDocument() }) it('FE-MOB-TRFRM-017: closing resets every editor flag on the planner', () => { const planner = makePlanner() renderSheet(planner) fireEvent.click(screen.getByRole('button', { name: 'common.cancel' })) expect(planner.setShowTransportModal).toHaveBeenCalledWith(false) expect(planner.setEditingTransport).toHaveBeenCalledWith(null) expect(planner.setTransportModalDayId).toHaveBeenCalledWith(null) expect(planner.setTransportModalAutomated).toHaveBeenCalledWith(false) expect(planner.setTransitPrefill).toHaveBeenCalledWith(null) }) it('FE-MOB-TRFRM-018: during an import review the close button advances instead of resetting', () => { const planner = makePlanner({ importReviewActive: true }) renderSheet(planner) fireEvent.click(screen.getByRole('button', { name: 'common.close' })) expect(planner.advanceImportReview).toHaveBeenCalledTimes(1) expect(planner.setShowTransportModal).not.toHaveBeenCalled() }) it('FE-MOB-TRFRM-019: a successful save during an import review advances to the next item', async () => { const planner = makePlanner({ importReviewActive: true, handleSaveTransport: makeSave() }) renderSheet(planner) typeTitle('Imported flight') await submit() expect(planner.advanceImportReview).toHaveBeenCalledTimes(1) }) it('FE-MOB-TRFRM-020: a save that returns nothing does not advance the import review', async () => { const planner = makePlanner({ importReviewActive: true, handleSaveTransport: makeSave(null) }) renderSheet(planner) typeTitle('Imported flight') await submit() expect(planner.advanceImportReview).not.toHaveBeenCalled() }) it('FE-MOB-TRFRM-021: seeds the form from the edited flight including the day selects', () => { const editingTransport = { id: 42, trip_id: 1, type: 'flight', title: 'LH 716', status: 'confirmed', day_id: 11, end_day_id: 12, reservation_time: '2026-05-01T10:20', reservation_end_time: '2026-05-02T06:45', confirmation_number: 'ABC123', notes: 'Exit row', metadata: JSON.stringify({ airline: 'Lufthansa', flight_number: 'LH 716', seat: '31A' }), endpoints: [ { id: 1, reservation_id: 42, role: 'to', sequence: 1, name: 'Tokyo (HND)', code: 'HND', lat: 35.55, lng: 139.78, timezone: 'Asia/Tokyo', local_date: '2026-05-02', local_time: '06:45' }, { id: 2, reservation_id: 42, role: 'from', sequence: 0, name: 'Frankfurt (FRA)', code: 'FRA', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin', local_date: '2026-05-01', local_time: '10:20' }, ], } as unknown as Reservation renderSheet(makePlanner({ editingTransport })) expect(screen.getByRole('dialog', { name: 'transport.modalTitle.edit' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.update' })).toBeInTheDocument() expect(screen.getByPlaceholderText('reservations.titlePlaceholder')).toHaveValue('LH 716') expect(screen.getByPlaceholderText('reservations.confirmationPlaceholder')).toHaveValue('ABC123') expect(screen.getByPlaceholderText('reservations.notesPlaceholder')).toHaveValue('Exit row') expect(screen.getByRole('button', { name: 'reservations.confirmed' }).className).toContain('bg-m-act') // Endpoints are read in sequence order, not array order. expect(airportInputs().map(i => i.value)).toEqual(['FRA', 'HND']) expect(daySelects().map(s => s.value)).toEqual(['11', '12']) expect(timeInputs().map(i => i.value)).toEqual(['10:20', '06:45']) expect(screen.getByPlaceholderText('Lufthansa')).toHaveValue('Lufthansa') expect(screen.getByPlaceholderText('12A')).toHaveValue('31A') // No mode switch while editing. expect(screen.queryByRole('button', { name: 'transport.modeAutomated' })).not.toBeInTheDocument() }) it('FE-MOB-TRFRM-022: seeds a multi-leg flight from metadata.legs and keeps the day positions on re-save', async () => { const handleSaveTransport = makeSave() const editingTransport = { id: 43, trip_id: 1, type: 'flight', title: 'FRA-IST-HND', status: 'pending', day_id: 11, end_day_id: 12, metadata: { legs: [ { from: 'FRA', to: 'IST', airline: 'Lufthansa', flight_number: 'LH 1300', seat: '2A', dep_day_id: 11, dep_time: '10:20', arr_day_id: 11, arr_time: '14:05', day_positions: { '11': 3 } }, { from: 'IST', to: 'HND', airline: 'Turkish Airlines', flight_number: 'TK 46', dep_day_id: 11, dep_time: '16:30', arr_day_id: 12, arr_time: '09:00' }, ], }, endpoints: [ { id: 1, reservation_id: 43, role: 'from', sequence: 0, name: 'Frankfurt (FRA)', code: 'FRA', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin', local_date: null, local_time: null }, { id: 2, reservation_id: 43, role: 'stop', sequence: 1, name: 'Istanbul (IST)', code: 'IST', lat: 41.27, lng: 28.75, timezone: 'Europe/Istanbul', local_date: null, local_time: null }, { id: 3, reservation_id: 43, role: 'to', sequence: 2, name: 'Tokyo (HND)', code: 'HND', lat: 35.55, lng: 139.78, timezone: 'Asia/Tokyo', local_date: null, local_time: null }, ], } as unknown as Reservation renderSheet(makePlanner({ editingTransport, handleSaveTransport })) expect(airportInputs().map(i => i.value)).toEqual(['FRA', 'IST', 'HND']) expect(timeInputs().map(i => i.value)).toEqual(['10:20', '14:05', '16:30', '09:00']) await submit('common.update') const legs = (handleSaveTransport.mock.calls[0][0].metadata as { legs: Record[] }).legs expect(legs[0]).toMatchObject({ from: 'FRA', to: 'IST', day_positions: { '11': 3 } }) expect(legs[1]).not.toHaveProperty('day_positions') }) it('FE-MOB-TRFRM-047: each segment of a layover saves its own booking code (#1943)', async () => { const handleSaveTransport = makeSave() renderSheet(makePlanner({ handleSaveTransport })) typeTitle('FRA-IST-HND') // A direct flight writes no legs, so only the booking's own field is offered. expect(screen.getAllByPlaceholderText('reservations.confirmationPlaceholder')).toHaveLength(1) fireEvent.click(screen.getByRole('button', { name: 'reservations.layover.addStop' })) setValue(airportInputs()[0], 'FRA') setValue(airportInputs()[1], 'IST') setValue(airportInputs()[2], 'HND') const codes = screen.getAllByPlaceholderText('reservations.confirmationPlaceholder') expect(codes).toHaveLength(3) setValue(codes[0], 'ABC123') setValue(codes[1], 'XYZ789') setValue(codes[2], 'BOOK1') await submit() const payload = handleSaveTransport.mock.calls[0][0] const legs = (payload.metadata as { legs: Record[] }).legs expect(legs.map(l => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']) // The booking's own reference stays on its column, never on a segment. expect(payload.confirmation_number).toBe('BOOK1') expect(payload.metadata).not.toHaveProperty('confirmation_number') }) it('FE-MOB-TRFRM-048: a re-save on the phone keeps the stored segment codes (#1943)', async () => { const handleSaveTransport = makeSave() const editingTransport = { id: 45, trip_id: 1, type: 'flight', title: 'FRA-IST-HND', status: 'pending', day_id: 11, end_day_id: 12, confirmation_number: 'BOOK1', metadata: { legs: [ { from: 'FRA', to: 'IST', confirmation_number: 'ABC123', dep_day_id: 11, dep_time: '10:20', arr_day_id: 11, arr_time: '14:05' }, { from: 'IST', to: 'HND', confirmation_number: 'XYZ789', dep_day_id: 11, dep_time: '16:30', arr_day_id: 12, arr_time: '09:00' }, ], }, endpoints: [ { id: 1, reservation_id: 45, role: 'from', sequence: 0, name: 'Frankfurt (FRA)', code: 'FRA', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin', local_date: null, local_time: null }, { id: 2, reservation_id: 45, role: 'stop', sequence: 1, name: 'Istanbul (IST)', code: 'IST', lat: 41.27, lng: 28.75, timezone: 'Europe/Istanbul', local_date: null, local_time: null }, { id: 3, reservation_id: 45, role: 'to', sequence: 2, name: 'Tokyo (HND)', code: 'HND', lat: 35.55, lng: 139.78, timezone: 'Asia/Tokyo', local_date: null, local_time: null }, ], } as unknown as Reservation renderSheet(makePlanner({ editingTransport, handleSaveTransport })) const codes = screen.getAllByPlaceholderText('reservations.confirmationPlaceholder') as HTMLInputElement[] expect(codes.map(i => i.value)).toEqual(['ABC123', 'XYZ789', 'BOOK1']) await submit('common.update') const legs = (handleSaveTransport.mock.calls[0][0].metadata as { legs: Record[] }).legs expect(legs.map(l => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']) }) it('FE-MOB-TRFRM-023: an edited flight without endpoints falls back to the reservation times', () => { const editingTransport = { id: 44, trip_id: 1, type: 'flight', title: 'Legacy flight', status: 'pending', day_id: 11, end_day_id: 13, reservation_time: '2026-05-01T06:00', reservation_end_time: '2026-05-03T21:30', metadata: { airline: 'Air Legacy', flight_number: 'AL 1', seat: '9F' }, endpoints: [], } as unknown as Reservation renderSheet(makePlanner({ editingTransport })) expect(airportInputs().map(i => i.value)).toEqual(['', '']) expect(daySelects().map(s => s.value)).toEqual(['11', '13']) expect(timeInputs().map(i => i.value)).toEqual(['06:00', '21:30']) expect(screen.getByPlaceholderText('Lufthansa')).toHaveValue('Air Legacy') }) it('FE-MOB-TRFRM-024: an edited train without endpoints falls back to the flat metadata', () => { const editingTransport = { id: 45, trip_id: 1, type: 'train', title: 'Legacy train', status: 'pending', day_id: 12, reservation_time: '2026-05-02T07:45', reservation_end_time: '2026-05-02T09:12', metadata: { train_number: 'ICE 599', platform: '7', seat: '12C' }, endpoints: [], } as unknown as Reservation renderSheet(makePlanner({ editingTransport })) expect(screen.getByPlaceholderText('ICE 123')).toHaveValue('ICE 599') expect(screen.getByPlaceholderText('12')).toHaveValue('7') expect(screen.getByPlaceholderText('42A')).toHaveValue('12C') expect(timeInputs().map(i => i.value)).toEqual(['07:45', '09:12']) }) it('FE-MOB-TRFRM-025: an unknown booking type is edited as a flight', () => { const editingTransport = { id: 46, trip_id: 1, type: 'hotel', title: 'Wrong type', status: 'pending', endpoints: [], metadata: null, } as unknown as Reservation renderSheet(makePlanner({ editingTransport })) expect(screen.getByRole('button', { name: 'reservations.type.flight' })).toHaveAttribute('aria-pressed', 'true') }) it('FE-MOB-TRFRM-026: keeps a transit itinerary and its stops while the endpoints stay put', async () => { const handleSaveTransport = makeSave() const editingTransport = { id: 47, trip_id: 1, type: 'transit', title: 'U4 to Prater', status: 'pending', day_id: 12, reservation_time: '2026-05-02T09:05', reservation_end_time: '2026-05-02T09:30', metadata: { transit: { legs: [{ mode: 'SUBWAY', line: 'U4' }] }, airtrail_ids: [7, 8] }, endpoints: [ { id: 1, reservation_id: 47, role: 'from', sequence: 0, name: 'Osaka Station', code: null, lat: 34.7, lng: 135.5, timezone: null, local_date: '2026-05-02', local_time: '09:05' }, { id: 2, reservation_id: 47, role: 'stop', sequence: 1, name: 'Change', code: null, lat: 34.8, lng: 135.6, timezone: null, local_date: '2026-05-02', local_time: '09:15' }, { id: 3, reservation_id: 47, role: 'to', sequence: 2, name: 'Kyoto Station', code: null, lat: 34.98, lng: 135.75, timezone: null, local_date: '2026-05-02', local_time: '09:30' }, ], } as unknown as Reservation renderSheet(makePlanner({ editingTransport, handleSaveTransport })) expect(locationInputs().map(i => i.value)).toEqual(['Osaka Station', 'Kyoto Station']) await submit('common.update') const payload = handleSaveTransport.mock.calls[0][0] expect(payload.metadata).toEqual({ transit: { legs: [{ mode: 'SUBWAY', line: 'U4' }] }, airtrail_ids: [7, 8], }) expect(payload.endpoints).toEqual([ expect.objectContaining({ role: 'from', sequence: 0, name: 'Osaka Station' }), expect.objectContaining({ role: 'stop', sequence: 1, name: 'Change', local_time: '09:15' }), expect.objectContaining({ role: 'to', sequence: 2, name: 'Kyoto Station' }), ]) }) it('FE-MOB-TRFRM-027: changing an endpoint drops the stale transit itinerary', async () => { const handleSaveTransport = makeSave() const editingTransport = { id: 48, trip_id: 1, type: 'transit', title: 'U4 to Prater', status: 'pending', day_id: 12, metadata: { transit: { legs: [{ mode: 'SUBWAY', line: 'U4' }] } }, endpoints: [ { id: 1, reservation_id: 48, role: 'from', sequence: 0, name: 'Osaka Station', code: null, lat: 34.7, lng: 135.5, timezone: null, local_date: null, local_time: null }, { id: 2, reservation_id: 48, role: 'stop', sequence: 1, name: 'Change', code: null, lat: 34.8, lng: 135.6, timezone: null, local_date: null, local_time: null }, { id: 3, reservation_id: 48, role: 'to', sequence: 2, name: 'Kyoto Station', code: null, lat: 34.98, lng: 135.75, timezone: null, local_date: null, local_time: null }, ], } as unknown as Reservation renderSheet(makePlanner({ editingTransport, handleSaveTransport })) setValue(locationInputs()[1], 'nara') await submit('common.update') const payload = handleSaveTransport.mock.calls[0][0] expect(payload.metadata).toBeNull() expect(payload.endpoints).toEqual([ expect.objectContaining({ role: 'from', name: 'Osaka Station' }), expect.objectContaining({ role: 'to', sequence: 1, name: 'Nara Station' }), ]) }) it('FE-MOB-TRFRM-028: deleting asks for a second tap before it removes the booking', async () => { const editingTransport = { id: 49, trip_id: 1, type: 'bus', title: 'Bus', status: 'pending', endpoints: [], metadata: null, } as unknown as Reservation const planner = makePlanner({ editingTransport }) renderSheet(planner) fireEvent.click(screen.getByRole('button', { name: 'common.delete' })) expect(planner.toast.warning).toHaveBeenCalledWith('mobileTrip.tapAgainToDelete') expect(planner.handleDeleteReservation).not.toHaveBeenCalled() await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'common.delete' })) }) expect(planner.handleDeleteReservation).toHaveBeenCalledWith(49) expect(planner.setShowTransportModal).toHaveBeenCalledWith(false) }) it('FE-MOB-TRFRM-029: imports a parsed booking with its day, source file and parsed price', async () => { const handleSaveTransport = makeSave() const addFile = vi.fn(async (_tripId: number, _form: FormData) => undefined) const sourceFile = new File(['pdf'], 'voucher.pdf') const transportPrefill = { type: 'car', title: 'Rental car', status: 'pending', reservation_time: '2026-05-02T09:00', reservation_end_time: '2026-05-03T18:00', metadata: { price: 120 }, endpoints: [], _sourceFiles: [sourceFile], } const planner = makePlanner({ transportPrefill, handleSaveTransport, tripActions: { addFile }, }) renderSheet(planner) expect(screen.getByPlaceholderText('reservations.titlePlaceholder')).toHaveValue('Rental car') expect(screen.getByRole('button', { name: 'reservations.type.car' })).toHaveAttribute('aria-pressed', 'true') expect(daySelects().map(s => s.value)).toEqual(['12', '13']) expect(timeInputs().map(i => i.value)).toEqual(['09:00', '18:00']) expect(screen.getByRole('button', { name: 'drop-voucher.pdf' })).toBeInTheDocument() await submit() const payload = handleSaveTransport.mock.calls[0][0] expect(payload.create_budget_entry).toEqual({ total_price: 120, category: 'transport' }) expect(addFile).toHaveBeenCalledTimes(1) expect(addFile.mock.calls[0][0]).toBe(1) expect((addFile.mock.calls[0][1] as FormData).get('description')).toBe('Rental car') }) it('FE-MOB-TRFRM-029b: a parsed price keeps the currency it was quoted in (#2525)', async () => { const handleSaveTransport = makeSave() const planner = makePlanner({ transportPrefill: { type: 'train', title: 'Amtrak', metadata: { price: 117, priceCurrency: 'USD' }, endpoints: [] }, handleSaveTransport, }) renderSheet(planner) await submit() // Sent without it, the server stored 117 in the trip currency. expect(handleSaveTransport.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 117, category: 'transport', currency: 'USD' }) }) it('FE-MOB-TRFRM-030: a prefill without a price and with the budget addon off skips the cost entry', async () => { seedStore(useAddonStore, { addons: [] }) const handleSaveTransport = makeSave() const planner = makePlanner({ transportPrefill: { type: 'bus', title: 'Shuttle', metadata: { price: 0 }, endpoints: [] }, handleSaveTransport, }) renderSheet(planner) expect(screen.queryByRole('button', { name: 'Create expense' })).not.toBeInTheDocument() await submit() expect(handleSaveTransport.mock.calls[0][0]).not.toHaveProperty('create_budget_entry') }) it('FE-MOB-TRFRM-031: attaching and dropping files only uploads what is left', async () => { const handleSaveTransport = makeSave() const addFile = vi.fn(async (_tripId: number, _form: FormData) => undefined) renderSheet(makePlanner({ handleSaveTransport, tripActions: { addFile } })) typeTitle('Flight with docs') fireEvent.click(screen.getByRole('button', { name: 'attach-file' })) fireEvent.click(screen.getByRole('button', { name: 'attach-file' })) expect(screen.getAllByRole('button', { name: 'drop-extra.pdf' })).toHaveLength(2) fireEvent.click(screen.getAllByRole('button', { name: 'drop-extra.pdf' })[0]) await submit() expect(addFile).toHaveBeenCalledTimes(1) }) it('FE-MOB-TRFRM-032: hides the file row without upload permission', () => { renderSheet(makePlanner({ canUploadFiles: false })) expect(screen.queryByRole('button', { name: 'attach-file' })).not.toBeInTheDocument() }) it('FE-MOB-TRFRM-033: assigns travelers and persists them once the booking has an id', async () => { const setReservationTravelers = vi.fn(async (_tripId: number, _resId: number, _ids: number[]) => undefined) seedStore(useTripStore, { setReservationTravelers }) const handleSaveTransport = makeSave() renderSheet(makePlanner({ handleSaveTransport, tripMembers: MEMBERS })) typeTitle('Shared flight') fireEvent.click(screen.getByRole('button', { name: /maurice/ })) fireEvent.click(screen.getByRole('button', { name: /julien/ })) fireEvent.click(screen.getByRole('button', { name: /maurice/ })) await submit() expect(setReservationTravelers).toHaveBeenCalledWith(1, 99, [5]) }) it('FE-MOB-TRFRM-034: leaves the traveler list alone when nothing changed', async () => { const setReservationTravelers = vi.fn(async (_tripId: number, _resId: number, _ids: number[]) => undefined) seedStore(useTripStore, { setReservationTravelers }) const editingTransport = { id: 50, trip_id: 1, type: 'bus', title: 'Bus', status: 'pending', endpoints: [], metadata: null, travelers: [{ user_id: 4, username: 'maurice' }], } as unknown as Reservation renderSheet(makePlanner({ editingTransport, tripMembers: MEMBERS, handleSaveTransport: makeSave(null) })) expect(screen.getByRole('button', { name: /maurice/ }).className).not.toContain('opacity-60') await submit('common.update') expect(setReservationTravelers).not.toHaveBeenCalled() }) it('FE-MOB-TRFRM-035: shows the empty hint when the trip has no members', () => { renderSheet(makePlanner({ tripMembers: [] })) expect(screen.getByText('reservations.travelers.none')).toBeInTheDocument() }) it('FE-MOB-TRFRM-036: creating an expense saves first and hands the new id to the cost sheet', async () => { const handleSaveTransport = makeSave() const onOpenExpense = vi.fn() renderSheet(makePlanner({ handleSaveTransport }), onOpenExpense) fireEvent.click(screen.getByRole('button', { name: 'reservations.type.ferry' })) typeTitle('Ferry ticket') await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Create expense' })) }) expect(handleSaveTransport).toHaveBeenCalledTimes(1) expect(onOpenExpense).toHaveBeenCalledWith({ prefill: { reservationId: 99, name: 'Ferry ticket', category: 'transport' }, }) }) it('FE-MOB-TRFRM-037: a plain save does not open the cost sheet', async () => { const onOpenExpense = vi.fn() renderSheet(makePlanner({ handleSaveTransport: makeSave() }), onOpenExpense) typeTitle('No costs please') await submit() expect(onOpenExpense).not.toHaveBeenCalled() }) it('FE-MOB-TRFRM-038: the automated tab asks for a day before it searches', () => { renderSheet(makePlanner({ transportModalAutomated: true })) expect(screen.getByRole('dialog', { name: 'transit.title' })).toBeInTheDocument() expect(screen.getByText('transit.pickDay')).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'common.add' })).not.toBeInTheDocument() expect(screen.getByRole('button', { name: 'common.cancel' })).toBeInTheDocument() }) it('FE-MOB-TRFRM-039: the transit panel gets the chosen day, its ordered places and the prefill', () => { const planner = makePlanner({ transportModalAutomated: true, transportModalDayId: 12, transitPrefill: { from: { name: 'Hotel', lat: 34.7, lng: 135.5 }, to: null, time: '08:15' }, places: [{ id: 1, name: 'Fushimi Inari' }, { id: 2, name: 'Nishiki Market' }], assignments: { '12': [{ id: 9, place_id: 2, order_index: 1 }, { id: 8, place_id: 1, order_index: 0 }] }, }) renderSheet(planner) expect(screen.getByTestId('transit-day')).toHaveTextContent('12') expect(screen.getByTestId('transit-places')).toHaveTextContent('Fushimi Inari,Nishiki Market') expect(screen.getByTestId('transit-from')).toHaveTextContent('Hotel') // A leg picked from the timeline's connector menu carries its departure (#2398). expect(screen.getByTestId('transit-time')).toHaveTextContent('08:15') }) it('FE-MOB-TRFRM-040: the transit panel saves its itinerary through the shared save path', async () => { const handleSaveTransport = makeSave() renderSheet(makePlanner({ transportModalAutomated: true, transportModalDayId: 12, handleSaveTransport })) await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'add-itinerary' })) }) expect(handleSaveTransport).toHaveBeenCalledWith({ title: 'U4 to Prater', type: 'transit' }) }) it('FE-MOB-TRFRM-041: switching between manual and automated swaps the form', () => { renderSheet(makePlanner()) expect(screen.getByText('reservations.bookingType')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'transport.modeAutomated' })) expect(screen.queryByText('reservations.bookingType')).not.toBeInTheDocument() expect(screen.getByText('transit.searchHint')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'transport.modeManual' })) expect(screen.getByText('reservations.bookingType')).toBeInTheDocument() }) it('FE-MOB-TRFRM-042: a new booking opened from a day preselects that day', () => { renderSheet(makePlanner({ transportModalDayId: 13 })) fireEvent.click(screen.getByRole('button', { name: 'reservations.type.bus' })) expect(daySelects().map(s => s.value)).toEqual(['13', '13']) }) // ── Blur booking codes in the edit sheet (#2457) ─────────────────────────── describe('blur booking codes (#2457)', () => { /** Both sources the phone reads the preference from: the settings store and planner.settings. */ function plannerWithBlur(on: boolean, overrides: Record = {}) { useSettingsStore.setState({ settings: { ...useSettingsStore.getState().settings, blur_booking_codes: on } }) return makePlanner({ settings: { time_format: '24h', date_format: 'DD.MM.YYYY', default_currency: 'EUR', distance_unit: 'km', blur_booking_codes: on }, ...overrides, }) } const codeFields = () => screen.getAllByPlaceholderText('reservations.confirmationPlaceholder') as HTMLInputElement[] const layover = () => ({ id: 45, trip_id: 1, type: 'flight', title: 'FRA-IST-HND', status: 'pending', day_id: 11, end_day_id: 12, confirmation_number: 'BOOK1', metadata: { legs: [ { from: 'FRA', to: 'IST', confirmation_number: 'ABC123', dep_day_id: 11, dep_time: '10:20', arr_day_id: 11, arr_time: '14:05' }, { from: 'IST', to: 'HND', confirmation_number: 'XYZ789', dep_day_id: 11, dep_time: '16:30', arr_day_id: 12, arr_time: '09:00' }, ], }, endpoints: [ { id: 1, reservation_id: 45, role: 'from', sequence: 0, name: 'Frankfurt (FRA)', code: 'FRA', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin', local_date: null, local_time: null }, { id: 2, reservation_id: 45, role: 'stop', sequence: 1, name: 'Istanbul (IST)', code: 'IST', lat: 41.27, lng: 28.75, timezone: 'Europe/Istanbul', local_date: null, local_time: null }, { id: 3, reservation_id: 45, role: 'to', sequence: 2, name: 'Tokyo (HND)', code: 'HND', lat: 35.55, lng: 139.78, timezone: 'Asia/Tokyo', local_date: null, local_time: null }, ], } as unknown as Reservation) it('FE-MOB-TRFRM-049: the booking code and every segment code are blurred while the setting is on', () => { renderSheet(plannerWithBlur(true, { editingTransport: layover() })) const codes = codeFields() expect(codes.map(i => i.value)).toEqual(['ABC123', 'XYZ789', 'BOOK1']) expect(codes.map(i => isBlurred(i))).toEqual([true, true, true]) }) it('FE-MOB-TRFRM-050: focusing a code field reveals it for editing, leaving it hides it again', () => { renderSheet(plannerWithBlur(true, { editingTransport: layover() })) const field = codeFields()[2] expect(isBlurred(field)).toBe(true) act(() => field.focus()) expect(isBlurred(field)).toBe(false) act(() => field.blur()) expect(isBlurred(field)).toBe(true) }) it('FE-MOB-TRFRM-051: with the setting off every code field stays plain', () => { renderSheet(plannerWithBlur(false, { editingTransport: layover() })) expect(codeFields().map(i => isBlurred(i))).toEqual([false, false, false]) }) it('FE-MOB-TRFRM-052: blurred codes still save unchanged', async () => { const handleSaveTransport = makeSave() renderSheet(plannerWithBlur(true, { editingTransport: layover(), handleSaveTransport })) await submit('common.update') const payload = handleSaveTransport.mock.calls[0][0] const legs = (payload.metadata as { legs: Record[] }).legs expect(legs.map(l => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']) expect(payload.confirmation_number).toBe('BOOK1') }) }) it('FE-MOB-TRFRM-053: the manual From and To fields offer the trip places that have a location, each once (#2468)', () => { renderSheet(makePlanner({ places: [ { id: 1, name: 'Fushimi Inari', lat: 34.97, lng: 135.77 }, { id: 2, name: 'No pin yet', lat: null, lng: null }, { id: 3, name: 'Fushimi Inari', lat: 34.97, lng: 135.77 }, ], })) fireEvent.click(screen.getByRole('button', { name: 'reservations.type.bus' })) const fields = locationInputs() expect(fields).toHaveLength(2) for (const field of fields) expect(field).toHaveAttribute('data-picks', 'Fushimi Inari') }) // ── Files and costs of a saved transport (#2084) ─────────────────────────── const SHINKANSEN = { id: 61, trip_id: 1, type: 'train', title: 'Shinkansen', status: 'confirmed', day_id: 12, metadata: {}, endpoints: [], } as unknown as Reservation const TRIP_FILES = [ { id: 7, trip_id: 1, reservation_id: 61, original_name: 'eticket.pdf', url: '/uploads/eticket.pdf', deleted_at: null }, { id: 8, trip_id: 1, original_name: 'railpass.pdf', url: '/uploads/railpass.pdf', deleted_at: null }, ] it('FE-MOB-TRFRM-054: an edited transport lists its files and links another trip file to itself', async () => { seedStore(useAuthStore, { user: buildUser({ id: 1 }) }) seedStore(useTripStore, { trip: { id: 1, user_id: 1, currency: 'EUR' } as never, files: TRIP_FILES as never }) let body: unknown = null server.use( http.post('/api/trips/1/files/8/link', async ({ request }) => { body = await request.json() return HttpResponse.json({ success: true }) }), http.get('/api/trips/1/files', () => HttpResponse.json({ files: TRIP_FILES })), ) renderSheet(makePlanner({ editingTransport: SHINKANSEN })) expect(screen.getByText('attached-eticket.pdf')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: 'link-railpass.pdf' })) await waitFor(() => expect(body).toEqual({ reservation_id: 61 })) }) it('FE-MOB-TRFRM-055: without upload permission the files of a transport are still shown, without the picker', () => { seedStore(useTripStore, { files: TRIP_FILES as never }) renderSheet(makePlanner({ editingTransport: SHINKANSEN, canUploadFiles: false })) expect(screen.getByText('attached-eticket.pdf')).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'attach-file' })).not.toBeInTheDocument() // Nobody is signed in here, so linking is not offered either. expect(screen.queryByRole('button', { name: 'link-railpass.pdf' })).not.toBeInTheDocument() }) it('FE-MOB-TRFRM-056: a linked expense of the transport opens in the cost sheet for editing', () => { const fare = buildBudgetItem({ id: 90, trip_id: 1, name: 'Train fare', total_price: 140, reservation_id: 61 }) seedStore(useTripStore, { trip: { id: 1, currency: 'EUR' } as never, budgetItems: [fare] }) const onOpenExpense = vi.fn() renderSheet(makePlanner({ editingTransport: SHINKANSEN }), onOpenExpense) fireEvent.click(screen.getByText('Train fare')) expect(onOpenExpense).toHaveBeenCalledWith({ editItem: fare }) }) it('FE-MOB-TRFRM-057: a new transport has nothing to link its costs to yet', () => { seedStore(useTripStore, { trip: { id: 1, currency: 'EUR' } as never, budgetItems: [buildBudgetItem({ id: 91, trip_id: 1, name: 'Loose' })] }) renderSheet(makePlanner()) expect(screen.getByRole('button', { name: 'Create expense' })).toBeInTheDocument() expect(screen.queryByRole('button', { name: 'Link' })).not.toBeInTheDocument() }) it('FE-MOB-TRFRM-058: files attached while editing a transport are uploaded against it (#2534)', async () => { const handleSaveTransport = makeSave(SHINKANSEN) const addFile = vi.fn(async (_tripId: number, _form: FormData) => undefined) renderSheet(makePlanner({ editingTransport: SHINKANSEN, handleSaveTransport, tripActions: { addFile } })) fireEvent.click(screen.getByRole('button', { name: 'attach-file' })) await submit('common.update') expect(addFile).toHaveBeenCalledTimes(1) const fd = addFile.mock.calls[0][1] as FormData expect(fd.get('reservation_id')).toBe('61') expect(fd.get('description')).toBe('Shinkansen') expect((fd.get('file') as File).name).toBe('extra.pdf') }) it('FE-MOB-TRFRM-059: a failed transport edit keeps the attached files off the server', async () => { const handleSaveTransport = vi.fn(async (_payload: SavePayload) => undefined) const addFile = vi.fn(async (_tripId: number, _form: FormData) => undefined) renderSheet(makePlanner({ editingTransport: SHINKANSEN, handleSaveTransport, tripActions: { addFile } })) fireEvent.click(screen.getByRole('button', { name: 'attach-file' })) await submit('common.update') expect(handleSaveTransport).toHaveBeenCalled() expect(addFile).not.toHaveBeenCalled() }) })