Files
TREK/client/src/components/Planner/ReservationsPanel.test.tsx
T
Maurice 0dd07befa7 feat(planner): bring the plan tab and every trip dialog into the new look
The Plan tab was the last trip tab still in the old design. It now reads
like Bookings, Costs, Lists, Files and Collab, without losing a function:

- Day plan: each day is a card with a head band (day tile with weather,
  stay and rental pills, a "+" menu for place, stay, transport, transit
  and note), rows with "..." menus, white badges and a white route bar.
- Places column: add, search and filters in three rows, a show dropdown,
  category and rating filters, and a selection bar at the foot.
- Place inspector, day details and the stay editor on the shared dialog
  frame; the place form in the editor look with the name in the head band.
- Clicking a booking or transport anywhere on the plan opens the booking
  detail popup first; Edit leads to the editor. The popup is one host
  shared with the Bookings and Transports tabs.
- Files and Collab dialogs, notes, polls, links and chat moved to the same
  frame; shared DialogShell and dialogParts hold the building blocks.
- The map hover card shows the place photo and follows the theme.

Every new and reworded string is translated into all locales, and a few
older English leftovers (help center, cost split, import hints) with them.
Phone screens keep their own look.
2026-09-27 21:00:19 +02:00

970 lines
55 KiB
TypeScript

