// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-021 import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; import { useAuthStore } from '../../store/authStore'; import { useTripStore } from '../../store/tripStore'; import { useAddonStore } from '../../store/addonStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildTrip, buildDay, buildReservation, buildTripFile, } from '../../../tests/helpers/factories'; import { TransportModal } from './TransportModal'; vi.mock('react-router-dom', async (importActual) => { const actual = await importActual(); return { ...actual, useParams: () => ({ id: '1' }) }; }); vi.mock('../shared/CustomTimePicker', () => ({ default: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => ( onChange(e.target.value)} /> ), })); vi.mock('./AirportSelect', () => ({ default: ({ onChange }: { onChange: (a: any) => void }) => ( onChange({ iata: e.target.value, name: e.target.value, city: '', country: '', lat: 0, lng: 0, tz: 'UTC', icao: null })} /> ), })); vi.mock('./LocationSelect', () => ({ default: ({ onChange }: { onChange: (l: any) => void }) => ( onChange({ name: e.target.value, lat: 0, lng: 0, address: null })} /> ), })); const defaultProps = { isOpen: true, onClose: vi.fn(), onSave: vi.fn().mockResolvedValue(undefined), reservation: null, days: [], selectedDayId: null, files: [], onFileUpload: vi.fn().mockResolvedValue(undefined), onFileDelete: vi.fn().mockResolvedValue(undefined), }; beforeEach(() => { resetAllStores(); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); seedStore(useTripStore, { trip: buildTrip({ id: 1 }), budgetItems: [] }); vi.clearAllMocks(); }); describe('TransportModal', () => { // ── Rendering ────────────────────────────────────────────────────────────── it('FE-PLANNER-TRANSMODAL-001: renders without crashing', () => { render(); expect(document.body).toBeInTheDocument(); }); it('FE-PLANNER-TRANSMODAL-002: shows "Add transport" title for new transport', () => { render(); expect(screen.getByText(/Add transport/i)).toBeInTheDocument(); }); it('FE-PLANNER-TRANSMODAL-003: shows "Edit transport" title when editing', () => { const res = buildReservation({ title: 'Paris Flight', type: 'flight' }); render(); expect(screen.getByText(/Edit transport/i)).toBeInTheDocument(); }); it('FE-PLANNER-TRANSMODAL-004: title input is required — onSave not called with empty title', async () => { const onSave = vi.fn().mockResolvedValue(undefined); render(); await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); expect(onSave).not.toHaveBeenCalled(); }); it('FE-PLANNER-TRANSMODAL-005: all 4 transport type buttons are visible', () => { render(); expect(screen.getByRole('button', { name: /^Flight$/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /^Train$/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /^Car$/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /^Cruise$/i })).toBeInTheDocument(); }); it('FE-PLANNER-TRANSMODAL-006: editing pre-fills title', () => { const res = buildReservation({ title: 'LH123 Frankfurt', type: 'flight' }); render(); expect(screen.getByDisplayValue('LH123 Frankfurt')).toBeInTheDocument(); }); it('FE-PLANNER-TRANSMODAL-007: edit mode save button shows "Update"', () => { const res = buildReservation({ title: 'My Train', type: 'train' }); render(); expect(screen.getByRole('button', { name: /^Update$/i })).toBeInTheDocument(); }); it('FE-PLANNER-TRANSMODAL-008: Cancel button calls onClose', async () => { const onClose = vi.fn(); render(); await userEvent.click(screen.getByRole('button', { name: /Cancel/i })); expect(onClose).toHaveBeenCalled(); }); it('FE-PLANNER-TRANSMODAL-009: submitting valid flight calls onSave with correct type', async () => { const onSave = vi.fn().mockResolvedValue(undefined); render(); await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH456'); await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ title: 'LH456', type: 'flight' })); }); it('FE-PLANNER-TRANSMODAL-010: switching to train type calls onSave with train type', async () => { const onSave = vi.fn().mockResolvedValue(undefined); render(); await userEvent.click(screen.getByRole('button', { name: /^Train$/i })); await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Eurostar'); await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'train' })); }); // ── Budget addon ───────────────────────────────────────────────────────────── it('FE-PLANNER-TRANSMODAL-011: costs section (create expense) visible when budget addon is enabled', () => { seedStore(useAddonStore, { addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }], loaded: true, }); render(); expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument(); }); it('FE-PLANNER-TRANSMODAL-012: costs section not shown when budget addon is disabled', () => { render(); expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument(); }); it('FE-PLANNER-TRANSMODAL-013: create-expense saves the booking (no create_budget_entry) then opens the Costs editor', async () => { seedStore(useAddonStore, { addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }], loaded: true, }); const onSave = vi.fn().mockResolvedValue({ id: 42 }); const onOpenExpense = vi.fn(); render(); await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE Train'); await userEvent.click(screen.getByRole('button', { name: /Create expense/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); // The legacy auto-budget mechanism is gone; the expense is created via the editor instead. expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ create_budget_entry: expect.anything() })); await waitFor(() => expect(onOpenExpense).toHaveBeenCalledWith( expect.objectContaining({ prefill: expect.objectContaining({ reservationId: 42 }) }) ) ); }); // ── File attachment ─────────────────────────────────────────────────────────── it('FE-PLANNER-TRANSMODAL-014: attach file button rendered when onFileUpload provided', () => { render(); expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument(); }); it('FE-PLANNER-TRANSMODAL-015: attach file button absent when onFileUpload is undefined', () => { render(); expect(screen.queryByRole('button', { name: /Attach file/i })).not.toBeInTheDocument(); }); it('FE-PLANNER-TRANSMODAL-016: attached files shown for existing transport', () => { const res = buildReservation({ id: 5, type: 'flight' }); const file = buildTripFile({ id: 1, trip_id: 1, original_name: 'boarding-pass.pdf' }); (file as any).reservation_id = 5; render(); expect(screen.getByText('boarding-pass.pdf')).toBeInTheDocument(); }); it('FE-PLANNER-TRANSMODAL-017: pending file added for new transport on file input change', async () => { render(); const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; const testFile = new File(['content'], 'itinerary.pdf', { type: 'application/pdf' }); fireEvent.change(fileInput, { target: { files: [testFile] } }); await waitFor(() => expect(screen.getByText('itinerary.pdf')).toBeInTheDocument()); }); it('FE-PLANNER-TRANSMODAL-018: file upload to existing transport calls onFileUpload with correct FormData', async () => { const onFileUpload = vi.fn().mockResolvedValue(undefined); const res = buildReservation({ id: 10, type: 'train', title: 'Eurostar' }); render(); const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; const testFile = new File(['content'], 'ticket.pdf', { type: 'application/pdf' }); fireEvent.change(fileInput, { target: { files: [testFile] } }); await waitFor(() => expect(onFileUpload).toHaveBeenCalled()); const [fd] = onFileUpload.mock.calls[0] as [FormData]; expect(fd.get('file')).toBeTruthy(); expect(fd.get('reservation_id')).toBe('10'); }); it('FE-PLANNER-TRANSMODAL-019: link existing file button appears when unattached files exist', () => { const res = buildReservation({ id: 5, type: 'flight' }); const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' }); render(); expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument(); }); it('FE-PLANNER-TRANSMODAL-020: clicking "link existing file" shows file picker dropdown', async () => { const res = buildReservation({ id: 5, type: 'flight' }); const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' }); render(); await userEvent.click(screen.getByRole('button', { name: /Link existing file/i })); expect(screen.getByText('invoice.pdf')).toBeInTheDocument(); }); it('FE-PLANNER-TRANSMODAL-021: clicking file in picker links it and closes picker', async () => { server.use( http.post('/api/trips/1/files/99/link', () => HttpResponse.json({ success: true })), http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })), ); const res = buildReservation({ id: 5, type: 'flight' }); const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' }); render(); await userEvent.click(screen.getByRole('button', { name: /Link existing file/i })); await userEvent.click(screen.getByText('invoice.pdf')); await waitFor(() => { expect(screen.queryByRole('button', { name: /Link existing file/i })).not.toBeInTheDocument(); }); }); it('FE-PLANNER-TRANSMODAL-022: removing pending file removes it from list', async () => { render(); const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; const testFile = new File(['content'], 'draft.pdf', { type: 'application/pdf' }); fireEvent.change(fileInput, { target: { files: [testFile] } }); await waitFor(() => expect(screen.getByText('draft.pdf')).toBeInTheDocument()); const pendingFileRow = screen.getByText('draft.pdf').closest('div')!; const removeBtn = pendingFileRow.querySelector('button')!; await userEvent.click(removeBtn); await waitFor(() => expect(screen.queryByText('draft.pdf')).not.toBeInTheDocument()); }); it('FE-PLANNER-TRANSMODAL-023: clicking attach file button triggers file input click', async () => { render(); const attachBtn = screen.getByRole('button', { name: /Attach file/i }); const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {}); await userEvent.click(attachBtn); expect(clickSpy).toHaveBeenCalled(); clickSpy.mockRestore(); }); it('FE-PLANNER-TRANSMODAL-024: unlinking a linked file removes it from attached list', async () => { server.use( http.post('/api/trips/1/files/42/link', () => HttpResponse.json({ success: true })), http.get('/api/trips/1/files/42/links', () => HttpResponse.json({ links: [{ id: 1, reservation_id: 7 }] })), http.delete('/api/trips/1/files/42/link/1', () => HttpResponse.json({ success: true })), http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })), ); const res = buildReservation({ id: 7, type: 'car' }); const looseFile = buildTripFile({ id: 42, original_name: 'rental-agreement.pdf' }); render(); await userEvent.click(screen.getByRole('button', { name: /Link existing file/i })); await waitFor(() => expect(screen.getByText('rental-agreement.pdf')).toBeInTheDocument()); await userEvent.click(screen.getByText('rental-agreement.pdf')); await waitFor(() => expect(screen.queryByRole('button', { name: /Link existing file/i })).not.toBeInTheDocument() ); const fileRow = screen.getByText('rental-agreement.pdf').closest('div')!; const unlinkBtn = fileRow.querySelector('button[type="button"]')!; await userEvent.click(unlinkBtn); await waitFor(() => { expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument(); }); }); it('FE-PLANNER-TRANSMODAL-025: pending files flushed after saving new transport', async () => { const savedReservation = buildReservation({ id: 99, type: 'flight' }); const onSave = vi.fn().mockResolvedValue(savedReservation); const onFileUpload = vi.fn().mockResolvedValue(undefined); render(); const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; const testFile = new File(['content'], 'boarding.pdf', { type: 'application/pdf' }); fireEvent.change(fileInput, { target: { files: [testFile] } }); await waitFor(() => expect(screen.getByText('boarding.pdf')).toBeInTheDocument()); await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH001'); await userEvent.click(screen.getByRole('button', { name: /^Add$/i })); await waitFor(() => expect(onFileUpload).toHaveBeenCalled()); const [fd] = onFileUpload.mock.calls[0] as [FormData]; expect(fd.get('reservation_id')).toBe('99'); expect(fd.get('file')).toBeTruthy(); }); });