mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
3f61e1ca38
Introduces a TransportModal for creating/editing flight, train, car, and cruise reservations that span multiple days. Transport entries now break the map route into disconnected segments so the polyline reflects actual travel legs. - Add TransportModal with airport/location pickers, multi-day date range, and all transport types - Extend DB schema with end_day_id on reservations (migration 110) and backfill from existing dates - Refactor useRouteCalculation to emit [][][number,number] segments split at transport boundaries - Update MapView, DayPlanSidebar, ReservationsPanel, TripPlannerPage to wire up transport flow - Add transport i18n keys across all 15 languages
725 lines
31 KiB
TypeScript
725 lines
31 KiB
TypeScript
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-035
|
|
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 submit the form directly
|
|
const form = screen.getByRole('button', { name: /^Add$/i }).closest('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' })
|
|
);
|
|
});
|
|
});
|