Files
TREK/client/src/components/Planner/ReservationModal.test.tsx
T
Julien G. 51ab30f436 Bug fixes - April 30th 2026 (#936)
* fix: hotel day-range clamping in ReservationModal + stale assignment_id on accommodation clear (issues #929, #934)

* ReservationModal hotel start/end pickers now use findIndex-based
  positional clamping instead of raw ID arithmetic, matching the fix
  applied to DayDetailPanel in 8e05ba7. Prevents inverted
  start_day_id/end_day_id on trips with non-monotonic day IDs.

* Clearing accommodation_id on a hotel reservation now forces
  assignment_id to null in the save payload, removing the stale
  day-assignment link that had no UI path to clear.

* Migration: swaps inverted start_day_id/end_day_id pairs in
  day_accommodations where start.day_number > end.day_number,
  recovering existing corrupt rows from the pre-fix picker bug.

* Tests FE-PLANNER-RESMODAL-050/051/052 cover both fixes.

* fix: preserve line breaks and wrap long URLs in notes fields (#930)

Add remark-breaks to all reservation/place notes markdown renderers so
single newlines render as <br>, and add wordBreak/overflowWrap styles
so long unbroken URLs (e.g. booking.com tracking links) wrap correctly.

* fix: delete linked budget item when accommodation or reservation is deleted (#933)

Deleting an accommodation or reservation now removes any budget item
linked via reservation_id, preventing orphan entries in the Budget page.
Also fixes a pre-existing payload-shape bug where budget:deleted was
broadcast with {id} instead of {itemId}, breaking live updates for
collaborators when a reservation price was cleared.

Tests added: ACCOM-006, RESV-009b, BUDGET-004b.

* fix: restore scroll position in mobile Plan and Places sidebars on reopen (issue #932)

Both DayPlanSidebar and PlacesSidebar have their own internal scroll
containers (overflowY: auto). Scroll events don't bubble, so previous
attempts that tracked scrollTop on the outer portal div never fired.

Each sidebar now accepts initialScrollTop and onScrollTopChange props.
The internal scroll container saves its scrollTop via onScrollTopChange
on every scroll event, and restores it via useLayoutEffect on mount
(before the browser paints, so no visible flash).

TripPlannerPage holds the saved values in refs (mobilePlanScrollTopRef,
mobilePlacesScrollTopRef) and passes them through on each portal mount.

* fix(map): prevent auto zoom-out when opening/closing place inspector (issue #921)

Both Leaflet and Mapbox GL renderers now gate fitBounds strictly on fitKey
increments from the parent. Selecting or dismissing a place inspector changes
paddingOpts (via hasInspector) but no longer triggers a re-fit that zoomed
the map out to the full trip extent when no day was selected.

Also removes the zoom-12 visibility gate on Leaflet route info pills so they
render at all zoom levels when a route is active.

* fix: translate mobile bottom-nav tab labels (issue #931)

Replaced hardcoded English labels in BottomNav with t() lookups using the same translation keys as the desktop navbar (nav.myTrips, admin.addons.catalog.*.name).
2026-05-01 01:43:19 +02:00

826 lines
37 KiB
TypeScript

// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-052
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { useAddonStore } from '../../store/addonStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import {
buildUser,
buildTrip,
buildDay,
buildPlace,
buildAssignment,
buildReservation,
buildTripFile,
} from '../../../tests/helpers/factories';
import { ReservationModal } from './ReservationModal';
// Mock react-router-dom useParams
vi.mock('react-router-dom', async (importActual) => {
const actual = await importActual<typeof import('react-router-dom')>();
return { ...actual, useParams: () => ({ id: '1' }) };
});
// Mock CustomDatePicker as a simple text input
vi.mock('../shared/CustomDateTimePicker', () => ({
CustomDatePicker: ({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) => (
<input
data-testid="date-picker"
type="text"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder ?? 'YYYY-MM-DD'}
/>
),
}));
// Mock CustomTimePicker as a simple text input
vi.mock('../shared/CustomTimePicker', () => ({
default: ({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) => (
<input
data-testid="time-picker"
type="text"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder ?? '00:00'}
/>
),
}));
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
onSave: vi.fn().mockResolvedValue(undefined),
reservation: null,
days: [],
places: [],
assignments: {},
selectedDayId: null,
files: [],
onFileUpload: vi.fn().mockResolvedValue(undefined),
onFileDelete: vi.fn().mockResolvedValue(undefined),
accommodations: [],
};
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }), budgetItems: [] });
// addonStore: budget addon disabled
vi.clearAllMocks();
});
describe('ReservationModal', () => {
// ── Rendering ──────────────────────────────────────────────────────────────
it('FE-PLANNER-RESMODAL-001: renders without crashing', () => {
render(<ReservationModal {...defaultProps} />);
expect(document.body).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-002: shows "New Reservation" title for new reservation', () => {
render(<ReservationModal {...defaultProps} reservation={null} />);
expect(screen.getByText(/New Reservation/i)).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-003: shows "Edit Reservation" title when editing', () => {
const res = buildReservation({ title: 'Nice Dinner', type: 'restaurant' });
render(<ReservationModal {...defaultProps} reservation={res} />);
expect(screen.getByText(/Edit Reservation/i)).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-004: title input is required — onSave not called with empty title', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />);
const submitBtn = screen.getByRole('button', { name: /^Add$/i });
await userEvent.click(submitBtn);
expect(onSave).not.toHaveBeenCalled();
});
it('FE-PLANNER-RESMODAL-005: all 5 type buttons are visible (transport types removed)', () => {
render(<ReservationModal {...defaultProps} />);
expect(screen.getByRole('button', { name: /Accommodation/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Restaurant/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Event/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Tour/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Other/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^Flight$/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^Train$/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^Car$/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /^Cruise$/i })).not.toBeInTheDocument();
});
// ── Type selection ──────────────────────────────────────────────────────────
it('FE-PLANNER-RESMODAL-006: clicking Event type button activates it', async () => {
render(<ReservationModal {...defaultProps} />);
const eventBtn = screen.getByRole('button', { name: /Event/i });
await userEvent.click(eventBtn);
expect(eventBtn).toHaveStyle({ background: 'var(--text-primary)' });
});
it('FE-PLANNER-RESMODAL-008: hotel type shows check-in/check-out time fields', async () => {
render(<ReservationModal {...defaultProps} />);
await userEvent.click(screen.getByRole('button', { name: /Accommodation/i }));
const checkInLabels = screen.getAllByText(/Check-in/i);
expect(checkInLabels.length).toBeGreaterThanOrEqual(1);
expect(screen.getByText(/Check-out/i)).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-009: restaurant type shows location field', async () => {
render(<ReservationModal {...defaultProps} />);
await userEvent.click(screen.getByRole('button', { name: /Restaurant/i }));
expect(screen.getByPlaceholderText(/Address, Airport/i)).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-010: hotel type hides assignment picker', async () => {
const day = buildDay({ id: 1, title: 'Day 1' });
const place = buildPlace({ name: 'Museum' });
const assignment = buildAssignment({ id: 99, day_id: 1, place });
render(
<ReservationModal
{...defaultProps}
days={[day]}
assignments={{ '1': [assignment] }}
/>
);
// Switch to hotel type
await userEvent.click(screen.getByRole('button', { name: /Accommodation/i }));
expect(screen.queryByText(/Link to day assignment/i)).not.toBeInTheDocument();
});
// ── Form population from existing reservation ──────────────────────────────
it('FE-PLANNER-RESMODAL-011: editing pre-fills title', () => {
const res = buildReservation({ title: 'Paris Hotel', type: 'hotel', status: 'confirmed' });
render(<ReservationModal {...defaultProps} reservation={res} />);
expect(screen.getByDisplayValue('Paris Hotel')).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-012: editing pre-fills confirmation number', () => {
const res = buildReservation({ confirmation_number: 'XYZ123' });
render(<ReservationModal {...defaultProps} reservation={res} />);
expect(screen.getByDisplayValue('XYZ123')).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-013: editing pre-fills notes', () => {
const res = buildReservation({ notes: 'Breakfast included' });
render(<ReservationModal {...defaultProps} reservation={res} />);
expect(screen.getByDisplayValue('Breakfast included')).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-014: editing pre-fills type — restaurant type shows location field', () => {
const res = buildReservation({ type: 'restaurant', location: 'Via Roma 1' });
render(<ReservationModal {...defaultProps} reservation={res} />);
expect(screen.getByDisplayValue('Via Roma 1')).toBeInTheDocument();
});
// ── Validation ──────────────────────────────────────────────────────────────
it('FE-PLANNER-RESMODAL-015: end datetime before start shows error and blocks submit', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const addToast = vi.fn();
window.__addToast = addToast;
render(<ReservationModal {...defaultProps} onSave={onSave} />);
// Fill in the title
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'My Flight');
// Set start date/time via the date-picker inputs (mocked as text inputs)
// reservation_time is rendered as two separate pickers: date part and time part
const datePickers = screen.getAllByTestId('date-picker');
const timePickers = screen.getAllByTestId('time-picker');
// First date picker = start date, second = end date
fireEvent.change(datePickers[0], { target: { value: '2025-06-10' } });
fireEvent.change(timePickers[0], { target: { value: '10:00' } });
// End date before start date
fireEvent.change(datePickers[1], { target: { value: '2025-06-09' } });
fireEvent.change(timePickers[1], { target: { value: '09:00' } });
// When isEndBeforeStart=true the submit button is disabled, so fire submit on the form directly.
// The Save button now lives in the Modal's sticky footer (outside the <form>), so we query
// the form by tag instead of walking up from the button.
const form = document.querySelector('form')!;
fireEvent.submit(form);
expect(onSave).not.toHaveBeenCalled();
expect(addToast).toHaveBeenCalledWith(
expect.stringMatching(/End date\/time must be after start/i),
'error',
undefined,
);
delete window.__addToast;
});
// ── Submit flow ─────────────────────────────────────────────────────────────
it('FE-PLANNER-RESMODAL-016: submitting valid restaurant booking calls onSave with correct shape', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /Restaurant/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Le Jules Verne');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Le Jules Verne', type: 'restaurant' })
);
});
it('FE-PLANNER-RESMODAL-017: status confirmed — onSave called with status confirmed', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />);
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Test Booking');
// The status CustomSelect renders as a button for its trigger — check for "Pending" text and change it
// CustomSelect renders a div/button with the current value label. We look for the status select area.
// Since CustomSelect is not mocked, we find the select by its displayed value.
// The easiest approach: render with a reservation that has status 'confirmed'
const res = buildReservation({ status: 'confirmed', type: 'flight', title: 'My Booking' });
const { unmount } = render(<ReservationModal {...defaultProps} reservation={res} onSave={onSave} />);
const updateBtn = screen.getAllByRole('button', { name: /Update/i })[0];
await userEvent.click(updateBtn);
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ status: 'confirmed' })
);
unmount();
});
it('FE-PLANNER-RESMODAL-018: onClose NOT called after successful save (parent controls closing)', async () => {
const onClose = vi.fn();
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onClose={onClose} onSave={onSave} />);
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Test Booking');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
// The component does NOT call onClose after save — the parent controls that
expect(onClose).not.toHaveBeenCalled();
});
it('FE-PLANNER-RESMODAL-019: save button is disabled while saving', async () => {
let resolveOnSave: () => void;
const onSave = vi.fn().mockReturnValue(
new Promise<void>(resolve => { resolveOnSave = resolve; })
);
render(<ReservationModal {...defaultProps} onSave={onSave} />);
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Test Booking');
const submitBtn = screen.getByRole('button', { name: /^Add$/i });
await userEvent.click(submitBtn);
// While promise is pending, the button should be disabled
await waitFor(() => {
expect(screen.getByRole('button', { name: /Saving/i })).toBeDisabled();
});
// Cleanup
resolveOnSave!();
});
// ── Assignment linking ──────────────────────────────────────────────────────
it('FE-PLANNER-RESMODAL-020: assignment picker appears when days/assignments are populated (non-hotel)', () => {
const day = buildDay({ id: 1, title: 'Day 1' });
const place = buildPlace({ name: 'Museum' });
const assignment = buildAssignment({ id: 99, day_id: 1, order_index: 0, place });
render(
<ReservationModal
{...defaultProps}
days={[day]}
assignments={{ '1': [assignment] }}
/>
);
expect(screen.getByText(/Link to day assignment/i)).toBeInTheDocument();
});
// ── Files ──────────────────────────────────────────────────────────────────
it('FE-PLANNER-RESMODAL-022: attached files shown for existing reservation', () => {
const res = buildReservation({ id: 5 });
const file = buildTripFile({
id: 1,
trip_id: 1,
original_name: 'ticket.pdf',
});
// Add reservation_id field manually (not in standard TripFile type but used in component)
(file as any).reservation_id = 5;
render(
<ReservationModal
{...defaultProps}
reservation={res}
files={[file]}
/>
);
expect(screen.getByText('ticket.pdf')).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-023: Cancel button calls onClose', async () => {
const onClose = vi.fn();
render(<ReservationModal {...defaultProps} onClose={onClose} />);
await userEvent.click(screen.getByRole('button', { name: /Cancel/i }));
expect(onClose).toHaveBeenCalled();
});
// ── Budget addon ─────────────────────────────────────────────────────────────
it('FE-PLANNER-RESMODAL-024: budget section visible when budget addon is enabled', () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
render(<ReservationModal {...defaultProps} />);
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-025: budget price input accepts valid decimal', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
render(<ReservationModal {...defaultProps} />);
const priceInput = screen.getByPlaceholderText('0.00');
await userEvent.type(priceInput, '99.99');
expect((priceInput as HTMLInputElement).value).toBe('99.99');
});
it('FE-PLANNER-RESMODAL-026: budget hint shown when price > 0', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
render(<ReservationModal {...defaultProps} />);
const priceInput = screen.getByPlaceholderText('0.00');
await userEvent.type(priceInput, '50');
expect(screen.getByText(/budget entry will be created/i)).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-027: budget fields included in onSave when price is set', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />);
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Paris');
await userEvent.type(screen.getByPlaceholderText('0.00'), '120');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 120 }) })
);
});
// ── File upload ───────────────────────────────────────────────────────────────
it('FE-PLANNER-RESMODAL-028: pending file added for new reservation on file input change', async () => {
render(<ReservationModal {...defaultProps} reservation={null} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const testFile = new File(['content'], 'document.pdf', { type: 'application/pdf' });
fireEvent.change(fileInput, { target: { files: [testFile] } });
// Pending file name should appear in the list
await waitFor(() => {
expect(screen.getByText('document.pdf')).toBeInTheDocument();
});
});
it('FE-PLANNER-RESMODAL-029: attach file button is rendered when onFileUpload provided', () => {
render(<ReservationModal {...defaultProps} />);
expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-030: hotel type — saving calls onSave with correct hotel shape', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /Accommodation/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Grand Hotel');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Grand Hotel', type: 'hotel' })
);
});
it('FE-PLANNER-RESMODAL-031: event type — saving calls onSave with event type', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /Event/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Louvre Museum');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Louvre Museum', type: 'event' })
);
});
it('FE-PLANNER-RESMODAL-032: edit mode — save button shows "Update"', () => {
const res = buildReservation({ title: 'My Trip', type: 'other' });
render(<ReservationModal {...defaultProps} reservation={res} />);
expect(screen.getByRole('button', { name: /^Update$/i })).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-033: modal is closed when isOpen=false', () => {
render(<ReservationModal {...defaultProps} isOpen={false} />);
// When isOpen=false the Modal component should hide content
expect(screen.queryByText(/New Reservation/i)).not.toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-034: location and confirmation number inputs are present', () => {
render(<ReservationModal {...defaultProps} />);
expect(screen.getByPlaceholderText(/Address, Airport/i)).toBeInTheDocument();
expect(screen.getByPlaceholderText(/e\.g\. ABC12345/i)).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-036: file upload to existing reservation calls onFileUpload', async () => {
const onFileUpload = vi.fn().mockResolvedValue(undefined);
const res = buildReservation({ id: 10, title: 'My Trip', type: 'other' });
render(
<ReservationModal
{...defaultProps}
reservation={res}
onFileUpload={onFileUpload}
/>
);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const testFile = new File(['content'], 'boarding-pass.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();
// FormData.append coerces numbers to strings
expect(fd.get('reservation_id')).toBe('10');
});
it('FE-PLANNER-RESMODAL-037: link existing file button appears when unattached files exist', () => {
const res = buildReservation({ id: 5 });
// File NOT attached to this reservation
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
render(
<ReservationModal
{...defaultProps}
reservation={res}
files={[unattachedFile]}
/>
);
expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-038: clicking "link existing file" shows file picker dropdown', async () => {
const res = buildReservation({ id: 5 });
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
render(
<ReservationModal
{...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-RESMODAL-039: 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 });
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
render(
<ReservationModal
{...defaultProps}
reservation={res}
files={[unattachedFile]}
/>
);
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
await userEvent.click(screen.getByText('invoice.pdf'));
// After linking, the file is moved to attached files and the "Link existing file" button disappears
// (all files are now attached, so the picker condition becomes false)
await waitFor(() => {
expect(screen.queryByRole('button', { name: /Link existing file/i })).not.toBeInTheDocument();
});
});
it('FE-PLANNER-RESMODAL-040: removing pending file removes it from list', async () => {
render(<ReservationModal {...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());
// Click the X next to the pending file
const removeButtons = screen.getAllByRole('button');
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-RESMODAL-041: budget section not shown when addon disabled', () => {
render(<ReservationModal {...defaultProps} />);
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-042: hotel type metadata saved with check-in time', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /Accommodation/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Grand Hotel');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Grand Hotel', type: 'hotel' })
);
});
it('FE-PLANNER-RESMODAL-043: hover styles applied to file picker items', async () => {
const res = buildReservation({ id: 5 });
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
render(
<ReservationModal
{...defaultProps}
reservation={res}
files={[unattachedFile]}
/>
);
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
const filePickerItem = screen.getByText('invoice.pdf').closest('button')!;
fireEvent.mouseEnter(filePickerItem);
fireEvent.mouseLeave(filePickerItem);
// Just testing the handlers don't throw
expect(filePickerItem).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-044: budget category dropdown options include existing categories', () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
budgetItems: [
{ id: 1, trip_id: 1, name: 'Flight ticket', amount: 300, currency: 'EUR', category: 'Transport', paid_by: null, persons: 1, members: [], expense_date: null },
],
});
render(<ReservationModal {...defaultProps} />);
// Budget section is visible
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
render(<ReservationModal {...defaultProps} />);
await userEvent.click(screen.getByRole('button', { name: /^Tour$/i }));
await waitFor(() => {
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThan(0);
});
});
it('FE-PLANNER-RESMODAL-046: other type renders and saves correctly', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Other$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Misc item');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'other' })));
});
it('FE-PLANNER-RESMODAL-047: clicking budget category select changes the value', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
budgetItems: [
{ id: 1, trip_id: 1, name: 'Ticket', amount: 100, currency: 'EUR', category: 'Transport', paid_by: null, persons: 1, members: [], expense_date: null },
],
});
render(<ReservationModal {...defaultProps} />);
// Open the budget category CustomSelect (shows placeholder "Auto (from booking type)")
const budgetCategoryBtn = screen.getByText(/Auto \(from booking type\)/i).closest('button')!;
await userEvent.click(budgetCategoryBtn);
// Click the "Transport" category option
await waitFor(() => expect(screen.getByText('Transport')).toBeInTheDocument());
await userEvent.click(screen.getByText('Transport'));
// The select should now show "Transport"
expect(screen.getByText('Transport')).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-048: clicking attach file button triggers file input', async () => {
render(<ReservationModal {...defaultProps} />);
const attachBtn = screen.getByRole('button', { name: /Attach file/i });
// Mock click on hidden file input
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-RESMODAL-049: unlinking a linked file removes it from attached list', async () => {
// First link the file, then unlink it via the X button
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 });
// File is NOT attached (no reservation_id) — it will be in the "link existing" picker
const looseFile = buildTripFile({ id: 42, original_name: 'receipt.pdf' });
render(
<ReservationModal
{...defaultProps}
reservation={res}
files={[looseFile]}
/>
);
// Link the file via the picker
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
await waitFor(() => expect(screen.getByText('receipt.pdf')).toBeInTheDocument());
await userEvent.click(screen.getByText('receipt.pdf'));
// File is now in attached list; "Link existing file" button gone
await waitFor(() =>
expect(screen.queryByRole('button', { name: /Link existing file/i })).not.toBeInTheDocument()
);
// Click the X to unlink
const fileRow = screen.getByText('receipt.pdf').closest('div')!;
const unlinkBtn = fileRow.querySelector('button[type="button"]')!;
await userEvent.click(unlinkBtn);
// File removed from attached list and "Link existing file" button reappears
await waitFor(() => {
expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument();
});
});
it('FE-PLANNER-RESMODAL-035: hotel type saves correctly', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Test');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ type: 'hotel' })
);
});
// ── Hotel day-range picker — non-monotonic IDs (issue #929) ───────────────
// Mirrors DayDetailPanel-056/057 for the ReservationModal path.
// ID layout: day_number 1-9 → IDs 17-25, day_number 10-16 → IDs 1-7.
function buildNonMonotonicDaysRM() {
return [
buildDay({ id: 17, trip_id: 1, date: '2026-04-30', day_number: 1 }),
buildDay({ id: 18, trip_id: 1, date: '2026-05-01', day_number: 2 }),
buildDay({ id: 19, trip_id: 1, date: '2026-05-02', day_number: 3 }),
buildDay({ id: 20, trip_id: 1, date: '2026-05-03', day_number: 4 }),
buildDay({ id: 21, trip_id: 1, date: '2026-05-04', day_number: 5 }),
buildDay({ id: 22, trip_id: 1, date: '2026-05-05', day_number: 6 }),
buildDay({ id: 23, trip_id: 1, date: '2026-05-06', day_number: 7 }),
buildDay({ id: 24, trip_id: 1, date: '2026-05-07', day_number: 8 }),
buildDay({ id: 25, trip_id: 1, date: '2026-05-08', day_number: 9 }),
buildDay({ id: 1, trip_id: 1, date: '2026-05-09', day_number: 10 }),
buildDay({ id: 2, trip_id: 1, date: '2026-05-10', day_number: 11 }),
buildDay({ id: 3, trip_id: 1, date: '2026-05-11', day_number: 12 }),
buildDay({ id: 4, trip_id: 1, date: '2026-05-12', day_number: 13 }),
buildDay({ id: 5, trip_id: 1, date: '2026-05-13', day_number: 14 }),
buildDay({ id: 6, trip_id: 1, date: '2026-05-14', day_number: 15 }),
buildDay({ id: 7, trip_id: 1, date: '2026-05-15', day_number: 16 }),
] as any[];
}
it('FE-PLANNER-RESMODAL-050: non-monotonic IDs — end picker with low ID does not clobber start', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const days = buildNonMonotonicDaysRM();
render(<ReservationModal {...defaultProps} onSave={onSave} days={days} />);
// Switch to hotel type
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Overlap Hotel');
// Open start picker (first "Select day" trigger) and select Day 1 (id=17)
const startTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || b.textContent?.startsWith('Day '))[0];
await userEvent.click(startTrigger());
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 1') && !b.textContent?.startsWith('Day 1 ') || b.textContent?.trim() === 'Day 1')!);
// Open end picker and select Day 16 (id=7, low ID but last positionally)
const endTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || /^Day \d+/.test(b.textContent?.trim() ?? ''))[1];
await userEvent.click(endTrigger());
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const saved = onSave.mock.calls[0][0];
// start must stay id=17 (Day 1) — old Math.max would clobber it to id=7
expect(saved.create_accommodation?.start_day_id).toBe(17);
expect(saved.create_accommodation?.end_day_id).toBe(7);
});
it('FE-PLANNER-RESMODAL-051: non-monotonic IDs — start picker does not collapse end when start has high ID', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const days = buildNonMonotonicDaysRM();
render(<ReservationModal {...defaultProps} onSave={onSave} days={days} />);
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Span Hotel');
// Set end to Day 16 (id=7) first
const endTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || /^Day \d+/.test(b.textContent?.trim() ?? ''))[1];
await userEvent.click(endTrigger());
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
// Set start to Day 9 (id=25, high ID but earlier by position than Day 16)
// Old code: Math.max(25, 7) = 25 → end collapses to Day 9.
// New code: position(id=25)=8 < position(id=7)=15 → end stays id=7.
const startTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || /^Day \d+/.test(b.textContent?.trim() ?? ''))[0];
await userEvent.click(startTrigger());
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 9'))!);
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const saved = onSave.mock.calls[0][0];
expect(saved.create_accommodation?.start_day_id).toBe(25); // Day 9
expect(saved.create_accommodation?.end_day_id).toBe(7); // Day 16 — must NOT have collapsed
});
it('FE-PLANNER-RESMODAL-052: hotel with no accommodation_id sends assignment_id as null (issue #934)', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
// Hotel reservation with assignment_id set but no accommodation
const res = buildReservation({
id: 10, title: 'Stale Hotel', type: 'hotel', status: 'confirmed',
accommodation_id: null, assignment_id: 99,
} as any);
render(<ReservationModal {...defaultProps} onSave={onSave} reservation={res} />);
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave.mock.calls[0][0].assignment_id).toBeNull();
});
});