mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
9f0caf8539
"Public transit" on the connector between two city walks in the day plan, on the desktop and on the phone, now opens the existing search from where the first walk really ends to where the next one starts, at the coordinates the day's route draws, named after the first walk's last stop and the next walk's first stop. A loop ends at its first stop again. A stop without a name takes the name of the trip place it stands for, and a walk whose stops cannot be read on this device names that end "End of" or "Start of" the walk. The search opens on the walks' day and departs when the first walk ends: its start that day, the time on the day's entry before the place's own, plus its planned total, a total entered by hand before walking and stays. The assignment's own end time is not used. Above the search a note says when the first walk ends, "at the earliest" where a stay or the walking time is not known yet, and when the next walk starts. Without a start or a total no time is made up: the time field stays empty and Search waits until one is chosen. A walk that only ends after midnight says so, and the time field stays empty as well. On any other day, or for any other leg, the search opens as before, with 09:00 as its default. A connection that arrives after the next walk starts says so in its card, before its legs are opened and before it is taken. Nothing is saved until "Add to day", which keeps the connection as its own journey in the plan as before; both walks stay two walks on foot and nothing is moved. A journey saved before a walk that now arrives after the walk starts says so on its row in the plan, on the desktop and the phone, while the plan is looked at. Nothing is rewritten. The desktop no longer draws a connector of nothing between a stop and a booking at the same spot, as the phone already did, so a journey saved at the walks' ends adds no walk on either side. A leg leaving a loop tour no longer departs at the tour's start time. The leg is told by the walk it leaves, so two walks of a day that end at one spot, such as two loops from the hotel, each search from their own end and time, and a stop between two walks keeps the leg an ordinary one. Only the leg asked for last opens the search, and none opens once the plan is left while the walks' stops are still being read. Both views run on one shared hook, the walk's stops are read through one helper the stop list uses as well, and the walk's start on a day is worked out in one place for the times shown in the detail and here.
1697 lines
94 KiB
TypeScript
1697 lines
94 KiB
TypeScript
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-082
|
|
import { render, screen, waitFor, fireEvent, within, act } 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 { useSettingsStore } from '../../store/settingsStore';
|
|
import { isBlurred } from '../../../tests/helpers/bookingCodeBlur';
|
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
|
import {
|
|
buildUser,
|
|
buildTrip,
|
|
buildDay,
|
|
buildPlace,
|
|
buildAssignment,
|
|
buildReservation,
|
|
buildTripFile,
|
|
} from '../../../tests/helpers/factories';
|
|
import { TransportModal } from './TransportModal';
|
|
import type { Day, Reservation } from '../../types';
|
|
import type { BookingReviewDraft } from './parsedItemToDraft';
|
|
import type { TripMember } from '../Budget/BudgetPanelMemberChips';
|
|
|
|
vi.mock('react-router', async (importActual) => {
|
|
const actual = await importActual<typeof import('react-router')>();
|
|
return { ...actual, useParams: () => ({ id: '1' }) };
|
|
});
|
|
|
|
vi.mock('../shared/CustomTimePicker', () => ({
|
|
default: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
|
|
<input data-testid="time-picker" type="text" value={value} onChange={e => onChange(e.target.value)} />
|
|
),
|
|
}));
|
|
|
|
vi.mock('./AirportSelect', () => ({
|
|
default: ({ onChange }: { onChange: (a: any) => void }) => (
|
|
<input data-testid="airport-select" type="text" onChange={e => onChange({ iata: e.target.value, name: e.target.value, city: '', country: '', lat: 0, lng: 0, tz: 'UTC', icao: null })} />
|
|
),
|
|
}));
|
|
|
|
vi.mock('./LocationSelect', () => ({
|
|
default: ({ onChange, places }: { onChange: (l: any) => void; places?: { name: string }[] }) => (
|
|
<input
|
|
data-testid="location-select"
|
|
data-picks={(places ?? []).map(p => p.name).join('|')}
|
|
type="text"
|
|
onChange={e => 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();
|
|
});
|
|
|
|
// The booking type is a pill in the dialog's head band (PillSelect), named
|
|
// "Booking Type: <type>": open it, then pick the option from its menu.
|
|
function typeField(): HTMLButtonElement {
|
|
return screen.getByRole('button', { name: /^Booking Type:/ }) as HTMLButtonElement;
|
|
}
|
|
async function pickType(name: RegExp) {
|
|
await userEvent.click(typeField());
|
|
// The menu is portaled after the dialog, so its option is the last match.
|
|
const options = screen.getAllByRole('button', { name });
|
|
await userEvent.click(options[options.length - 1]);
|
|
}
|
|
// The travelers sit in a dropdown field too; the member rows exist once it is open.
|
|
async function openTravelers() {
|
|
await userEvent.click(screen.getByRole('button', { name: /Assign travelers|alice|bob/i, expanded: false }));
|
|
return screen.getByRole('listbox');
|
|
}
|
|
|
|
describe('TransportModal', () => {
|
|
// ── Rendering ──────────────────────────────────────────────────────────────
|
|
|
|
it('FE-PLANNER-TRANSMODAL-001: renders without crashing', () => {
|
|
render(<TransportModal {...defaultProps} />);
|
|
expect(document.body).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-002: shows "Add transport" title for new transport', () => {
|
|
render(<TransportModal {...defaultProps} reservation={null} />);
|
|
expect(screen.getByText(/Add transport/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-002b: file input accepts pkpass (#1448)', () => {
|
|
render(<TransportModal {...defaultProps} />);
|
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
expect(fileInput.accept).toContain('.pkpass');
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-003: shows "Edit transport" title when editing', () => {
|
|
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
|
|
render(<TransportModal {...defaultProps} reservation={res} />);
|
|
expect(screen.getByText(/Edit transport/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-003b: shows a delete action when editing with onDelete', () => {
|
|
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
|
|
render(<TransportModal {...defaultProps} reservation={res} onDelete={vi.fn()} />);
|
|
expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-003c: no delete action when creating, or when onDelete is omitted', () => {
|
|
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
|
|
const { rerender } = render(<TransportModal {...defaultProps} reservation={null} onDelete={vi.fn()} />);
|
|
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
|
|
rerender(<TransportModal {...defaultProps} reservation={res} />);
|
|
expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-003d: confirming delete calls onDelete then onClose', async () => {
|
|
const onDelete = vi.fn().mockResolvedValue(undefined);
|
|
const onClose = vi.fn();
|
|
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
|
|
render(<TransportModal {...defaultProps} reservation={res} onDelete={onDelete} onClose={onClose} />);
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: /delete/i }));
|
|
expect(screen.getByText('Delete booking?')).toBeInTheDocument();
|
|
|
|
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
|
|
await userEvent.click(deleteButtons[deleteButtons.length - 1]);
|
|
|
|
await waitFor(() => expect(onDelete).toHaveBeenCalledTimes(1));
|
|
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-004: title input is required — onSave not called with empty title', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
expect(onSave).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-005: the type dropdown offers every transport type', async () => {
|
|
render(<TransportModal {...defaultProps} />);
|
|
// A new transport starts as a flight; the options only exist once the menu is open.
|
|
expect(typeField()).toHaveTextContent('Flight');
|
|
expect(screen.queryByRole('button', { name: /^Train$/i })).not.toBeInTheDocument();
|
|
await userEvent.click(typeField());
|
|
for (const name of [/^Train$/i, /^Bus$/i, /^Car$/i, /^Taxi$/i, /^Bicycle$/i, /^Cruise$/i, /^Ferry$/i, /^Other$/i]) {
|
|
expect(screen.getByRole('button', { name })).toBeInTheDocument();
|
|
}
|
|
// The field itself and its option both read "Flight".
|
|
expect(screen.getAllByRole('button', { name: /^(Booking Type: ?)?Flight$/i })).toHaveLength(2);
|
|
// Booking types stay in the booking dialog.
|
|
expect(screen.queryByRole('button', { name: /^Accommodation$/i })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-006: editing pre-fills title', () => {
|
|
const res = buildReservation({ title: 'LH123 Frankfurt', type: 'flight' });
|
|
render(<TransportModal {...defaultProps} reservation={res} />);
|
|
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(<TransportModal {...defaultProps} reservation={res} />);
|
|
expect(screen.getByRole('button', { name: /^Update$/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-008: Cancel button calls onClose', async () => {
|
|
const onClose = vi.fn();
|
|
render(<TransportModal {...defaultProps} onClose={onClose} />);
|
|
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(<TransportModal {...defaultProps} onSave={onSave} />);
|
|
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(<TransportModal {...defaultProps} onSave={onSave} />);
|
|
await pickType(/^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(<TransportModal {...defaultProps} />);
|
|
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-012: costs section not shown when budget addon is disabled', () => {
|
|
render(<TransportModal {...defaultProps} />);
|
|
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(<TransportModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
|
|
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(<TransportModal {...defaultProps} />);
|
|
expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-015: attach file button absent when onFileUpload is undefined', () => {
|
|
render(<TransportModal {...defaultProps} onFileUpload={undefined} />);
|
|
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(<TransportModal {...defaultProps} reservation={res} files={[file]} />);
|
|
expect(screen.getByText('boarding-pass.pdf')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-017: pending file added for new transport on file input change', async () => {
|
|
render(<TransportModal {...defaultProps} reservation={null} />);
|
|
|
|
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(<TransportModal {...defaultProps} reservation={res} onFileUpload={onFileUpload} />);
|
|
|
|
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(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
|
|
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(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
|
|
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(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
|
|
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-063: an outside pointer closes the file picker while an inside pointer keeps it open', async () => {
|
|
const res = buildReservation({ id: 5, type: 'flight' });
|
|
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
|
|
|
|
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
|
|
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
|
|
|
|
const pickerItem = screen.getByText('invoice.pdf');
|
|
fireEvent.pointerDown(pickerItem);
|
|
expect(screen.getByText('invoice.pdf')).toBeInTheDocument();
|
|
|
|
fireEvent.pointerDown(document.body);
|
|
expect(screen.queryByText('invoice.pdf')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-064: closing and reopening the modal resets the file picker', async () => {
|
|
const res = buildReservation({ id: 5, type: 'flight' });
|
|
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
|
|
const { rerender } = render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
|
|
expect(screen.getByText('invoice.pdf')).toBeInTheDocument();
|
|
|
|
rerender(<TransportModal {...defaultProps} isOpen={false} reservation={res} files={[unattachedFile]} />);
|
|
rerender(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
|
|
|
|
expect(screen.queryByText('invoice.pdf')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-022: removing pending file removes it from list', async () => {
|
|
render(<TransportModal {...defaultProps} reservation={null} />);
|
|
|
|
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(<TransportModal {...defaultProps} />);
|
|
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(<TransportModal {...defaultProps} reservation={res} files={[looseFile]} />);
|
|
|
|
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')!;
|
|
// The row carries two buttons: [0] opens the file, [1] unlinks it.
|
|
const unlinkBtn = fileRow.querySelectorAll('button[type="button"]')[1];
|
|
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(<TransportModal {...defaultProps} onSave={onSave} onFileUpload={onFileUpload} reservation={null} />);
|
|
|
|
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();
|
|
});
|
|
|
|
// ── Transit itinerary preservation (#1065) ─────────────────────────────────
|
|
|
|
it('FE-PLANNER-TRANSMODAL-020: re-saving a transit reservation keeps metadata.transit + stop endpoints', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
const res = buildReservation({ title: 'Fernsehturm → Zoo', type: 'bus' }) as any;
|
|
res.metadata = { transit: { provider: 'transitous', transfers: 1, legs: [{ mode: 'BUS', line: '100' }] } };
|
|
res.endpoints = [
|
|
{ role: 'from', sequence: 0, name: 'Fernsehturm', code: null, lat: 52.5208, lng: 13.4094, timezone: 'Europe/Berlin', local_date: '2025-06-01', local_time: '08:30' },
|
|
{ role: 'stop', sequence: 1, name: 'Alexanderplatz', code: null, lat: 52.521, lng: 13.41, timezone: 'Europe/Berlin', local_date: '2025-06-01', local_time: '08:40' },
|
|
{ role: 'to', sequence: 2, name: 'Zoologischer Garten', code: null, lat: 52.507, lng: 13.332, timezone: 'Europe/Berlin', local_date: '2025-06-01', local_time: '09:00' },
|
|
];
|
|
render(<TransportModal {...defaultProps} reservation={res} onSave={onSave} />);
|
|
// Save without touching the route — the itinerary must survive.
|
|
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
expect(payload.metadata?.transit?.provider).toBe('transitous');
|
|
expect(payload.metadata?.transit?.legs).toHaveLength(1);
|
|
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'stop', 'to']);
|
|
expect(payload.endpoints[1]).toMatchObject({ name: 'Alexanderplatz', lat: 52.521 });
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-021: changing the destination drops the stale transit itinerary', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
const res = buildReservation({ title: 'Fernsehturm → Zoo', type: 'bus' }) as any;
|
|
res.metadata = { transit: { provider: 'transitous', legs: [{ mode: 'BUS' }] } };
|
|
res.endpoints = [
|
|
{ role: 'from', sequence: 0, name: 'Fernsehturm', code: null, lat: 52.5208, lng: 13.4094, timezone: 'Europe/Berlin', local_date: null, local_time: null },
|
|
{ role: 'stop', sequence: 1, name: 'Alexanderplatz', code: null, lat: 52.521, lng: 13.41, timezone: 'Europe/Berlin', local_date: null, local_time: null },
|
|
{ role: 'to', sequence: 2, name: 'Zoologischer Garten', code: null, lat: 52.507, lng: 13.332, timezone: 'Europe/Berlin', local_date: null, local_time: null },
|
|
];
|
|
render(<TransportModal {...defaultProps} reservation={res} onSave={onSave} />);
|
|
// Pick a different destination (mocked LocationSelect emits lat/lng 0,0).
|
|
const locationInputs = screen.getAllByTestId('location-select');
|
|
fireEvent.change(locationInputs[1], { target: { value: 'Somewhere Else' } });
|
|
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
expect(payload.metadata?.transit).toBeUndefined();
|
|
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-029: re-saving a joined AirTrail flight keeps metadata.airtrail_ids (#1535)', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
const res = buildReservation({ title: 'BRU → HEL → JFK', type: 'flight' }) as any;
|
|
res.metadata = {
|
|
airline: 'Finnair',
|
|
flight_number: 'AY1502',
|
|
airtrail_ids: ['101', '102'],
|
|
legs: [
|
|
{ from: 'BRU', to: 'HEL', flight_number: 'AY1502', dep_time: '08:00', arr_time: '12:30' },
|
|
{ from: 'HEL', to: 'JFK', flight_number: 'AY15', dep_time: '14:00', arr_time: '15:00' },
|
|
],
|
|
};
|
|
res.endpoints = [
|
|
{ role: 'from', sequence: 0, name: 'Brussels', code: 'BRU', lat: 50.9, lng: 4.48, timezone: 'Europe/Brussels', local_date: '2025-06-01', local_time: '08:00' },
|
|
{ role: 'stop', sequence: 1, name: 'Helsinki-Vantaa', code: 'HEL', lat: 60.32, lng: 24.96, timezone: 'Europe/Helsinki', local_date: '2025-06-01', local_time: '14:00' },
|
|
{ role: 'to', sequence: 2, name: 'JFK', code: 'JFK', lat: 40.64, lng: -73.78, timezone: 'America/New_York', local_date: '2025-06-01', local_time: '15:00' },
|
|
];
|
|
render(<TransportModal {...defaultProps} reservation={res} onSave={onSave} />);
|
|
// A routine edit (retitle + save) must not cost the booking its AirTrail
|
|
// linkage — the import picker relies on it to not re-offer the legs.
|
|
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onSave.mock.calls[0][0].metadata?.airtrail_ids).toEqual(['101', '102']);
|
|
});
|
|
|
|
// ── Manual / Automated creation switch (#1065) ─────────────────────────────
|
|
|
|
it('FE-PLANNER-TRANSMODAL-022: creating shows the Manual/Automated switch; Automated opens the transit search', async () => {
|
|
render(<TransportModal {...defaultProps} places={[]} accommodations={[]} />);
|
|
expect(screen.getByRole('button', { name: 'Manual' })).toBeInTheDocument();
|
|
await userEvent.click(screen.getByRole('button', { name: 'Automated' }));
|
|
// No day selected in defaultProps (days: []) — the pick-day hint shows.
|
|
expect(screen.getByText(/Pick a day/)).toBeInTheDocument();
|
|
// The manual form is gone in automated mode.
|
|
expect(screen.queryByPlaceholderText(/e\.g\. Lufthansa/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-022b: a trip without start/end dates only offers the manual form', () => {
|
|
render(<TransportModal {...defaultProps} tripHasDates={false} places={[]} accommodations={[]} />);
|
|
expect(screen.queryByRole('button', { name: 'Automated' })).not.toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: 'Manual' })).not.toBeInTheDocument();
|
|
expect(screen.getByPlaceholderText(/e\.g\. Lufthansa/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-023: initialAutomated opens straight in the transit search with the day preset', () => {
|
|
const days = [{ id: 10, trip_id: 1, day_number: 1, date: '2025-06-01', title: 'Day 1' }] as any;
|
|
render(<TransportModal {...defaultProps} days={days} selectedDayId={10} initialAutomated places={[]} accommodations={[]} />);
|
|
expect(screen.getAllByPlaceholderText('Search stop or station…')).toHaveLength(2);
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-023b: a leg between two city walks opens the search with what it says of them (CW-08)', () => {
|
|
const days = [{ id: 10, trip_id: 1, day_number: 1, date: '2025-06-01', title: 'Day 1' }] as Day[];
|
|
const walk = { dayId: 10, fromWalk: 'Old town', toWalk: 'Harbour', ends: '12:15', endsNextDay: false, endsAtLeast: false, nextStarts: '13:00' };
|
|
const transitPrefill = { from: { name: 'Old bridge', lat: 52.51, lng: 13.42 }, to: { name: 'Fish market', lat: 52.5, lng: 13.45 }, time: '12:15', walk };
|
|
render(<TransportModal {...defaultProps} days={days} selectedDayId={10} initialAutomated transitPrefill={transitPrefill} places={[]} accommodations={[]} />);
|
|
expect(screen.getByTestId('transit-walk-end')).toHaveTextContent('Old town ends at about 12:15.');
|
|
expect(screen.getByTestId('transit-walk-next')).toHaveTextContent('Harbour starts at 13:00.');
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-028: automated quick picks only offer the chosen day\'s places (#1460)', async () => {
|
|
const days = [
|
|
buildDay({ id: 10, date: '2025-06-01' }),
|
|
buildDay({ id: 11, date: '2025-06-02' }),
|
|
];
|
|
const louvre = buildPlace({ id: 1, name: 'Louvre' });
|
|
const eiffel = buildPlace({ id: 2, name: 'Eiffel Tower' });
|
|
const assignments = {
|
|
'10': [buildAssignment({ day_id: 10, place_id: louvre.id, place: louvre })],
|
|
'11': [buildAssignment({ day_id: 11, place_id: eiffel.id, place: eiffel })],
|
|
};
|
|
render(<TransportModal {...defaultProps} days={days} selectedDayId={10} initialAutomated places={[louvre, eiffel]} assignments={assignments} accommodations={[]} />);
|
|
// Focusing the "from" field opens the quick picks — day 1's place only.
|
|
const [fromInput] = screen.getAllByPlaceholderText('Search stop or station…');
|
|
await userEvent.click(fromInput);
|
|
expect(screen.getByText('Louvre')).toBeInTheDocument();
|
|
expect(screen.queryByText('Eiffel Tower')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-024: editing shows no Manual/Automated switch', () => {
|
|
const res = buildReservation({ title: 'My Train', type: 'train' });
|
|
render(<TransportModal {...defaultProps} reservation={res} />);
|
|
expect(screen.queryByRole('button', { name: 'Automated' })).not.toBeInTheDocument();
|
|
});
|
|
|
|
// ── Multi-leg trains (#1150) ───────────────────────────────────────────────
|
|
|
|
it('FE-PLANNER-TRANSMODAL-025: a train with an added stop saves from/stop/to endpoints + metadata.legs', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
|
await pickType(/^Train$/i);
|
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Berlin → München');
|
|
// Insert an intermediate station (2 → 3 stations = 2 legs).
|
|
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
|
const stations = screen.getAllByTestId('location-select');
|
|
expect(stations).toHaveLength(3);
|
|
fireEvent.change(stations[0], { target: { value: 'Berlin Hbf' } });
|
|
fireEvent.change(stations[1], { target: { value: 'Frankfurt Hbf' } });
|
|
fireEvent.change(stations[2], { target: { value: 'München Hbf' } });
|
|
// Per-leg train number on the first station (placeholder ICE 123).
|
|
const trainNumbers = screen.getAllByPlaceholderText('ICE 123');
|
|
fireEvent.change(trainNumbers[0], { target: { value: 'ICE 100' } });
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
expect(payload.type).toBe('train');
|
|
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'stop', 'to']);
|
|
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin Hbf', 'Frankfurt Hbf', 'München Hbf']);
|
|
expect(payload.metadata.legs).toHaveLength(2);
|
|
expect(payload.metadata.legs[0]).toMatchObject({ from: 'Berlin Hbf', to: 'Frankfurt Hbf', train_number: 'ICE 100' });
|
|
expect(payload.metadata.train_number).toBe('ICE 100'); // flat mirror of leg 0
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-1807: a cruise lists its ports with times, without train fields, and saves them as legs', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
|
await pickType(/^Cruise$/i);
|
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Baltic cruise');
|
|
expect(screen.getByText('Embarkation')).toBeInTheDocument();
|
|
expect(screen.getByText('Disembarkation')).toBeInTheDocument();
|
|
expect(screen.queryByPlaceholderText('ICE 123')).not.toBeInTheDocument();
|
|
await userEvent.click(screen.getByRole('button', { name: /Add port/i }));
|
|
expect(screen.getByText('Port of call')).toBeInTheDocument();
|
|
const ports = screen.getAllByTestId('location-select');
|
|
fireEvent.change(ports[0], { target: { value: 'Kiel' } });
|
|
fireEvent.change(ports[1], { target: { value: 'Tallinn' } });
|
|
fireEvent.change(ports[2], { target: { value: 'Kiel' } });
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
expect(payload.type).toBe('cruise');
|
|
expect(payload.endpoints.map((e: { role: string; name: string }) => `${e.role}:${e.name}`)).toEqual(['from:Kiel', 'stop:Tallinn', 'to:Kiel']);
|
|
expect(payload.metadata.legs).toHaveLength(2);
|
|
expect(payload.metadata.legs[1]).toMatchObject({ from: 'Tallinn', to: 'Kiel' });
|
|
expect(payload.metadata.legs[0].train_number).toBeUndefined();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-027: a train with a day + train number but no geocoded station still saves them (#1150 regression)', async () => {
|
|
const days = [{ id: 10, trip_id: 1, day_number: 1, date: '2026-08-01', title: 'Day 1' }] as any;
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} days={days} selectedDayId={10} onSave={onSave} />);
|
|
await pickType(/^Train$/i);
|
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE 599');
|
|
// Fill the train number + a departure time, but never pick a geocoded station.
|
|
fireEvent.change(screen.getAllByPlaceholderText('ICE 123')[0], { target: { value: 'ICE 599' } });
|
|
fireEvent.change(screen.getAllByTestId('time-picker')[0], { target: { value: '08:00' } });
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
// The day, time and train number survive even without any map-picked station.
|
|
expect(payload.day_id).toBe(10);
|
|
expect(payload.reservation_time).toBe('2026-08-01T08:00');
|
|
expect(payload.metadata.train_number).toBe('ICE 599');
|
|
expect(payload.endpoints).toEqual([]); // no geocoded station → no map endpoints, like before
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-026: a two-station train saves flat (no metadata.legs)', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
|
await pickType(/^Train$/i);
|
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Köln → Aachen');
|
|
const stations = screen.getAllByTestId('location-select');
|
|
fireEvent.change(stations[0], { target: { value: 'Köln Hbf' } });
|
|
fireEvent.change(stations[1], { target: { value: 'Aachen Hbf' } });
|
|
fireEvent.change(screen.getAllByPlaceholderText('ICE 123')[0], { target: { value: 'RE 9' } });
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
|
|
expect(payload.metadata.legs).toBeUndefined();
|
|
expect(payload.metadata.train_number).toBe('RE 9');
|
|
});
|
|
|
|
// ── Per-endpoint day resolution (#1684) ────────────────────────────────────
|
|
|
|
const spanDays = [
|
|
{ id: 10, trip_id: 1, day_number: 1, date: '2026-08-01', title: 'Day 1' },
|
|
{ id: 11, trip_id: 1, day_number: 2, date: '2026-08-02', title: 'Day 2' },
|
|
{ id: 12, trip_id: 1, day_number: 3, date: '2026-08-03', title: 'Day 3' },
|
|
] as any;
|
|
|
|
const flightEndpoints = (fromDate: string, toDate: string) => ([
|
|
{ id: 1, reservation_id: 1, role: 'from', sequence: 0, name: 'Frankfurt (FRA)', code: 'FRA', lat: 50.03, lng: 8.57, timezone: 'Europe/Berlin', local_date: fromDate, local_time: '10:00' },
|
|
{ id: 2, reservation_id: 1, role: 'to', sequence: 1, name: 'New York (JFK)', code: 'JFK', lat: 40.64, lng: -73.78, timezone: 'America/New_York', local_date: toDate, local_time: '13:00' },
|
|
]);
|
|
|
|
// #2076 — an import whose type could not be read used to arrive here as a flight.
|
|
// A wrong flight looks right enough to be saved without a second look; an
|
|
// explicit "other" asks to be corrected.
|
|
it('FE-PLANNER-TRANSMODAL-062: an unrecognised prefill type lands on transport_other, not flight', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
const prefill = { title: 'Airport transfer', type: 'shuttle-voucher', status: 'pending' } as any;
|
|
render(<TransportModal {...defaultProps} prefill={prefill} onSave={onSave} />);
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onSave.mock.calls[0][0].type).toBe('transport_other');
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-030: an import prefill resolves each waypoint day from its endpoint local_date (#1684)', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
// A parsed import carries local_date per endpoint but no day_id at all.
|
|
const prefill = { title: 'LH 400', type: 'flight', status: 'pending', endpoints: flightEndpoints('2026-08-02', '2026-08-03') } as any;
|
|
render(<TransportModal {...defaultProps} days={spanDays} prefill={prefill} onSave={onSave} />);
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
expect(payload.day_id).toBe(11);
|
|
expect(payload.end_day_id).toBe(12);
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-031: editing keeps the saved days when the endpoint local_date is stale', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
// Dragging a booking to another day rewrites day_id/end_day_id but leaves the
|
|
// endpoints untouched, so local_date lags behind. Re-saving must not move the
|
|
// booking back to the day that stale date points at.
|
|
const reservation = buildReservation({
|
|
id: 1, title: 'LH 400', type: 'flight', day_id: 10, end_day_id: 11,
|
|
reservation_time: '2026-08-01T10:00', reservation_end_time: '2026-08-02T13:00',
|
|
endpoints: flightEndpoints('2026-08-03', '2026-08-03'),
|
|
} as any) as any;
|
|
render(<TransportModal {...defaultProps} days={spanDays} reservation={reservation} onSave={onSave} />);
|
|
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
expect(payload.day_id).toBe(10);
|
|
expect(payload.end_day_id).toBe(11);
|
|
});
|
|
|
|
// ── Multi-leg flights (#872) ───────────────────────────────────────────────
|
|
|
|
const routeDays = [
|
|
{ id: 10, trip_id: 1, day_number: 1, date: '2026-08-01', title: 'Depart Day' },
|
|
{ id: 11, trip_id: 1, day_number: 2, date: '2026-08-02', title: 'Stop Day' },
|
|
{ id: 12, trip_id: 1, day_number: 3, date: '2026-08-03', title: 'Arrive Day' },
|
|
] as unknown as Day[];
|
|
|
|
/** The route cards have no test ids — each day picker is found via its own label. */
|
|
async function pickDay(labelText: string, index: number, dayTitle: string) {
|
|
const label = screen.getAllByText(labelText)[index];
|
|
const trigger = (label.parentElement as HTMLElement).querySelector('button') as HTMLButtonElement;
|
|
await userEvent.click(trigger);
|
|
await userEvent.click(screen.getByRole('button', { name: new RegExp(`^${dayTitle}`) }));
|
|
}
|
|
|
|
it('FE-PLANNER-TRANSMODAL-032: a flight with an added stop saves per-leg metadata and from/stop/to endpoints', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
|
|
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'BRU → HEL → JFK');
|
|
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
|
|
|
const airports = screen.getAllByTestId('airport-select');
|
|
expect(airports).toHaveLength(3);
|
|
fireEvent.change(airports[0], { target: { value: 'BRU' } });
|
|
fireEvent.change(airports[1], { target: { value: 'HEL' } });
|
|
fireEvent.change(airports[2], { target: { value: 'JFK' } });
|
|
|
|
// Times run: wp0 departure, wp1 arrival, wp1 departure, wp2 arrival.
|
|
const times = screen.getAllByTestId('time-picker');
|
|
fireEvent.change(times[0], { target: { value: '08:00' } });
|
|
fireEvent.change(times[1], { target: { value: '12:30' } });
|
|
fireEvent.change(times[2], { target: { value: '14:00' } });
|
|
fireEvent.change(times[3], { target: { value: '15:00' } });
|
|
|
|
const airlines = screen.getAllByPlaceholderText('Lufthansa');
|
|
fireEvent.change(airlines[0], { target: { value: 'Brussels Airlines' } });
|
|
fireEvent.change(airlines[1], { target: { value: 'Finnair' } });
|
|
const numbers = screen.getAllByPlaceholderText('LH 123');
|
|
fireEvent.change(numbers[0], { target: { value: 'SN 1234' } });
|
|
fireEvent.change(numbers[1], { target: { value: 'AY 15' } });
|
|
const seats = screen.getAllByPlaceholderText('12A');
|
|
fireEvent.change(seats[0], { target: { value: '4F' } });
|
|
|
|
// The last leg lands a day later than it departs.
|
|
await pickDay('Arrival', 1, 'Arrive Day');
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
|
|
expect(payload.endpoints.map((e: { role: string; code: string }) => `${e.role}:${e.code}`)).toEqual(['from:BRU', 'stop:HEL', 'to:JFK']);
|
|
expect(payload.metadata.legs).toHaveLength(2);
|
|
expect(payload.metadata.legs[0]).toMatchObject({
|
|
from: 'BRU', to: 'HEL', airline: 'Brussels Airlines', flight_number: 'SN 1234', seat: '4F',
|
|
dep_day_id: 10, dep_time: '08:00', arr_day_id: 10, arr_time: '12:30',
|
|
});
|
|
expect(payload.metadata.legs[1]).toMatchObject({ from: 'HEL', to: 'JFK', airline: 'Finnair', flight_number: 'AY 15', arr_day_id: 12, arr_time: '15:00' });
|
|
// Flat mirrors of the first leg for legacy readers.
|
|
expect(payload.metadata).toMatchObject({ airline: 'Brussels Airlines', flight_number: 'SN 1234', seat: '4F', departure_airport: 'BRU', arrival_airport: 'JFK' });
|
|
expect(payload.day_id).toBe(10);
|
|
expect(payload.end_day_id).toBe(12);
|
|
expect(payload.reservation_time).toBe('2026-08-01T08:00');
|
|
expect(payload.reservation_end_time).toBe('2026-08-03T15:00');
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-033: removing a flight stop collapses the route back to two waypoints', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
|
|
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'BRU → JFK');
|
|
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
|
expect(screen.getAllByTestId('airport-select')).toHaveLength(3);
|
|
|
|
// Only the intermediate stop carries a delete button.
|
|
await userEvent.click(screen.getByRole('button', { name: /^Delete$/i }));
|
|
expect(screen.getAllByTestId('airport-select')).toHaveLength(2);
|
|
|
|
const airports = screen.getAllByTestId('airport-select');
|
|
fireEvent.change(airports[0], { target: { value: 'BRU' } });
|
|
fireEvent.change(airports[1], { target: { value: 'JFK' } });
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
|
|
expect(payload.metadata.legs).toBeUndefined();
|
|
});
|
|
|
|
// ── Per-segment booking references (#1943) ─────────────────────────────────
|
|
|
|
function multiLegFlight(): Reservation {
|
|
const res = buildReservation({ id: 31, title: 'BRU → HEL → JFK', type: 'flight' });
|
|
return Object.assign(res, {
|
|
day_id: 10,
|
|
end_day_id: 12,
|
|
reservation_time: '2026-08-01T08:00',
|
|
reservation_end_time: '2026-08-03T15:00',
|
|
confirmation_number: 'BOOK1',
|
|
metadata: {
|
|
airline: 'Brussels Airlines', flight_number: 'SN 1234', departure_airport: 'BRU', arrival_airport: 'JFK',
|
|
legs: [
|
|
{ from: 'BRU', to: 'HEL', airline: 'Brussels Airlines', flight_number: 'SN 1234', confirmation_number: 'ABC123', dep_day_id: 10, dep_time: '08:00', arr_day_id: 10, arr_time: '12:30' },
|
|
{ from: 'HEL', to: 'JFK', airline: 'Finnair', flight_number: 'AY 15', confirmation_number: 'XYZ789', dep_day_id: 11, dep_time: '14:00', arr_day_id: 12, arr_time: '15:00' },
|
|
],
|
|
},
|
|
endpoints: [
|
|
{ id: 1, reservation_id: 31, role: 'from', sequence: 0, name: 'Brussels (BRU)', code: 'BRU', lat: 50.9, lng: 4.48, timezone: 'Europe/Brussels', local_date: '2026-08-01', local_time: '08:00' },
|
|
{ id: 2, reservation_id: 31, role: 'stop', sequence: 1, name: 'Helsinki (HEL)', code: 'HEL', lat: 60.31, lng: 24.96, timezone: 'Europe/Helsinki', local_date: '2026-08-02', local_time: '14:00' },
|
|
{ id: 3, reservation_id: 31, role: 'to', sequence: 2, name: 'New York (JFK)', code: 'JFK', lat: 40.64, lng: -73.78, timezone: 'America/New_York', local_date: '2026-08-03', local_time: '15:00' },
|
|
],
|
|
}) as unknown as Reservation;
|
|
}
|
|
|
|
it('FE-PLANNER-TRANSMODAL-058: each segment of a stopover flight saves its own booking code', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
|
|
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'BRU → HEL → JFK');
|
|
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
|
const airports = screen.getAllByTestId('airport-select');
|
|
fireEvent.change(airports[0], { target: { value: 'BRU' } });
|
|
fireEvent.change(airports[1], { target: { value: 'HEL' } });
|
|
fireEvent.change(airports[2], { target: { value: 'JFK' } });
|
|
|
|
// Two departing waypoints get a code field, the booking's own field stays last.
|
|
const codes = screen.getAllByPlaceholderText('e.g. ABC12345');
|
|
expect(codes).toHaveLength(3);
|
|
fireEvent.change(codes[0], { target: { value: 'ABC123' } });
|
|
fireEvent.change(codes[1], { target: { value: 'XYZ789' } });
|
|
fireEvent.change(codes[2], { target: { value: 'BOOK1' } });
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
expect(payload.metadata.legs.map((l: { confirmation_number?: string }) => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']);
|
|
// The booking's own reference stays where it was and is never mirrored.
|
|
expect(payload.confirmation_number).toBe('BOOK1');
|
|
expect(payload.metadata.confirmation_number).toBeUndefined();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-059: no per-segment field while the route writes no legs', async () => {
|
|
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} />);
|
|
|
|
fireEvent.change(screen.getAllByTestId('airport-select')[0], { target: { value: 'BRU' } });
|
|
fireEvent.change(screen.getAllByTestId('airport-select')[1], { target: { value: 'JFK' } });
|
|
// Only the booking's own field: a direct flight stores no legs.
|
|
expect(screen.getAllByPlaceholderText('e.g. ABC12345')).toHaveLength(1);
|
|
|
|
// A third waypoint whose airport is never picked drops out of the route, so
|
|
// the legs are still not written and the field must stay away. Otherwise the
|
|
// typed code would vanish on save.
|
|
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
|
expect(screen.getAllByTestId('airport-select')).toHaveLength(3);
|
|
expect(screen.getAllByPlaceholderText('e.g. ABC12345')).toHaveLength(1);
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-060: editing a stopover flight pre-fills every segment code and a re-save keeps them', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegFlight()} onSave={onSave} />);
|
|
|
|
const codes = screen.getAllByPlaceholderText('e.g. ABC12345') as HTMLInputElement[];
|
|
expect(codes.map(i => i.value)).toEqual(['ABC123', 'XYZ789', 'BOOK1']);
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
expect(payload.metadata.legs.map((l: { confirmation_number?: string }) => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']);
|
|
expect(payload.confirmation_number).toBe('BOOK1');
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-061: dropping the stop drops the segment codes with the legs, never the booking code', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegFlight()} onSave={onSave} />);
|
|
|
|
// Removing the only stop collapses the route to a direct flight, so there are
|
|
// no legs left to hold a per-segment code. Documented behaviour, not a bug:
|
|
// the field disappears with them and the booking keeps its own reference.
|
|
await userEvent.click(screen.getByRole('button', { name: /^Delete$/i }));
|
|
expect(screen.getAllByPlaceholderText('e.g. ABC12345')).toHaveLength(1);
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
expect(payload.metadata.legs).toBeUndefined();
|
|
expect(payload.confirmation_number).toBe('BOOK1');
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-034: the departure-date picker moves the whole flight to another day', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
|
|
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH 400');
|
|
fireEvent.change(screen.getAllByTestId('airport-select')[0], { target: { value: 'FRA' } });
|
|
fireEvent.change(screen.getAllByTestId('airport-select')[1], { target: { value: 'JFK' } });
|
|
await pickDay('Departure', 0, 'Stop Day');
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onSave.mock.calls[0][0].day_id).toBe(11);
|
|
});
|
|
|
|
// ── Multi-leg trains (#1150) — edit round-trip ─────────────────────────────
|
|
|
|
function multiLegTrain(): Reservation {
|
|
const res = buildReservation({ id: 30, title: 'Berlin → München', type: 'train' });
|
|
return Object.assign(res, {
|
|
day_id: 10,
|
|
end_day_id: 12,
|
|
reservation_time: '2026-08-01T08:00',
|
|
reservation_end_time: '2026-08-03T18:00',
|
|
metadata: {
|
|
train_number: 'ICE 100', platform: '5', seat: '11A',
|
|
legs: [
|
|
{ from: 'Berlin Hbf', to: 'Frankfurt Hbf', train_number: 'ICE 100', platform: '5', seat: '11A', dep_day_id: 10, dep_time: '08:00', arr_day_id: 11, arr_time: '12:00', day_positions: { '10': 2 } },
|
|
{ from: 'Frankfurt Hbf', to: 'München Hbf', train_number: 'ICE 200', platform: '7', seat: '12B', dep_day_id: 11, dep_time: '13:00', arr_day_id: 12, arr_time: '18:00' },
|
|
],
|
|
},
|
|
endpoints: [
|
|
{ id: 1, reservation_id: 30, role: 'from', sequence: 0, name: 'Berlin Hbf', code: null, lat: 52.52, lng: 13.37, timezone: null, local_date: '2026-08-01', local_time: '08:00' },
|
|
{ id: 2, reservation_id: 30, role: 'stop', sequence: 1, name: 'Frankfurt Hbf', code: null, lat: 50.107, lng: 8.663, timezone: null, local_date: '2026-08-02', local_time: '13:00' },
|
|
{ id: 3, reservation_id: 30, role: 'to', sequence: 2, name: 'München Hbf', code: null, lat: 48.14, lng: 11.558, timezone: null, local_date: '2026-08-03', local_time: '18:00' },
|
|
],
|
|
}) as unknown as Reservation;
|
|
}
|
|
|
|
it('FE-PLANNER-TRANSMODAL-035: editing a multi-leg train seeds every station from metadata.legs', () => {
|
|
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegTrain()} />);
|
|
|
|
expect(screen.getAllByTestId('location-select')).toHaveLength(3);
|
|
const numbers = screen.getAllByPlaceholderText('ICE 123') as HTMLInputElement[];
|
|
expect(numbers.map(i => i.value)).toEqual(['ICE 100', 'ICE 200']);
|
|
const platforms = screen.getAllByPlaceholderText('12') as HTMLInputElement[];
|
|
expect(platforms.map(i => i.value)).toEqual(['5', '7']);
|
|
const times = screen.getAllByTestId('time-picker') as HTMLInputElement[];
|
|
// wp0 dep, wp1 arr, wp1 dep, wp2 arr
|
|
expect(times.map(i => i.value)).toEqual(['08:00', '12:00', '13:00', '18:00']);
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-036: re-saving a multi-leg train keeps its legs and day-plan positions', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegTrain()} onSave={onSave} />);
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
expect(payload.endpoints.map((e: { role: string; name: string }) => `${e.role}:${e.name}`)).toEqual([
|
|
'from:Berlin Hbf', 'stop:Frankfurt Hbf', 'to:München Hbf',
|
|
]);
|
|
expect(payload.metadata.legs).toHaveLength(2);
|
|
// The planner owns the per-leg positions — an edit must not drop them.
|
|
expect(payload.metadata.legs[0].day_positions).toEqual({ '10': 2 });
|
|
expect(payload.metadata).toMatchObject({ train_number: 'ICE 100', platform: '5', seat: '11A' });
|
|
expect(payload.day_id).toBe(10);
|
|
expect(payload.end_day_id).toBe(12);
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-037: a train with no arrival day dates its arrival from the departure day', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
|
|
|
await pickType(/^Train$/i);
|
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'RE 9');
|
|
const stations = screen.getAllByTestId('location-select');
|
|
fireEvent.change(stations[0], { target: { value: 'Köln Hbf' } });
|
|
fireEvent.change(stations[1], { target: { value: 'Aachen Hbf' } });
|
|
fireEvent.change(screen.getAllByTestId('time-picker')[0], { target: { value: '09:00' } });
|
|
fireEvent.change(screen.getAllByTestId('time-picker')[1], { target: { value: '10:05' } });
|
|
fireEvent.change(screen.getAllByPlaceholderText('12')[0], { target: { value: '3' } });
|
|
fireEvent.change(screen.getAllByPlaceholderText('42A')[0], { target: { value: '21C' } });
|
|
// Blank out the arrival day so only the departure day is known.
|
|
await pickDay('Arrival', 0, '—');
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
expect(payload.reservation_end_time).toBe('2026-08-01T10:05');
|
|
expect(payload.endpoints[1].local_date).toBe('2026-08-01');
|
|
expect(payload.metadata).toMatchObject({ platform: '3', seat: '21C' });
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-038: a single-leg flight mirrors the seat into the flat metadata', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
|
|
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH 400');
|
|
fireEvent.change(screen.getAllByTestId('airport-select')[0], { target: { value: 'FRA' } });
|
|
fireEvent.change(screen.getAllByTestId('airport-select')[1], { target: { value: 'JFK' } });
|
|
fireEvent.change(screen.getAllByPlaceholderText('12A')[0], { target: { value: '7A' } });
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
expect(payload.metadata.seat).toBe('7A');
|
|
expect(payload.metadata.legs).toBeUndefined();
|
|
});
|
|
|
|
// ── Non-flight form fields ─────────────────────────────────────────────────
|
|
|
|
it('FE-PLANNER-TRANSMODAL-039: a car rental keeps its pick-up/return days, times and booking code', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} days={routeDays} onSave={onSave} />);
|
|
|
|
await pickType(/^Car$/i);
|
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Sixt compact');
|
|
// The labels switch to rental wording for cars.
|
|
expect(screen.getByText('Pickup')).toBeInTheDocument();
|
|
expect(screen.getByText('Return time')).toBeInTheDocument();
|
|
|
|
const locations = screen.getAllByTestId('location-select');
|
|
fireEvent.change(locations[0], { target: { value: 'Berlin Airport' } });
|
|
fireEvent.change(locations[1], { target: { value: 'Munich Airport' } });
|
|
await pickDay('Pickup', 0, 'Depart Day');
|
|
await pickDay('Return', 0, 'Arrive Day');
|
|
fireEvent.change(screen.getAllByTestId('time-picker')[0], { target: { value: '09:30' } });
|
|
fireEvent.change(screen.getAllByTestId('time-picker')[1], { target: { value: '17:45' } });
|
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. ABC12345/i), 'SIXT-42');
|
|
await userEvent.type(screen.getByPlaceholderText(/Additional notes/i), 'Full-to-full tank');
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
expect(payload).toMatchObject({
|
|
type: 'car', day_id: 10, end_day_id: 12,
|
|
reservation_time: '2026-08-01T09:30', reservation_end_time: '2026-08-03T17:45',
|
|
confirmation_number: 'SIXT-42', notes: 'Full-to-full tank',
|
|
});
|
|
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin Airport', 'Munich Airport']);
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-040: the status picker switches the booking to confirmed', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
|
|
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH 400');
|
|
// The status is a pill in the head band that flips between the two.
|
|
expect(screen.getByText('Pending')).toBeInTheDocument();
|
|
await userEvent.click(screen.getByRole('button', { name: 'Set to Confirmed' }));
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onSave.mock.calls[0][0].status).toBe('confirmed');
|
|
});
|
|
|
|
// ── Submit guards + failures ───────────────────────────────────────────────
|
|
|
|
it('FE-PLANNER-TRANSMODAL-041: submitting the form with an empty title is a no-op', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
|
fireEvent.submit(document.querySelector('form') as HTMLFormElement);
|
|
await waitFor(() => expect(onSave).not.toHaveBeenCalled());
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-042: a rejected save surfaces the error message and re-enables the button', async () => {
|
|
const addToast = vi.fn();
|
|
window.__addToast = addToast;
|
|
const onSave = vi.fn().mockRejectedValue(new Error('Server exploded'));
|
|
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
|
|
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH 400');
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Server exploded', 'error', undefined));
|
|
await waitFor(() => expect(screen.getByRole('button', { name: /^Add$/i })).toBeEnabled());
|
|
delete window.__addToast;
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-043: a closed modal does not seed the form from the reservation', () => {
|
|
const res = buildReservation({ title: 'Hidden Flight', type: 'flight' });
|
|
render(<TransportModal {...defaultProps} isOpen={false} reservation={res} />);
|
|
expect(screen.queryByDisplayValue('Hidden Flight')).not.toBeInTheDocument();
|
|
});
|
|
|
|
// ── Travelers (#1517) ──────────────────────────────────────────────────────
|
|
|
|
const tripMembers: TripMember[] = [
|
|
{ id: 1, username: 'alice', avatar_url: null },
|
|
{ id: 2, username: 'bob', avatar_url: null },
|
|
];
|
|
|
|
it('FE-PLANNER-TRANSMODAL-044: assigned travelers are written back after the save resolves', async () => {
|
|
const onSave = vi.fn().mockResolvedValue({ id: 60 });
|
|
let body: { user_ids: number[] } | null = null;
|
|
server.use(
|
|
http.put('/api/trips/1/reservations/60/travelers', async ({ request }) => {
|
|
body = (await request.json()) as { user_ids: number[] };
|
|
return HttpResponse.json({ travelers: [] });
|
|
}),
|
|
);
|
|
|
|
render(<TransportModal {...defaultProps} onSave={onSave} tripMembers={tripMembers} />);
|
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH 400');
|
|
await userEvent.click(within(await openTravelers()).getByText('bob'));
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
await waitFor(() => expect(body).not.toBeNull());
|
|
expect(body!.user_ids).toEqual([2]);
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-045: a failing traveler write surfaces an error toast', async () => {
|
|
const addToast = vi.fn();
|
|
window.__addToast = addToast;
|
|
const onSave = vi.fn().mockResolvedValue({ id: 61 });
|
|
server.use(
|
|
http.put('/api/trips/1/reservations/61/travelers', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
|
|
);
|
|
|
|
render(<TransportModal {...defaultProps} onSave={onSave} tripMembers={tripMembers} />);
|
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH 400');
|
|
await userEvent.click(within(await openTravelers()).getByText('alice'));
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
await waitFor(() => expect(addToast).toHaveBeenCalledWith(expect.any(String), 'error', undefined));
|
|
delete window.__addToast;
|
|
});
|
|
|
|
// ── Linked cost actions ─────────────────────────────────────────────────────
|
|
|
|
function seedLinkedCost() {
|
|
seedStore(useAddonStore, {
|
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
|
loaded: true,
|
|
});
|
|
seedStore(useTripStore, {
|
|
trip: buildTrip({ id: 1 }),
|
|
budgetItems: [
|
|
{ id: 3, trip_id: 1, name: 'Flight ticket', total_price: 320, currency: 'EUR', category: 'transport', reservation_id: 50, members: [], payers: [], persons: 1, expense_date: null, paid_by_user_id: null },
|
|
],
|
|
});
|
|
}
|
|
|
|
it('FE-PLANNER-TRANSMODAL-046: editing the linked cost saves the booking, then opens that item', async () => {
|
|
seedLinkedCost();
|
|
const onSave = vi.fn().mockResolvedValue({ id: 50 });
|
|
const onOpenExpense = vi.fn();
|
|
render(
|
|
<TransportModal
|
|
{...defaultProps}
|
|
onSave={onSave}
|
|
onOpenExpense={onOpenExpense}
|
|
reservation={buildReservation({ id: 50, type: 'flight', title: 'LH 400' })}
|
|
/>,
|
|
);
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: /^Edit$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onOpenExpense).toHaveBeenCalledWith({ editItem: expect.objectContaining({ id: 3 }) });
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-047: a failing cost removal reports the error', async () => {
|
|
const addToast = vi.fn();
|
|
window.__addToast = addToast;
|
|
seedLinkedCost();
|
|
server.use(http.delete('/api/trips/1/budget/3', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
|
|
|
render(<TransportModal {...defaultProps} reservation={buildReservation({ id: 50, type: 'flight', title: 'LH 400' })} />);
|
|
await userEvent.click(screen.getByRole('button', { name: /Remove expense/i }));
|
|
|
|
await waitFor(() => expect(addToast).toHaveBeenCalledWith(expect.any(String), 'error', undefined));
|
|
delete window.__addToast;
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-048: an imported transport with a price creates the linked cost on save', async () => {
|
|
seedStore(useAddonStore, {
|
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
|
loaded: true,
|
|
});
|
|
const onSave = vi.fn().mockResolvedValue({ id: 62 });
|
|
const prefill = {
|
|
title: 'LH 400', type: 'flight', status: 'pending',
|
|
reservation_time: '2026-08-01T08:00', reservation_end_time: '2026-08-01T11:00',
|
|
metadata: { airline: 'Lufthansa', flight_number: 'LH 400', price: 189.5, priceCurrency: 'EUR' },
|
|
endpoints: [],
|
|
} as unknown as BookingReviewDraft;
|
|
|
|
render(<TransportModal {...defaultProps} days={routeDays} prefill={prefill} onSave={onSave} />);
|
|
// The parsed price is previewed before the booking exists.
|
|
expect(screen.getByText('Linked expense')).toBeInTheDocument();
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
// The parsed currency travels with the price (#2525), or the server would store
|
|
// it in whatever the trip currency is.
|
|
expect(onSave.mock.calls[0][0].create_budget_entry).toEqual({ total_price: 189.5, category: 'flights', currency: 'EUR' });
|
|
});
|
|
|
|
// ── File edge cases ─────────────────────────────────────────────────────────
|
|
|
|
it('FE-PLANNER-TRANSMODAL-049: a cancelled file dialog changes nothing', () => {
|
|
render(<TransportModal {...defaultProps} reservation={null} />);
|
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
fireEvent.change(fileInput, { target: { files: [] } });
|
|
expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-050: a failing upload to an existing booking shows the upload error', async () => {
|
|
const addToast = vi.fn();
|
|
window.__addToast = addToast;
|
|
const onFileUpload = vi.fn().mockRejectedValue(new Error('disk full'));
|
|
const res = buildReservation({ id: 20, type: 'train', title: 'Eurostar' });
|
|
|
|
render(<TransportModal {...defaultProps} reservation={res} onFileUpload={onFileUpload} />);
|
|
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
|
|
fireEvent.change(fileInput, { target: { files: [new File(['x'], 'fail.pdf', { type: 'application/pdf' })] } });
|
|
|
|
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to upload', 'error', undefined));
|
|
delete window.__addToast;
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-051: a failing unlink reports the update error', async () => {
|
|
const addToast = vi.fn();
|
|
window.__addToast = addToast;
|
|
server.use(
|
|
http.put('/api/trips/1/files/70', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
|
|
http.get('/api/trips/1/files/70/links', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
|
|
);
|
|
const res = buildReservation({ id: 21, type: 'flight', title: 'LH 400' });
|
|
const attached = buildTripFile({ id: 70, original_name: 'boarding.pdf' });
|
|
(attached as unknown as { reservation_id: number }).reservation_id = 21;
|
|
|
|
render(<TransportModal {...defaultProps} reservation={res} files={[attached]} />);
|
|
const row = screen.getByText('boarding.pdf').closest('div') as HTMLElement;
|
|
await userEvent.click(within(row).getAllByRole('button')[1]);
|
|
|
|
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to update', 'error', undefined));
|
|
delete window.__addToast;
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-052: a failing link keeps the picker open and reports the error', async () => {
|
|
const addToast = vi.fn();
|
|
window.__addToast = addToast;
|
|
server.use(http.post('/api/trips/1/files/71/link', () => HttpResponse.json({ error: 'nope' }, { status: 500 })));
|
|
|
|
const res = buildReservation({ id: 22, type: 'flight', title: 'LH 400' });
|
|
const loose = buildTripFile({ id: 71, original_name: 'invoice.pdf' });
|
|
|
|
render(<TransportModal {...defaultProps} reservation={res} files={[loose]} />);
|
|
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
|
|
const pickerItem = screen.getByText('invoice.pdf').closest('button') as HTMLButtonElement;
|
|
fireEvent.mouseEnter(pickerItem);
|
|
fireEvent.mouseLeave(pickerItem);
|
|
await userEvent.click(pickerItem);
|
|
|
|
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to update', 'error', undefined));
|
|
expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument();
|
|
delete window.__addToast;
|
|
});
|
|
|
|
// ── Transit itinerary preservation, continued ──────────────────────────────
|
|
|
|
it('FE-PLANNER-TRANSMODAL-053: kept transfer stops are re-sequenced in order', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
const res = buildReservation({ title: 'A → C', type: 'bus' });
|
|
Object.assign(res, {
|
|
metadata: { transit: { provider: 'transitous', legs: [{ mode: 'BUS' }] } },
|
|
// Stops arrive out of order — the form must restore their sequence.
|
|
endpoints: [
|
|
{ role: 'from', sequence: 0, name: 'A', code: null, lat: 0, lng: 0, timezone: null, local_date: null, local_time: null },
|
|
{ role: 'stop', sequence: 2, name: 'Second stop', code: null, lat: 1, lng: 1, timezone: null, local_date: null, local_time: null },
|
|
{ role: 'stop', sequence: 1, name: 'First stop', code: null, lat: 2, lng: 2, timezone: null, local_date: null, local_time: null },
|
|
{ role: 'to', sequence: 3, name: 'C', code: null, lat: 3, lng: 3, timezone: null, local_date: null, local_time: null },
|
|
],
|
|
});
|
|
|
|
render(<TransportModal {...defaultProps} reservation={res as unknown as Reservation} onSave={onSave} />);
|
|
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
|
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['A', 'First stop', 'Second stop', 'C']);
|
|
expect(payload.endpoints.map((e: { sequence: number }) => e.sequence)).toEqual([0, 1, 2, 3]);
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-055: an existing traveler list is toggled off and written back empty', async () => {
|
|
const onSave = vi.fn().mockResolvedValue({ id: 63 });
|
|
let body: { user_ids: number[] } | null = null;
|
|
server.use(
|
|
http.put('/api/trips/1/reservations/63/travelers', async ({ request }) => {
|
|
body = (await request.json()) as { user_ids: number[] };
|
|
return HttpResponse.json({ travelers: [] });
|
|
}),
|
|
);
|
|
const res = buildReservation({ id: 63, type: 'flight', title: 'LH 400' });
|
|
(res as unknown as { travelers: { user_id: number; username: string }[] }).travelers = [
|
|
{ user_id: 1, username: 'alice' },
|
|
];
|
|
|
|
render(<TransportModal {...defaultProps} reservation={res} onSave={onSave} tripMembers={tripMembers} />);
|
|
// Seeded from the reservation, so alice starts selected and the closed field names her.
|
|
expect(screen.getByRole('button', { name: /alice/, expanded: false })).toBeInTheDocument();
|
|
const travelers = await openTravelers();
|
|
expect(within(travelers).getByText('alice').closest('button')).toHaveAttribute('aria-pressed', 'true');
|
|
await userEvent.click(within(travelers).getByText('alice'));
|
|
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
|
|
|
await waitFor(() => expect(body).not.toBeNull());
|
|
expect(body!.user_ids).toEqual([]);
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-056: a train stop can be added and removed again', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
|
|
|
await pickType(/^Train$/i);
|
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Köln → Aachen');
|
|
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
|
expect(screen.getAllByTestId('location-select')).toHaveLength(3);
|
|
await userEvent.click(screen.getByRole('button', { name: /^Delete$/i }));
|
|
expect(screen.getAllByTestId('location-select')).toHaveLength(2);
|
|
|
|
// Move the departure to another day through its own picker.
|
|
await pickDay('Departure', 0, 'Stop Day');
|
|
const stations = screen.getAllByTestId('location-select');
|
|
fireEvent.change(stations[0], { target: { value: 'Köln Hbf' } });
|
|
fireEvent.change(stations[1], { target: { value: 'Aachen Hbf' } });
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
expect(payload.day_id).toBe(11);
|
|
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-058: a car booking keeps its stops in order between pick-up and return (#1797)', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
|
|
|
await pickType(/^Car$/i);
|
|
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen Berlin → Prag');
|
|
// Pick-up and return are the rental frame; the stops sit between them.
|
|
expect(screen.getAllByTestId('location-select')).toHaveLength(2);
|
|
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
|
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
|
// Pick-up and return come first in the form, the stops follow — so the saved
|
|
// route is from · stop · stop · to even though the fields read in another order.
|
|
const fields = screen.getAllByTestId('location-select');
|
|
expect(fields).toHaveLength(4);
|
|
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
|
|
fireEvent.change(fields[1], { target: { value: 'Prag' } });
|
|
fireEvent.change(fields[2], { target: { value: 'Dresden' } });
|
|
fireEvent.change(fields[3], { target: { value: 'Bastei' } });
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'stop', 'stop', 'to']);
|
|
expect(payload.endpoints.map((e: { sequence: number }) => e.sequence)).toEqual([0, 1, 2, 3]);
|
|
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin', 'Dresden', 'Bastei', 'Prag']);
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-060: the stops of a car booking can be put in another order', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
|
|
|
await pickType(/^Car$/i);
|
|
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen');
|
|
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
|
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
|
|
|
const fields = screen.getAllByTestId('location-select');
|
|
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
|
|
fireEvent.change(fields[1], { target: { value: 'Prag' } });
|
|
fireEvent.change(fields[2], { target: { value: 'Dresden' } });
|
|
fireEvent.change(fields[3], { target: { value: 'Bastei' } });
|
|
|
|
// The order of the stops is the order they are driven, and `sequence` is derived
|
|
// from it on save — so without this the only way to swap two was to retype both.
|
|
await userEvent.click(screen.getAllByRole('button', { name: /Move down/i })[0]);
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin', 'Bastei', 'Dresden', 'Prag']);
|
|
expect(payload.endpoints.map((e: { sequence: number }) => e.sequence)).toEqual([0, 1, 2, 3]);
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-061: the ends of the stop list cannot be pushed past themselves', async () => {
|
|
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} />);
|
|
|
|
await pickType(/^Car$/i);
|
|
// A single stop has nothing to swap with, so the buttons stay away entirely.
|
|
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
|
expect(screen.queryByRole('button', { name: /Move up/i })).not.toBeInTheDocument();
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
|
const up = screen.getAllByRole('button', { name: /Move up/i });
|
|
const down = screen.getAllByRole('button', { name: /Move down/i });
|
|
expect(up[0]).toBeDisabled();
|
|
expect(down[down.length - 1]).toBeDisabled();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-059: a stop removed from a car booking is gone from the saved route', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
|
|
|
await pickType(/^Car$/i);
|
|
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen');
|
|
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
|
expect(screen.getAllByTestId('location-select')).toHaveLength(3);
|
|
await userEvent.click(screen.getByRole('button', { name: /Remove stop/i }));
|
|
expect(screen.getAllByTestId('location-select')).toHaveLength(2);
|
|
|
|
const fields = screen.getAllByTestId('location-select');
|
|
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
|
|
fireEvent.change(fields[1], { target: { value: 'Prag' } });
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onSave.mock.calls[0][0].endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-057: the open-file link on an attached document does not throw', async () => {
|
|
const res = buildReservation({ id: 23, type: 'flight', title: 'LH 400' });
|
|
const attached = buildTripFile({ id: 72, original_name: 'boarding.pdf' });
|
|
(attached as unknown as { reservation_id: number }).reservation_id = 23;
|
|
|
|
render(<TransportModal {...defaultProps} reservation={res} files={[attached]} />);
|
|
const row = screen.getByText('boarding.pdf').closest('div') as HTMLElement;
|
|
await userEvent.click(within(row).getByRole('button', { name: /open/i }));
|
|
expect(screen.getByText('boarding.pdf')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-054: switching the transit day rescopes the quick picks', async () => {
|
|
const days = [
|
|
buildDay({ id: 10, date: '2025-06-01', title: 'Day one' }),
|
|
buildDay({ id: 11, date: '2025-06-02', title: 'Day two' }),
|
|
];
|
|
const louvre = buildPlace({ id: 1, name: 'Louvre' });
|
|
const eiffel = buildPlace({ id: 2, name: 'Eiffel Tower' });
|
|
const orsay = buildPlace({ id: 3, name: 'Musée d\'Orsay' });
|
|
const assignments = {
|
|
'10': [buildAssignment({ day_id: 10, place_id: louvre.id, place: louvre })],
|
|
// Two entries so the quick picks follow the day's own order.
|
|
'11': [
|
|
buildAssignment({ day_id: 11, order_index: 1, place_id: orsay.id, place: orsay }),
|
|
buildAssignment({ day_id: 11, order_index: 0, place_id: eiffel.id, place: eiffel }),
|
|
],
|
|
};
|
|
|
|
render(
|
|
<TransportModal
|
|
{...defaultProps}
|
|
days={days}
|
|
selectedDayId={10}
|
|
initialAutomated
|
|
places={[louvre, eiffel, orsay]}
|
|
assignments={assignments}
|
|
accommodations={[]}
|
|
/>,
|
|
);
|
|
|
|
await userEvent.click(screen.getByText('Day one'));
|
|
await userEvent.click(screen.getByRole('button', { name: /^Day two/ }));
|
|
|
|
const [fromInput] = screen.getAllByPlaceholderText('Search stop or station…');
|
|
await userEvent.click(fromInput);
|
|
expect(screen.getByText('Eiffel Tower')).toBeInTheDocument();
|
|
expect(screen.getByText(/Orsay/)).toBeInTheDocument();
|
|
expect(screen.queryByText('Louvre')).not.toBeInTheDocument();
|
|
});
|
|
|
|
// ── Blur booking codes in the edit form (#2457) ─────────────────────────────
|
|
|
|
describe('blur booking codes (#2457)', () => {
|
|
const blurOn = (on: boolean) => seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: on } });
|
|
const codeFields = () => screen.getAllByPlaceholderText('e.g. ABC12345') as HTMLInputElement[];
|
|
|
|
function multiLegTrain(): Reservation {
|
|
const res = buildReservation({ id: 32, title: 'Osaka → Kyoto → Nara', type: 'train' });
|
|
return Object.assign(res, {
|
|
day_id: 10,
|
|
end_day_id: 10,
|
|
confirmation_number: 'RAIL-BOOK',
|
|
metadata: {
|
|
legs: [
|
|
{ from: 'Osaka', to: 'Kyoto', train_number: 'JR 1', confirmation_number: 'RAIL-LEG1', dep_day_id: 10, dep_time: '08:00', arr_day_id: 10, arr_time: '08:30' },
|
|
{ from: 'Kyoto', to: 'Nara', train_number: 'JR 2', confirmation_number: 'RAIL-LEG2', dep_day_id: 10, dep_time: '09:00', arr_day_id: 10, arr_time: '09:45' },
|
|
],
|
|
},
|
|
endpoints: [
|
|
{ id: 11, reservation_id: 32, role: 'from', sequence: 0, name: 'Osaka Station', code: null, lat: 34.7, lng: 135.5, timezone: 'Asia/Tokyo', local_date: '2026-08-01', local_time: '08:00' },
|
|
{ id: 12, reservation_id: 32, role: 'stop', sequence: 1, name: 'Kyoto Station', code: null, lat: 34.98, lng: 135.75, timezone: 'Asia/Tokyo', local_date: '2026-08-01', local_time: '09:00' },
|
|
{ id: 13, reservation_id: 32, role: 'to', sequence: 2, name: 'Nara Station', code: null, lat: 34.68, lng: 135.82, timezone: 'Asia/Tokyo', local_date: '2026-08-01', local_time: '09:45' },
|
|
],
|
|
}) as unknown as Reservation;
|
|
}
|
|
|
|
it('FE-PLANNER-TRANSMODAL-065: the flight booking code field is blurred while the setting is on', () => {
|
|
blurOn(true);
|
|
const res = buildReservation({ title: 'LH 400', type: 'flight', confirmation_number: 'FLY-SECRET' });
|
|
render(<TransportModal {...defaultProps} reservation={res} />);
|
|
expect(isBlurred(screen.getByDisplayValue('FLY-SECRET'))).toBe(true);
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-066: every per-segment code of a stopover flight is blurred as well', () => {
|
|
blurOn(true);
|
|
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegFlight()} />);
|
|
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-PLANNER-TRANSMODAL-067: every per-segment code of a multi-leg train is blurred as well', () => {
|
|
blurOn(true);
|
|
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegTrain()} />);
|
|
const codes = codeFields();
|
|
expect(codes.map(i => i.value)).toEqual(['RAIL-LEG1', 'RAIL-LEG2', 'RAIL-BOOK']);
|
|
expect(codes.map(i => isBlurred(i))).toEqual([true, true, true]);
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-068: focusing a code field reveals it for editing, leaving it hides it again', () => {
|
|
blurOn(true);
|
|
const res = buildReservation({ title: 'LH 400', type: 'flight', confirmation_number: 'FLY-SECRET' });
|
|
render(<TransportModal {...defaultProps} reservation={res} />);
|
|
const code = screen.getByDisplayValue('FLY-SECRET') as HTMLInputElement;
|
|
expect(isBlurred(code)).toBe(true);
|
|
act(() => code.focus());
|
|
expect(isBlurred(code)).toBe(false);
|
|
act(() => code.blur());
|
|
expect(isBlurred(code)).toBe(true);
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-069: with the setting off every code field stays plain', () => {
|
|
blurOn(false);
|
|
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegFlight()} />);
|
|
expect(codeFields().map(i => isBlurred(i))).toEqual([false, false, false]);
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-070: blurred codes still save unchanged', async () => {
|
|
blurOn(true);
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegFlight()} onSave={onSave} />);
|
|
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
expect(payload.metadata.legs.map((l: { confirmation_number?: string }) => l.confirmation_number)).toEqual(['ABC123', 'XYZ789']);
|
|
expect(payload.confirmation_number).toBe('BOOK1');
|
|
});
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-071: the manual From and To fields offer the trip places that have a location, each once (#2468)', async () => {
|
|
const places = [
|
|
buildPlace({ id: 1, name: 'Louvre', lat: 48.86, lng: 2.33 }),
|
|
buildPlace({ id: 2, name: 'No pin yet', lat: null, lng: null }),
|
|
buildPlace({ id: 3, name: 'Louvre', lat: 48.86, lng: 2.33 }),
|
|
];
|
|
render(<TransportModal {...defaultProps} places={places} />);
|
|
await pickType(/^Bus$/i);
|
|
|
|
const fields = screen.getAllByTestId('location-select');
|
|
expect(fields).toHaveLength(2);
|
|
for (const field of fields) expect(field).toHaveAttribute('data-picks', 'Louvre');
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-072: every train station field offers the trip places too', async () => {
|
|
const places = [buildPlace({ id: 1, name: 'Berlin Hbf', lat: 52.52, lng: 13.37 })];
|
|
render(<TransportModal {...defaultProps} places={places} />);
|
|
await pickType(/^Train$/i);
|
|
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
|
|
|
const stations = screen.getAllByTestId('location-select');
|
|
expect(stations).toHaveLength(3);
|
|
for (const station of stations) expect(station).toHaveAttribute('data-picks', 'Berlin Hbf');
|
|
});
|
|
|
|
// ── Link field (#2084): transports keep a booking URL like bookings do ─────
|
|
|
|
it('FE-PLANNER-TRANSMODAL-073: a URL typed into the Link field is saved with the transport', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH 400');
|
|
await userEvent.type(screen.getByPlaceholderText('https://...'), 'https://lufthansa.example/booking');
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onSave.mock.calls[0][0]).toMatchObject({ url: 'https://lufthansa.example/booking' });
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-074: without a URL the transport saves url null', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH 400');
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onSave.mock.calls[0][0]).toHaveProperty('url', null);
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-075: editing pre-fills the stored URL, and clearing it saves null', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
const res = buildReservation({ id: 64, title: 'ICE 599', type: 'train', url: 'https://bahn.example/ticket' });
|
|
render(<TransportModal {...defaultProps} reservation={res} onSave={onSave} />);
|
|
const field = screen.getByPlaceholderText('https://...');
|
|
expect(field).toHaveValue('https://bahn.example/ticket');
|
|
|
|
await userEvent.clear(field);
|
|
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onSave.mock.calls[0][0]).toHaveProperty('url', null);
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-076: an edited transport keeps its URL when saved untouched', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
const res = buildReservation({ id: 65, title: 'Ferry', type: 'ferry', url: 'https://ferry.example' });
|
|
render(<TransportModal {...defaultProps} reservation={res} onSave={onSave} />);
|
|
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onSave.mock.calls[0][0]).toMatchObject({ url: 'https://ferry.example' });
|
|
});
|
|
|
|
// ── Dialog frame ─────────────────────────────────────────────────────────
|
|
|
|
it('FE-PLANNER-TRANSMODAL-077: Escape while the delete question is open takes back only the question', async () => {
|
|
const onClose = vi.fn();
|
|
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
|
|
render(<TransportModal {...defaultProps} reservation={res} onDelete={vi.fn()} onClose={onClose} />);
|
|
await userEvent.click(screen.getByRole('button', { name: /delete/i }));
|
|
expect(screen.getByText('Delete booking?')).toBeInTheDocument();
|
|
|
|
await userEvent.keyboard('{Escape}');
|
|
expect(screen.queryByText('Delete booking?')).not.toBeInTheDocument();
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
|
|
await userEvent.keyboard('{Escape}');
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-078: a transit journey edited by hand reads "Transit" on the type pill and saves as transit', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
const res = buildReservation({ title: 'Tram 12', type: 'transit' });
|
|
render(<TransportModal {...defaultProps} reservation={res} onSave={onSave} />);
|
|
// The list does not offer transit, so the pill falls back to the type's own name.
|
|
expect(typeField()).toHaveAccessibleName(/^Booking Type: ?Transit$/);
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
expect(onSave.mock.calls[0][0]).toMatchObject({ type: 'transit' });
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-079: a blank title says it is required under the field', async () => {
|
|
render(<TransportModal {...defaultProps} />);
|
|
expect(screen.getByText('Title *')).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /^Add$/i })).toBeDisabled();
|
|
|
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH 400');
|
|
expect(screen.queryByText('Title *')).not.toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /^Add$/i })).toBeEnabled();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-080: Automated shows the whole search hint in the head band, free to wrap', async () => {
|
|
render(<TransportModal {...defaultProps} places={[]} accommodations={[]} />);
|
|
await userEvent.click(screen.getByRole('button', { name: 'Automated' }));
|
|
const hint = screen.getByText(/Search real connections and add them straight to the day/);
|
|
expect(hint).not.toHaveClass('truncate');
|
|
expect(screen.queryByText('Title *')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-081: a car stop moves up and keeps the time given to it', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
|
|
|
await pickType(/^Car$/i);
|
|
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen');
|
|
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
|
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
|
|
|
const fields = screen.getAllByTestId('location-select');
|
|
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
|
|
fireEvent.change(fields[1], { target: { value: 'Prag' } });
|
|
fireEvent.change(fields[2], { target: { value: 'Dresden' } });
|
|
fireEvent.change(fields[3], { target: { value: 'Bastei' } });
|
|
// The stops' own time fields come first, before the pick-up and return times.
|
|
fireEvent.change(screen.getAllByTestId('time-picker')[1], { target: { value: '14:30' } });
|
|
|
|
await userEvent.click(screen.getAllByRole('button', { name: /Move up/i })[1]);
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const payload = onSave.mock.calls[0][0];
|
|
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin', 'Bastei', 'Dresden', 'Prag']);
|
|
expect(payload.endpoints.find((e: { name: string }) => e.name === 'Bastei').local_time).toBe('14:30');
|
|
});
|
|
|
|
it('FE-PLANNER-TRANSMODAL-082: a multi-leg train saves the code typed for one of its segments', async () => {
|
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
|
render(<TransportModal {...defaultProps} days={routeDays} reservation={multiLegTrain()} onSave={onSave} />);
|
|
|
|
const codes = screen.getAllByPlaceholderText('e.g. ABC12345') as HTMLInputElement[];
|
|
// One code per segment, then the booking's own.
|
|
expect(codes).toHaveLength(3);
|
|
fireEvent.change(codes[1], { target: { value: 'SEG-TWO' } });
|
|
|
|
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
|
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
|
const legs = onSave.mock.calls[0][0].metadata.legs as { confirmation_number?: string }[];
|
|
expect(legs.map(l => l.confirmation_number ?? null)).toEqual([null, 'SEG-TWO']);
|
|
});
|
|
});
|