// FE-COMP-RES-001 to FE-COMP-RES-040, FE-PLANNER-RESP-016 to FE-PLANNER-RESP-080
import { render, screen, fireEvent, waitFor, within } 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 { useSettingsStore } from '../../store/settingsStore';
import { usePermissionsStore } from '../../store/permissionsStore';
import { usePluginStore } from '../../store/pluginStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser, buildTrip, buildReservation, buildDay, buildPlace } from '../../../tests/helpers/factories';
import { openFile } from '../../utils/fileDownload';
import { formatMoney } from '../../utils/formatters';
import ReservationsPanel from './ReservationsPanel';
vi.mock('../../api/authUrl', () => ({ getAuthUrl: vi.fn().mockResolvedValue('http://test/file') }));
vi.mock('../../utils/fileDownload', () => ({ openFile: vi.fn(async () => {}) }));
// A card shows a price the way formatMoney writes it; getByText collapses the
// no-break space Intl puts between amount and symbol, so the expectation does too.
const money = (amount: number, currency: string) => formatMoney(amount, currency, 'en').replace(/\s/g, ' ');
const defaultProps = {
tripId: 1,
reservations: [],
days: [],
assignments: {},
files: [],
onAdd: vi.fn(),
onEdit: vi.fn(),
onDelete: vi.fn(),
onNavigateToFiles: vi.fn(),
};
/** The question a delete asks sits in its own portal; its Delete is the last one on the page. */
const confirmDeleteButton = () => screen.getAllByRole('button', { name: 'Delete' }).at(-1)!;
/** A card's head band carries its own Edit and Delete, named by their tooltips. */
const cardAction = (title: string, name: 'Edit' | 'Delete') =>
within(screen.getByRole('article', { name: title })).getByRole('button', { name });
/** The filters sit behind the Filter button in the bar. */
async function openFilters(user: ReturnType<typeof userEvent.setup>) {
await user.click(screen.getByRole('button', { name: 'Filter' }));
}
const FILTER_KEY = 'trek-bookings-filters-bookings-1';
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }) });
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: false, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', map_tile_url: '', show_place_description: false } });
server.use(
http.get('/api/view-contributions/:view/:tripId', () => HttpResponse.json({ contributions: [] })),
);
});
describe('ReservationsPanel', () => {
it('FE-COMP-RES-001: renders without crashing', () => {
render(<ReservationsPanel {...defaultProps} />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-RES-002: shows Bookings title', () => {
render(<ReservationsPanel {...defaultProps} />);
// reservations.title = "Bookings"
expect(screen.getByText('Bookings')).toBeInTheDocument();
});
it('FE-COMP-RES-003: shows empty state when no reservations', () => {
render(<ReservationsPanel {...defaultProps} reservations={[]} />);
// "No reservations yet" appears in both header subtitle and empty state body
const els = screen.getAllByText('No reservations yet');
expect(els.length).toBeGreaterThan(0);
});
it('FE-COMP-RES-004: shows empty-state mascot illustration', () => {
render(<ReservationsPanel {...defaultProps} reservations={[]} />);
// The mobile rewrite dropped the hint subtitle in favour of the shared
// EmptyState mascot; the bookings scene renders the TREK mark svg.
expect(document.querySelector('.trek--bookings')).toBeInTheDocument();
});
it('FE-COMP-RES-005: shows Manual Booking add button', () => {
render(<ReservationsPanel {...defaultProps} />);
// Button text is reservations.addManual = "Manual Booking" — in the toolbar
// and, on an empty list, in the empty state's call to action too (#2007).
expect(screen.getAllByText('Manual Booking').length).toBeGreaterThan(0);
});
it('FE-COMP-RES-006: clicking Manual Booking button calls onAdd', async () => {
const user = userEvent.setup();
const onAdd = vi.fn();
render(<ReservationsPanel {...defaultProps} onAdd={onAdd} />);
await user.click(screen.getAllByText('Manual Booking')[0]);
expect(onAdd).toHaveBeenCalled();
});
it('FE-COMP-RES-007: renders reservation title', () => {
// Component renders r.title, not r.name
const res = buildReservation({ title: 'Hotel Paris', type: 'hotel', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
expect(screen.getByText('Hotel Paris')).toBeInTheDocument();
});
it('FE-COMP-RES-008: renders confirmed reservation badge', () => {
const res = buildReservation({ title: 'Flight NY', type: 'flight', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
// "Confirmed" appears in both section header and card badge
const els = screen.getAllByText('Confirmed');
expect(els.length).toBeGreaterThan(0);
});
it('FE-COMP-RES-009: renders pending reservation badge', () => {
const res = buildReservation({ title: 'Hotel Rome', type: 'hotel', status: 'pending' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
// "Pending" appears in both section header and card badge
const els = screen.getAllByText('Pending');
expect(els.length).toBeGreaterThan(0);
});
it('FE-COMP-RES-010: shows reservations title and cards', () => {
const r1 = buildReservation({ title: 'My Flight Booking', type: 'flight', status: 'confirmed' });
const r2 = buildReservation({ title: 'Grand Hotel', type: 'hotel', status: 'pending' });
render(<ReservationsPanel {...defaultProps} reservations={[r1, r2]} />);
expect(screen.getByText('My Flight Booking')).toBeInTheDocument();
expect(screen.getByText('Grand Hotel')).toBeInTheDocument();
});
it('FE-COMP-RES-011: hotel reservation renders', () => {
const res = buildReservation({ title: 'Grand Hotel', type: 'hotel', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
expect(screen.getByText('Grand Hotel')).toBeInTheDocument();
});
it('FE-COMP-RES-012: flight reservation renders', () => {
const res = buildReservation({ title: 'Air France 123', type: 'flight', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
expect(screen.getByText('Air France 123')).toBeInTheDocument();
});
it('FE-COMP-RES-013: multiple reservations all render', () => {
const r1 = buildReservation({ title: 'Hotel A', type: 'hotel', status: 'confirmed' });
const r2 = buildReservation({ title: 'Flight B', type: 'flight', status: 'confirmed' });
const r3 = buildReservation({ title: 'Restaurant C', type: 'restaurant', status: 'pending' });
render(<ReservationsPanel {...defaultProps} reservations={[r1, r2, r3]} />);
expect(screen.getByText('Hotel A')).toBeInTheDocument();
expect(screen.getByText('Flight B')).toBeInTheDocument();
expect(screen.getByText('Restaurant C')).toBeInTheDocument();
});
it('FE-COMP-RES-014: edit button calls onEdit with reservation', async () => {
const user = userEvent.setup();
const onEdit = vi.fn();
const res = buildReservation({ id: 77, title: 'Editable Res', type: 'hotel', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} onEdit={onEdit} />);
await user.click(cardAction('Editable Res', 'Edit'));
expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ id: 77 }));
});
it('FE-COMP-RES-015: delete button opens confirm dialog, then calls onDelete', async () => {
const user = userEvent.setup();
const onDelete = vi.fn().mockResolvedValue(undefined);
const res = buildReservation({ id: 88, title: 'Delete Me', type: 'hotel', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} onDelete={onDelete} />);
await user.click(cardAction('Delete Me', 'Delete'));
await screen.findByText(/will be permanently deleted/i);
await user.click(confirmDeleteButton());
await waitFor(() => expect(onDelete).toHaveBeenCalledWith(88));
});
// ── Section collapsing ──────────────────────────────────────────────────────
it('FE-PLANNER-RESP-016: clicking Pending section header collapses it', async () => {
const user = userEvent.setup();
const res = buildReservation({ title: 'Pending Hotel', type: 'hotel', status: 'pending' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
// Initially the card is visible
expect(screen.getByText('Pending Hotel')).toBeInTheDocument();
// Click the "Pending" section header button (the one with count badge)
const pendingButtons = screen.getAllByText('Pending');
// The section header button contains "Pending" text
const sectionHeaderBtn = pendingButtons.find(el => el.closest('button'));
await user.click(sectionHeaderBtn!.closest('button')!);
// Card should no longer be visible
expect(screen.queryByText('Pending Hotel')).not.toBeInTheDocument();
});
it('FE-PLANNER-RESP-017: clicking Pending section header again expands it', async () => {
const user = userEvent.setup();
const res = buildReservation({ title: 'Pending Train', type: 'train', status: 'pending' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
const pendingButtons = screen.getAllByText('Pending');
const sectionHeaderBtn = pendingButtons.find(el => el.closest('button'));
// Collapse
await user.click(sectionHeaderBtn!.closest('button')!);
expect(screen.queryByText('Pending Train')).not.toBeInTheDocument();
// Re-query after collapse
const pendingButtons2 = screen.getAllByText('Pending');
const sectionHeaderBtn2 = pendingButtons2.find(el => el.closest('button'));
// Expand
await user.click(sectionHeaderBtn2!.closest('button')!);
expect(screen.getByText('Pending Train')).toBeInTheDocument();
});
it('FE-PLANNER-RESP-018: confirmed and pending sections render separately', () => {
const confirmed = buildReservation({ title: 'Confirmed Flight', type: 'flight', status: 'confirmed' });
const pending = buildReservation({ title: 'Pending Restaurant', type: 'restaurant', status: 'pending' });
render(<ReservationsPanel {...defaultProps} reservations={[confirmed, pending]} />);
// Both section labels should appear (as buttons or spans in card headers, plus section titles)
const confirmedEls = screen.getAllByText('Confirmed');
const pendingEls = screen.getAllByText('Pending');
expect(confirmedEls.length).toBeGreaterThan(0);
expect(pendingEls.length).toBeGreaterThan(0);
});
// ── ReservationCard details ─────────────────────────────────────────────────
it('FE-PLANNER-RESP-019: reservation with date shows formatted date', () => {
const res = buildReservation({ reservation_time: '2025-06-15', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
// Should show some form of Jun 15 formatted date
expect(screen.getByText(/Jun/i)).toBeInTheDocument();
});
it('FE-PLANNER-RESP-020: reservation with ISO datetime shows time', () => {
const res = buildReservation({ reservation_time: '2025-06-15T14:30:00Z', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
// Time column should appear (exact format depends on locale/env but contains hour:minute)
expect(screen.getByText(/\d{1,2}:\d{2}/)).toBeInTheDocument();
});
it('FE-PLANNER-RESP-021: confirmation number is visible by default (no blur)', () => {
const res = buildReservation({ confirmation_number: 'ABC123', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
expect(screen.getByText('ABC123')).toBeInTheDocument();
});
it('FE-PLANNER-RESP-022: confirmation number is blurred when blur_booking_codes=true', () => {
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', map_tile_url: '', show_place_description: false } });
const res = buildReservation({ confirmation_number: 'ABC123', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
expect(screen.getByText('ABC123').className).toContain('blur-[4px]');
});
it('FE-PLANNER-RESP-023: a blurred confirmation code uncovers on hover', () => {
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', map_tile_url: '', show_place_description: false } });
const res = buildReservation({ confirmation_number: 'ABC123', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
const codeEl = screen.getByText('ABC123');
expect(codeEl.className).toContain('blur-[4px]');
// The hover reveal is the stylesheet's: the blur drops while the pointer rests on it.
expect(codeEl.className).toContain('hover:blur-none');
});
const layoverFlight = () => buildReservation({
id: 7, type: 'flight', status: 'confirmed', confirmation_number: 'BOOK1',
metadata: JSON.stringify({
departure_airport: 'FRA', arrival_airport: 'HND',
legs: [
{ from: 'FRA', to: 'BER', confirmation_number: 'ABC123' },
{ from: 'BER', to: 'HND' },
],
}),
});
it('FE-PLANNER-RESP-077: a segment with its own booking code shows it under its route (#1943)', () => {
render(<ReservationsPanel {...defaultProps} reservations={[layoverFlight()]} />);
// The booking's own reference keeps its own cell.
expect(screen.getByText('BOOK1')).toBeInTheDocument();
expect(screen.getByText('FRA → BER')).toBeInTheDocument();
expect(screen.getByText('ABC123')).toBeInTheDocument();
// A segment without its own code adds no cell.
expect(screen.queryByText('BER → HND')).not.toBeInTheDocument();
});
it('FE-PLANNER-RESP-078: a segment code obeys blur_booking_codes and uncovers on its own', async () => {
const user = userEvent.setup();
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', map_tile_url: '', show_place_description: false } });
render(<ReservationsPanel {...defaultProps} reservations={[layoverFlight()]} />);
const legCode = screen.getByText('ABC123');
expect(legCode.className).toContain('blur-[4px]');
await user.click(legCode);
expect(legCode).toHaveAttribute('aria-pressed', 'true');
expect(legCode.className).not.toContain('blur-[4px]');
// Each code is its own reveal: the booking's reference stays covered.
expect(screen.getByText('BOOK1').className).toContain('blur-[4px]');
});
it('FE-PLANNER-RESP-024: reservation notes are shown', () => {
const res = buildReservation({ notes: 'Window seat requested', status: 'pending' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
expect(screen.getByText('Window seat requested')).toBeInTheDocument();
});
it('FE-PLANNER-RESP-025: reservation location is shown', () => {
const res = buildReservation({ location: 'Charles de Gaulle Airport', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
expect(screen.getByText('Charles de Gaulle Airport')).toBeInTheDocument();
});
it('FE-PLANNER-RESP-026: flight metadata (airline, flight number) renders', () => {
const res = buildReservation({
type: 'flight',
status: 'confirmed',
metadata: JSON.stringify({ airline: 'Air France', flight_number: 'AF001', departure_airport: 'CDG', arrival_airport: 'JFK' }),
});
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
expect(screen.getByText('Air France')).toBeInTheDocument();
expect(screen.getByText('AF001')).toBeInTheDocument();
});
it('FE-PLANNER-RESP-027: train metadata (train number, platform, seat) renders', () => {
const res = buildReservation({
type: 'train',
status: 'confirmed',
metadata: JSON.stringify({ train_number: 'TGV9876', platform: '3', seat: '42A' }),
});
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
expect(screen.getByText('TGV9876')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('42A')).toBeInTheDocument();
});
it('FE-PLANNER-RESP-028: hotel check-in/check-out metadata renders', () => {
const res = buildReservation({
type: 'hotel',
status: 'confirmed',
metadata: JSON.stringify({ check_in_time: '14:00', check_out_time: '11:00' }),
});
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
expect(screen.getByText('14:00')).toBeInTheDocument();
expect(screen.getByText('11:00')).toBeInTheDocument();
});
it('FE-PLANNER-RESP-029: linked assignment shows day title and place name', () => {
const place = buildPlace({ name: 'Eiffel Tower', place_time: '10:00' });
const assignmentId = 55;
const day = { ...buildDay({ id: 1, title: 'Day 1', date: '2025-06-01' }), day_number: 1 } as any;
const assignments = { '1': [{ id: assignmentId, order_index: 0, day_id: 1, place_id: place.id, notes: null, place }] };
const res = buildReservation({ assignment_id: assignmentId, status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} days={[day]} assignments={assignments} />);
expect(screen.getByText(/Day 1/)).toBeInTheDocument();
expect(screen.getByText(/Eiffel Tower/)).toBeInTheDocument();
});
// ── Status toggle (canEdit=true) ────────────────────────────────────────────
it('FE-PLANNER-RESP-030: status label is always a span (not clickable)', () => {
const res = buildReservation({ title: 'My Booking', status: 'pending' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
const pendingEls = screen.getAllByText('Pending');
const statusSpan = pendingEls.find(el => el.tagName === 'SPAN');
expect(statusSpan).toBeDefined();
const statusBtn = pendingEls.find(el => el.tagName === 'BUTTON');
expect(statusBtn).toBeUndefined();
});
// ── Status (canEdit=false) ──────────────────────────────────────────────────
it('FE-PLANNER-RESP-032: status label is a span (not button) when canEdit=false', () => {
seedStore(usePermissionsStore, { permissions: { reservation_edit: 'admin' } });
const res = buildReservation({ title: 'Read Only', status: 'pending' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
const pendingEls = screen.getAllByText('Pending');
const statusSpan = pendingEls.find(el => el.tagName === 'SPAN');
expect(statusSpan).toBeDefined();
const statusBtn = pendingEls.find(el => el.tagName === 'BUTTON');
expect(statusBtn).toBeUndefined();
});
it('FE-PLANNER-RESP-033: edit and delete buttons hidden when canEdit=false', () => {
seedStore(usePermissionsStore, { permissions: { reservation_edit: 'admin' } });
const res = buildReservation({ title: 'Read Only', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
});
// ── Delete confirmation ─────────────────────────────────────────────────────
it('FE-PLANNER-RESP-034: delete confirm dialog shows reservation title', async () => {
const user = userEvent.setup();
const res = buildReservation({ id: 99, title: 'Paris Hotel', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
await user.click(cardAction('Paris Hotel', 'Delete'));
const dialogBody = await screen.findByText(/will be permanently deleted/i);
expect(dialogBody.textContent).toContain('Paris Hotel');
});
it('FE-PLANNER-RESP-035: clicking Cancel in delete dialog closes it without calling onDelete', async () => {
const user = userEvent.setup();
const onDelete = vi.fn();
const res = buildReservation({ id: 100, title: 'Cancel Test', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} onDelete={onDelete} />);
await user.click(cardAction('Cancel Test', 'Delete'));
await user.click(await screen.findByRole('button', { name: 'Cancel' }));
expect(onDelete).not.toHaveBeenCalled();
expect(screen.queryByText(/will be permanently deleted/i)).not.toBeInTheDocument();
});
it('FE-PLANNER-RESP-036: clicking backdrop closes delete confirm dialog', async () => {
const user = userEvent.setup();
const res = buildReservation({ id: 101, title: 'Backdrop Test', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
await user.click(cardAction('Backdrop Test', 'Delete'));
const body = await screen.findByText(/will be permanently deleted/i);
// The dimmed layer around the question is the outermost element of its portal.
const backdrop = body.closest('.fixed.inset-0') as HTMLElement;
fireEvent.click(backdrop);
await waitFor(() => expect(screen.queryByText(/will be permanently deleted/i)).not.toBeInTheDocument());
});
// ── Files ───────────────────────────────────────────────────────────────────
it('FE-PLANNER-RESP-037: attached files section appears for reservation with files', () => {
const res = buildReservation({ id: 77, status: 'confirmed' });
const files = [{ id: 1, trip_id: 1, reservation_id: 77, original_name: 'boarding_pass.pdf', url: '/uploads/bp.pdf', filename: 'bp.pdf', mime_type: 'application/pdf', created_at: '2025-01-01T00:00:00.000Z' }];
render(<ReservationsPanel {...defaultProps} reservations={[res]} files={files} />);
expect(screen.getByText('boarding_pass.pdf')).toBeInTheDocument();
});
it('FE-PLANNER-RESP-038: linked file (via linked_reservation_ids) also appears', () => {
const res = buildReservation({ id: 77, status: 'confirmed' });
const files = [{ id: 2, trip_id: 1, reservation_id: null, linked_reservation_ids: [77], original_name: 'voucher.pdf', url: '/uploads/v.pdf', filename: 'v.pdf', mime_type: 'application/pdf', created_at: '2025-01-01T00:00:00.000Z' }];
render(<ReservationsPanel {...defaultProps} reservations={[res]} files={files as any} />);
expect(screen.getByText('voucher.pdf')).toBeInTheDocument();
});
// ── Add button ──────────────────────────────────────────────────────────────
it('FE-PLANNER-RESP-039: "Add" button hidden when canEdit=false', () => {
seedStore(usePermissionsStore, { permissions: { reservation_edit: 'admin' } });
render(<ReservationsPanel {...defaultProps} />);
expect(screen.queryByText('Manual Booking')).not.toBeInTheDocument();
});
it('FE-PLANNER-RESP-040: multiple reservations in pending section all render', () => {
const r1 = buildReservation({ title: 'Pending 1', status: 'pending' });
const r2 = buildReservation({ title: 'Pending 2', status: 'pending' });
const r3 = buildReservation({ title: 'Pending 3', status: 'pending' });
render(<ReservationsPanel {...defaultProps} reservations={[r1, r2, r3]} />);
expect(screen.getByText('Pending 1')).toBeInTheDocument();
expect(screen.getByText('Pending 2')).toBeInTheDocument();
expect(screen.getByText('Pending 3')).toBeInTheDocument();
});
it('FE-PLANNER-RESP-041: dateless transport with legacy T-prefix shows time without "Invalid Date"', () => {
const day = buildDay({ date: null, day_number: 25 } as any);
const r = buildReservation({
title: 'Cruise test',
type: 'cruise',
status: 'pending',
reservation_time: 'T10:00',
reservation_end_time: 'T18:00',
day_id: day.id,
end_day_id: day.id,
} as any);
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
expect(screen.getByText(/10:00/)).toBeInTheDocument();
});
it('FE-PLANNER-RESP-042: dateless transport with bare time format shows time without "Invalid Date"', () => {
const day = buildDay({ date: null, day_number: 3 } as any);
const r = buildReservation({
title: 'Car rental',
type: 'car',
status: 'pending',
reservation_time: '09:00',
reservation_end_time: '17:00',
day_id: day.id,
end_day_id: day.id,
} as any);
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
expect(screen.getByText(/09:00/)).toBeInTheDocument();
});
it('FE-PLANNER-RESP-043: dated transport still shows date and time correctly', () => {
const day = buildDay({ date: '2026-07-15', day_number: 1 });
const r = buildReservation({
title: 'Flight out',
type: 'flight',
status: 'confirmed',
reservation_time: '2026-07-15T08:30',
reservation_end_time: '2026-07-15T10:45',
day_id: day.id,
} as any);
render(<ReservationsPanel {...defaultProps} reservations={[r]} days={[day]} />);
expect(screen.queryByText(/Invalid Date/)).not.toBeInTheDocument();
expect(screen.getByText(/08:30/)).toBeInTheDocument();
});
// ── Chronological sorting (#1507) ───────────────────────────────────────────
it('FE-PLANNER-RESP-044: cards are ordered chronologically, day-linked entries by their day date', () => {
const day1 = buildDay({ id: 201, date: '2025-06-02', day_number: 2 } as any);
const day2 = buildDay({ id: 202, date: '2025-06-04', day_number: 4 } as any);
const dated = buildReservation({ title: 'Dated flight', type: 'flight', status: 'pending', reservation_time: '2025-06-03T09:00', created_at: '2025-05-01T00:00:00.000Z' });
const dayOnly = buildReservation({ title: 'Day-only train', type: 'train', status: 'pending', reservation_time: 'T10:00', day_id: 201, created_at: '2025-05-02T00:00:00.000Z' } as any);
const late = buildReservation({ title: 'Late bus', type: 'bus', status: 'pending', reservation_time: null, day_id: 202, created_at: '2025-05-03T00:00:00.000Z' } as any);
const undated = buildReservation({ title: 'Undated taxi', type: 'taxi', status: 'pending', created_at: '2025-04-01T00:00:00.000Z' });
render(<ReservationsPanel {...defaultProps} reservations={[undated, late, dayOnly, dated]} days={[day1, day2]} />);
const text = document.body.textContent || '';
const order = ['Day-only train', 'Dated flight', 'Late bus', 'Undated taxi'].map(t => text.indexOf(t));
expect(order.every(i => i >= 0)).toBe(true);
expect([...order].sort((a, b) => a - b)).toEqual(order);
});
it('FE-PLANNER-RESP-045: hotel sorts by its accommodation start day, not a stale day_id', () => {
const day1 = buildDay({ id: 301, date: '2025-06-01', day_number: 1 } as any);
const day2 = buildDay({ id: 302, date: '2025-06-05', day_number: 5 } as any);
const hotel = buildReservation({ title: 'Hotel stay', type: 'hotel', status: 'pending', day_id: 301, accommodation_start_day_id: 302 } as any);
const flight = buildReservation({ title: 'Mid flight', type: 'flight', status: 'pending', reservation_time: '2025-06-03T12:00' });
render(<ReservationsPanel {...defaultProps} reservations={[hotel, flight]} days={[day1, day2]} />);
const text = document.body.textContent || '';
expect(text.indexOf('Mid flight')).toBeLessThan(text.indexOf('Hotel stay'));
});
// AirTrail sync badge — three states (#1646)
/** Hovers the AirTrail badge and returns the tooltip it raises. */
async function airTrailTooltip(user: ReturnType<typeof userEvent.setup>, badgeText: string) {
await user.hover(screen.getByText(badgeText));
return screen.findByRole('tooltip');
}
it('FE-PLANNER-RESP-046: a synced AirTrail flight shows the AirTrail badge', async () => {
const user = userEvent.setup();
const res = buildReservation({ title: 'Synced flight', type: 'flight', external_source: 'airtrail', sync_enabled: 1 } as any);
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
expect(screen.queryByText('Not synced')).not.toBeInTheDocument();
expect(await airTrailTooltip(user, 'AirTrail')).toHaveTextContent('Synced from AirTrail — edits stay in sync both ways.');
});
it('FE-PLANNER-RESP-047: a multi-leg import shows the layover hint, not the "removed" message', async () => {
const user = userEvent.setup();
const res = buildReservation({
title: 'Layover flight', type: 'flight', external_source: 'airtrail', sync_enabled: 0,
metadata: JSON.stringify({ legs: [{ from: 'AMS' }, { from: 'IST' }] }),
} as any);
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
// Not falsely labelled "Not synced" / "removed in AirTrail"…
expect(screen.queryByText('Not synced')).not.toBeInTheDocument();
// …and carries the truthful layover explanation.
expect(await airTrailTooltip(user, 'AirTrail')).toHaveTextContent(
'Imported from AirTrail. A multi-leg flight with a layover has no single AirTrail flight to sync back to, so it stays as a one-time import.',
);
});
it('FE-PLANNER-RESP-048: a single-leg flight removed upstream still shows "Not synced"', async () => {
const user = userEvent.setup();
const res = buildReservation({ title: 'Removed flight', type: 'flight', external_source: 'airtrail', sync_enabled: 0 } as any);
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
expect(await airTrailTooltip(user, 'Not synced')).toHaveTextContent('This flight was removed in AirTrail and no longer syncs.');
});
it('FE-PLANNER-RESP-049: a flight grown into multiple legs locally (endpoints > 2, no legs array) shows the layover hint, not "Not synced"', async () => {
const user = userEvent.setup();
// Matches the server's second hasLocalMultiLegShape criterion (endpoint count > 2).
const res = buildReservation({
title: 'Grown multi-leg', type: 'flight', external_source: 'airtrail', sync_enabled: 0,
endpoints: [{ sequence: 0 }, { sequence: 1 }, { sequence: 2 }],
} as any);
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
expect(screen.queryByText('Not synced')).not.toBeInTheDocument();
expect(await airTrailTooltip(user, 'AirTrail')).toHaveTextContent(
'Imported from AirTrail. A multi-leg flight with a layover has no single AirTrail flight to sync back to, so it stays as a one-time import.',
);
});
// ── Type + traveler filters ─────────────────────────────────────────────────
it('FE-PLANNER-RESP-050: a type filter narrows the list and resetting restores it', async () => {
const user = userEvent.setup();
const flight = buildReservation({ id: 1, title: 'Flight out', type: 'flight', status: 'confirmed' });
const hotel = buildReservation({ id: 2, title: 'Hotel stay', type: 'hotel', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[flight, hotel]} />);
await openFilters(user);
await user.click(screen.getByRole('button', { name: /^Flight\s*1$/ }));
expect(screen.getByText('Flight out')).toBeInTheDocument();
expect(screen.queryByText('Hotel stay')).not.toBeInTheDocument();
// The choice is remembered for this trip and this tab.
expect(JSON.parse(sessionStorage.getItem(FILTER_KEY) || '{}').types).toEqual(['flight']);
await user.click(screen.getByRole('button', { name: 'Reset filters' }));
expect(screen.getByText('Hotel stay')).toBeInTheDocument();
expect(JSON.parse(sessionStorage.getItem(FILTER_KEY) || '{}').types).toEqual([]);
});
it('FE-PLANNER-RESP-051: clicking an active type filter clears it again', async () => {
const user = userEvent.setup();
const flight = buildReservation({ id: 1, title: 'Flight out', type: 'flight', status: 'confirmed' });
const hotel = buildReservation({ id: 2, title: 'Hotel stay', type: 'hotel', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[flight, hotel]} />);
await openFilters(user);
await user.click(screen.getByRole('button', { name: /^Flight\s*1$/ }));
expect(screen.queryByText('Hotel stay')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /^Flight\s*1$/ }));
expect(screen.getByText('Hotel stay')).toBeInTheDocument();
});
it('FE-PLANNER-RESP-052: a filter that matches nothing shows the "none found" hint, not the empty state', () => {
// A stored filter for a type this trip no longer has.
sessionStorage.setItem(FILTER_KEY, JSON.stringify({ types: ['bus'], status: 'all', travelers: [] }));
const flight = buildReservation({ id: 1, title: 'Flight out', type: 'flight', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[flight]} />);
expect(screen.getByText('Nothing matches these filters')).toBeInTheDocument();
expect(screen.queryByText('Flight out')).not.toBeInTheDocument();
expect(document.querySelector('.trek--bookings')).toBeNull();
});
it('FE-PLANNER-RESP-053: corrupt stored filters are ignored instead of crashing the panel', () => {
sessionStorage.setItem(FILTER_KEY, '{not json');
const res = buildReservation({ id: 1, title: 'Still here', type: 'flight', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
expect(screen.getByText('Still here')).toBeInTheDocument();
});
it('FE-PLANNER-RESP-054: a persisted type filter is restored on mount', () => {
sessionStorage.setItem(FILTER_KEY, JSON.stringify({ types: ['hotel'], status: 'all', travelers: [] }));
const flight = buildReservation({ id: 1, title: 'Flight out', type: 'flight', status: 'confirmed' });
const hotel = buildReservation({ id: 2, title: 'Hotel stay', type: 'hotel', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[flight, hotel]} />);
expect(screen.getByText('Hotel stay')).toBeInTheDocument();
expect(screen.queryByText('Flight out')).not.toBeInTheDocument();
});
it('FE-PLANNER-RESP-055: the traveler filter keeps only that traveler\'s bookings and persists', async () => {
const user = userEvent.setup();
const members = [{ id: 1, username: 'ada' }, { id: 2, username: 'bob' }];
const adas = buildReservation({ id: 1, title: 'Ada flight', type: 'flight', status: 'confirmed', travelers: [{ user_id: 1, username: 'ada', avatar_url: null }] } as any);
const bobs = buildReservation({ id: 2, title: 'Bob hotel', type: 'hotel', status: 'confirmed', travelers: [{ user_id: 2, username: 'bob', avatar_url: null }] } as any);
render(<ReservationsPanel {...defaultProps} reservations={[adas, bobs]} tripMembers={members} />);
await openFilters(user);
const ada = screen.getByRole('button', { name: /ada/, pressed: false });
await user.click(ada);
expect(screen.getByText('Ada flight')).toBeInTheDocument();
expect(screen.queryByText('Bob hotel')).not.toBeInTheDocument();
expect(JSON.parse(sessionStorage.getItem(FILTER_KEY) || '{}').travelers).toEqual([1]);
await user.click(screen.getByRole('button', { name: /ada/, pressed: true }));
expect(screen.getByText('Bob hotel')).toBeInTheDocument();
});
it('FE-PLANNER-RESP-056: assigned travelers are shown on the card', () => {
const res = buildReservation({ id: 1, title: 'Shared flight', type: 'flight', status: 'confirmed', travelers: [{ user_id: 1, username: 'ada', avatar_url: null }] } as any);
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
expect(screen.getByText('Travelers')).toBeInTheDocument();
});
// ── Toolbar actions ─────────────────────────────────────────────────────────
it('FE-PLANNER-RESP-057: the import and AirTrail buttons appear only when enabled and call their handlers', async () => {
const user = userEvent.setup();
const onImport = vi.fn();
const onAirTrailImport = vi.fn();
const { rerender } = render(<ReservationsPanel {...defaultProps} onImport={onImport} onAirTrailImport={onAirTrailImport} />);
// Both handlers given but the server features are off — nothing rendered.
expect(screen.queryByRole('button', { name: 'Import booking confirmations' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Import from AirTrail' })).not.toBeInTheDocument();
rerender(<ReservationsPanel {...defaultProps} onImport={onImport} bookingImportAvailable onAirTrailImport={onAirTrailImport} airTrailAvailable />);
await user.click(screen.getByRole('button', { name: 'Import booking confirmations' }));
await user.click(screen.getByRole('button', { name: 'Import from AirTrail' }));
expect(onImport).toHaveBeenCalled();
expect(onAirTrailImport).toHaveBeenCalled();
});
it('FE-PLANNER-RESP-058: the add button is the accent button of the bar and dims on hover', () => {
render(<ReservationsPanel {...defaultProps} />);
const add = screen.getAllByText('Manual Booking')[0].closest('button') as HTMLButtonElement;
expect(add.className).toContain('bg-accent');
expect(add.className).toContain('hover:opacity-90');
});
// ── Card details ────────────────────────────────────────────────────────────
it('FE-PLANNER-RESP-059: a hotel with an accommodation range labels both days and hides the raw date row', () => {
const day1 = buildDay({ id: 401, date: '2025-07-01', day_number: 1, title: null } as any);
const day3 = buildDay({ id: 403, date: '2025-07-03', day_number: 3, title: null } as any);
const hotel = buildReservation({
id: 1, title: 'Hotel Adlon', type: 'hotel', status: 'confirmed',
accommodation_start_day_id: 401, accommodation_end_day_id: 403,
reservation_time: '2025-07-01T15:00',
} as any);
render(<ReservationsPanel {...defaultProps} reservations={[hotel]} days={[day1, day3]} />);
expect(screen.getByText(/Day 1 → Day 3/)).toBeInTheDocument();
// The stamped time row is suppressed for an accommodation-backed hotel.
expect(screen.queryByText('15:00')).not.toBeInTheDocument();
});
it('FE-PLANNER-RESP-060: clicking a blurred booking code toggles it open and closed', () => {
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', map_tile_url: '', show_place_description: false } });
const onEdit = vi.fn();
const res = buildReservation({ id: 1, confirmation_number: 'TOGGLE1', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} onEdit={onEdit} />);
const code = screen.getByText('TOGGLE1');
expect(code.className).toContain('blur-[4px]');
// A bare click (no hover) flips it open, a second one hides it again.
fireEvent.click(code);
expect(code.className).not.toContain('blur-[4px]');
fireEvent.click(code);
expect(code.className).toContain('blur-[4px]');
// Revealing the code never opens the booking it sits on.
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(onEdit).not.toHaveBeenCalled();
});
it('FE-PLANNER-RESP-061: the card actions stay quiet until the card is hovered or focused', () => {
const res = buildReservation({ id: 1, title: 'Hover me', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
const edit = cardAction('Hover me', 'Edit');
expect(edit.className).toContain('opacity-60');
expect(edit.className).toContain('group-hover:opacity-100');
expect(edit.className).toContain('group-focus-within:opacity-100');
});
it('FE-PLANNER-RESP-062: a failing delete surfaces an error instead of throwing', async () => {
const user = userEvent.setup();
const onDelete = vi.fn().mockRejectedValue(new Error('nope'));
const res = buildReservation({ id: 5, title: 'Undeletable', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} onDelete={onDelete} />);
await user.click(cardAction('Undeletable', 'Delete'));
await screen.findByText(/will be permanently deleted/i);
await user.click(confirmDeleteButton());
await waitFor(() => expect(onDelete).toHaveBeenCalledWith(5));
// The question closed, the card is still there, and the failure is said out loud.
expect(screen.queryByText(/will be permanently deleted/i)).not.toBeInTheDocument();
expect(screen.getByText('Undeletable')).toBeInTheDocument();
});
it('FE-PLANNER-RESP-063: an endpoint route replaces the airport metadata cells', () => {
const res = buildReservation({
id: 1, title: 'Routed flight', type: 'flight', status: 'confirmed',
metadata: JSON.stringify({ airline: 'KLM', departure_airport: 'AMS', arrival_airport: 'JFK', price: 320, priceCurrency: 'EUR' }),
endpoints: [
{ role: 'from', sequence: 0, name: 'Amsterdam Schiphol' },
{ role: 'stop', sequence: 1, name: 'Reykjavik' },
{ role: 'to', sequence: 2, name: 'New York JFK' },
],
} as any);
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
expect(screen.getByText('Amsterdam Schiphol')).toBeInTheDocument();
expect(screen.getByText('Reykjavik')).toBeInTheDocument();
// The raw airport codes are dropped in favour of the route line…
expect(screen.queryByText('AMS')).not.toBeInTheDocument();
// …but the price still gets its own cell, formatted as money in its own currency.
expect(screen.getByText(money(320, 'EUR'))).toBeInTheDocument();
});
it('FE-PLANNER-RESP-063b: a price without a currency is shown in the trip currency, a non-numeric one as written', () => {
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'CHF' }) });
const priced = buildReservation({
id: 1, title: 'Lake cruise', type: 'cruise', status: 'confirmed',
metadata: JSON.stringify({ price: '45' }),
});
const vague = buildReservation({
id: 2, title: 'Mountain hut', type: 'other', status: 'confirmed',
metadata: JSON.stringify({ price: 'on request', priceCurrency: 'EUR' }),
});
render(<ReservationsPanel {...defaultProps} reservations={[priced, vague]} />);
expect(screen.getByText(money(45, 'CHF'))).toBeInTheDocument();
expect(screen.getByText('on request EUR')).toBeInTheDocument();
});
it('FE-PLANNER-RESP-064: an attached file opens through the download helper', async () => {
const user = userEvent.setup();
const res = buildReservation({ id: 7, status: 'confirmed' });
const files = [{ id: 1, trip_id: 1, reservation_id: 7, original_name: 'ticket.pdf', url: '/uploads/ticket.pdf', filename: 'ticket.pdf', mime_type: 'application/pdf', created_at: '2025-01-01T00:00:00.000Z' }];
render(<ReservationsPanel {...defaultProps} reservations={[res]} files={files} />);
await user.click(screen.getByText('ticket.pdf'));
expect(vi.mocked(openFile)).toHaveBeenCalledWith('/uploads/ticket.pdf', 'ticket.pdf');
// Opening the file does not open the booking as well.
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('FE-PLANNER-RESP-065: an assignment without a place is skipped by the day/place lookup', () => {
const day = { ...buildDay({ id: 1, title: 'Day 1', date: '2025-06-01' }), day_number: 1 } as any;
const assignments = {
'1': [
{ id: 10, order_index: 1, day_id: 1, place_id: 2, notes: null, place: buildPlace({ name: 'Louvre' }) },
{ id: 11, order_index: 0, day_id: 1, place_id: null, notes: null, place: null },
],
};
const withPlace = buildReservation({ id: 1, title: 'Linked', assignment_id: 10, status: 'confirmed' });
const withoutPlace = buildReservation({ id: 2, title: 'Orphan', assignment_id: 11, status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[withPlace, withoutPlace]} days={[day]} assignments={assignments as any} />);
expect(screen.getByText(/Louvre/)).toBeInTheDocument();
expect(screen.getByText('Orphan')).toBeInTheDocument();
});
// ── Section state ───────────────────────────────────────────────────────────
it('FE-PLANNER-RESP-066: a section collapsed in a previous session stays collapsed', () => {
localStorage.setItem('trek:bookings-bookings-collapsed:1', JSON.stringify({ 'status:confirmed': true }));
const res = buildReservation({ id: 1, title: 'Hidden card', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
expect(screen.queryByText('Hidden card')).not.toBeInTheDocument();
});
// ── Transit journeys (#1065) ────────────────────────────────────────────────
const transitJourney = (over: Record<string, unknown> = {}) => buildReservation({
id: 900,
title: 'Berlin Hbf → Hamburg Hbf',
type: 'transit',
status: 'confirmed',
day_id: 501,
reservation_time: '2025-06-01T08:00',
reservation_end_time: '2025-06-01T09:45',
metadata: JSON.stringify({
transit: {
provider: 'transitous', duration: 6300, transfers: 1, walk_seconds: 300,
legs: [
{ mode: 'WALK', duration: 300, line: null },
{ mode: 'SUBWAY', duration: 900, line: 'U2', line_color: '#FF3300' },
{ mode: 'HIGHSPEED_RAIL', duration: 5100, line: 'ICE 599', line_color: null },
],
},
}),
...over,
} as any);
it('FE-PLANNER-RESP-067: a transit journey shows its legs, day and time, and its own section on request', () => {
const day = buildDay({ id: 501, date: '2025-06-01', day_number: 2, title: 'Travel day' } as any);
const { unmount } = render(<ReservationsPanel {...defaultProps} reservations={[transitJourney()]} days={[day]} />);
expect(screen.getByText('U2')).toBeInTheDocument();
expect(screen.getByText('ICE 599')).toBeInTheDocument();
expect(screen.getByText('Travel day')).toBeInTheDocument();
expect(screen.getByText(/08:00/)).toBeInTheDocument();
// By default a journey sorts in among the confirmed bookings.
expect(screen.queryByText('Automated public transit')).not.toBeInTheDocument();
unmount();
// The view option puts journeys in a section of their own.
localStorage.setItem('trek:bookings-bookings-transitApart', 'true');
render(<ReservationsPanel {...defaultProps} reservations={[transitJourney()]} days={[day]} />);
expect(screen.getByText('Automated public transit')).toBeInTheDocument();
});
it('FE-PLANNER-RESP-068: clicking a transit journey opens its detail, and Edit hands it to onEdit', async () => {
const user = userEvent.setup();
const onEdit = vi.fn();
render(<ReservationsPanel {...defaultProps} reservations={[transitJourney()]} onEdit={onEdit} />);
await user.click(screen.getByRole('article', { name: /Hamburg Hbf/ }));
const detail = await screen.findByRole('dialog');
expect(onEdit).not.toHaveBeenCalled();
await user.click(within(detail).getByRole('button', { name: 'Edit' }));
expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ id: 900 }));
});
it('FE-PLANNER-RESP-069: deleting a transit journey asks first and does not open the journey', async () => {
const user = userEvent.setup();
const onDelete = vi.fn();
const onEdit = vi.fn();
const title = 'Berlin Hbf → Hamburg Hbf';
render(<ReservationsPanel {...defaultProps} reservations={[transitJourney()]} onDelete={onDelete} onEdit={onEdit} />);
await user.click(cardAction(title, 'Delete'));
expect(onEdit).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
// Cancel first — nothing is deleted.
await user.click(await screen.findByRole('button', { name: 'Cancel' }));
expect(onDelete).not.toHaveBeenCalled();
await user.click(cardAction(title, 'Delete'));
await screen.findByText(/will be permanently deleted/i);
await user.click(confirmDeleteButton());
await waitFor(() => expect(onDelete).toHaveBeenCalledWith(900));
});
it('FE-PLANNER-RESP-070: a transit journey with unreadable metadata still renders its header', () => {
render(<ReservationsPanel {...defaultProps} reservations={[transitJourney({ metadata: '{broken' })]} />);
expect(screen.getByText(/Hamburg Hbf/)).toBeInTheDocument();
expect(screen.queryByText('U2')).not.toBeInTheDocument();
});
it('FE-PLANNER-RESP-071: a transit journey shows its notes as markdown and its travelers', () => {
const res = transitJourney({
notes: '**Reserve** a seat\nsecond line',
travelers: [{ user_id: 1, username: 'ada', avatar_url: null }],
});
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
expect(screen.getByText('Reserve').tagName).toBe('STRONG');
expect(screen.getByText('ada')).toBeInTheDocument();
});
// ── Reservation-detail plugin slot ──────────────────────────────────────────
it('FE-PLANNER-RESP-072: a reservation-detail plugin mounts a frame on both card kinds', async () => {
seedStore(usePluginStore, {
plugins: [
{ id: 'seat-map', name: 'Seat Map', type: 'widget', icon: null, slot: 'reservation-detail' },
{ id: 'dash-widget', name: 'Dash', type: 'widget', icon: null, slot: 'hero' },
],
});
const booking = buildReservation({ id: 1, title: 'Flight out', type: 'flight', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[booking, transitJourney()]} />);
await waitFor(() => expect(document.querySelectorAll('iframe[src*="seat-map"]')).toHaveLength(2));
expect(document.querySelector('iframe[src*="dash-widget"]')).toBeNull();
});
it('FE-PLANNER-RESP-073: interacting with a transit card\'s travelers or plugin frame does not open the journey', async () => {
seedStore(usePluginStore, {
plugins: [{ id: 'seat-map', name: 'Seat Map', type: 'widget', icon: null, slot: 'reservation-detail' }],
});
const onEdit = vi.fn();
const res = transitJourney({ travelers: [{ user_id: 1, username: 'ada', avatar_url: null }] });
render(<ReservationsPanel {...defaultProps} reservations={[res]} onEdit={onEdit} />);
const frame = await waitFor(() => document.querySelector('iframe[src*="seat-map"]') as HTMLIFrameElement);
fireEvent.click(frame.parentElement!.parentElement!);
fireEvent.click(screen.getByText('ada').parentElement!);
expect(onEdit).not.toHaveBeenCalled();
});
it('FE-PLANNER-RESP-074: the transit delete dialog closes on a backdrop click without deleting', async () => {
const user = userEvent.setup();
const onDelete = vi.fn();
render(<ReservationsPanel {...defaultProps} reservations={[transitJourney()]} onDelete={onDelete} />);
await user.click(cardAction('Berlin Hbf → Hamburg Hbf', 'Delete'));
const body = await screen.findByText(/will be permanently deleted/i);
fireEvent.click(body.closest('.fixed.inset-0') as HTMLElement);
await waitFor(() => expect(screen.queryByText(/will be permanently deleted/i)).not.toBeInTheDocument());
expect(onDelete).not.toHaveBeenCalled();
});
it('FE-PLANNER-RESP-075: a revealed code covers up again once hidden, and keeps its hover reveal', () => {
seedStore(useSettingsStore, { settings: { time_format: '24h', blur_booking_codes: true, temperature_unit: 'celsius', language: 'en', dark_mode: false, default_currency: 'USD', map_tile_url: '', show_place_description: false } });
const res = buildReservation({ id: 1, confirmation_number: 'HOVER1', status: 'confirmed' });
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
const code = screen.getByText('HOVER1');
expect(code).toHaveAttribute('aria-pressed', 'false');
expect(code.className).toContain('hover:blur-none');
fireEvent.click(code);
expect(code).toHaveAttribute('aria-pressed', 'true');
fireEvent.click(code);
expect(code).toHaveAttribute('aria-pressed', 'false');
expect(code.className).toContain('blur-[4px]');
});
it('FE-PLANNER-RESP-076: entries with no resolvable date sink below the dated ones', () => {
const day = buildDay({ id: 601, date: '2025-08-02', day_number: 2 } as any);
const undated = buildReservation({ id: 1, title: 'Undated taxi', type: 'taxi', status: 'pending', created_at: '2025-01-01T00:00:00.000Z' });
const dated = buildReservation({ id: 2, title: 'Dated train', type: 'train', status: 'pending', day_id: 601, created_at: '2025-02-01T00:00:00.000Z' } as any);
render(<ReservationsPanel {...defaultProps} reservations={[undated, dated]} days={[day]} />);
const text = document.body.textContent || '';
expect(text.indexOf('Dated train')).toBeLessThan(text.indexOf('Undated taxi'));
});
it('FE-PLANNER-RESP-079: an http booking link is rendered as an anchor', () => {
const res = buildReservation({ id: 1, title: 'Hotel', status: 'confirmed', url: 'https://hotel.example/booking' } as any);
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
const link = screen.getByText('https://hotel.example/booking');
expect(link.tagName).toBe('A');
expect(link).toHaveAttribute('href', 'https://hotel.example/booking');
});
it('FE-PLANNER-RESP-080: a javascript: booking link stays plain text, never an href', () => {
const res = buildReservation({ id: 1, title: 'Hotel', status: 'confirmed', url: 'javascript:alert(1)' } as any);
render(<ReservationsPanel {...defaultProps} reservations={[res]} />);
// The stored value stays readable, but nothing is clickable.
const shown = screen.getByText('javascript:alert(1)');
expect(shown.closest('a')).toBeNull();
expect(document.querySelector('a[href^="javascript:"]')).toBeNull();
});
});