// FE-COMP-TRIPFORM-001 to FE-COMP-TRIPFORM-097 import type { Mock } from 'vitest'; import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { delay, http, HttpResponse } from 'msw'; import { useAuthStore } from '../../store/authStore'; import { useTripStore } from '../../store/tripStore'; import { useSettingsStore } from '../../store/settingsStore'; import { usePermissionsStore } from '../../store/permissionsStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildTrip, buildDay, buildAssignment, buildPlace, buildReservation, buildBudgetItem } from '../../../tests/helpers/factories'; import { server } from '../../../tests/helpers/msw/server'; import type { Accommodation, BudgetItem, Reservation, Trip } from '../../types'; import { MAX_TRIP_DAYS } from '@trek/shared'; import TripFormModal from './TripFormModal'; const defaultProps = { isOpen: true, onClose: vi.fn(), onSave: vi.fn(), trip: null, onCoverUpdate: vi.fn(), }; type AddToast = NonNullable; let addToast: Mock; /** Blob URLs handed out for staged cover files; jsdom has no real implementation. */ let createObjectURL: Mock<(obj: Blob) => string>; let originalCreateObjectURL: typeof URL.createObjectURL; beforeEach(() => { resetAllStores(); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); seedStore(useTripStore, { trip: buildTrip({ id: 1 }) }); addToast = vi.fn(() => 0); window.__addToast = addToast; createObjectURL = vi.fn(() => 'blob:cover'); originalCreateObjectURL = URL.createObjectURL; Object.defineProperty(URL, 'createObjectURL', { writable: true, configurable: true, value: createObjectURL }); }); afterEach(() => { delete window.__addToast; Object.defineProperty(URL, 'createObjectURL', { writable: true, configurable: true, value: originalCreateObjectURL }); vi.restoreAllMocks(); }); const pngFile = () => new File(['img'], 'cover.png', { type: 'image/png' }); const fileInput = () => document.querySelector('input[type="file"]') as HTMLInputElement; const submitNewTrip = async (user: ReturnType) => { const btn = screen.getAllByText('Create New Trip').find(el => el.closest('button'))!; await user.click(btn.closest('button')!); }; describe('TripFormModal', () => { it('FE-COMP-TRIPFORM-001: renders without crashing', () => { render(); expect(document.body).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-002: shows Create New Trip title for new trip', () => { render(); expect(screen.getAllByText('Create New Trip').length).toBeGreaterThan(0); }); it('FE-COMP-TRIPFORM-003: shows Edit Trip title when editing', () => { const trip = buildTrip({ id: 1, title: 'Japan 2025' }); render(); expect(screen.getByText('Edit Trip')).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-004: shows trip title input field', () => { render(); expect(screen.getByPlaceholderText(/Summer in Japan/i)).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-005: Cancel button is present', () => { render(); expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-006: clicking Cancel calls onClose', async () => { const user = userEvent.setup(); const onClose = vi.fn(); render(); await user.click(screen.getByRole('button', { name: /Cancel/i })); expect(onClose).toHaveBeenCalled(); }); it('FE-COMP-TRIPFORM-007: Create New Trip submit button is present', () => { render(); // Submit button text is "Create New Trip" for new trips const createBtns = screen.getAllByText('Create New Trip'); expect(createBtns.length).toBeGreaterThan(0); }); it('FE-COMP-TRIPFORM-008: Update button shown when editing', () => { const trip = buildTrip({ id: 1, title: 'Japan 2025' }); render(); expect(screen.getByRole('button', { name: /Update/i })).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-009: submitting with empty title shows error', async () => { const user = userEvent.setup(); render(); // Click submit without filling title const submitBtn = screen.getAllByText('Create New Trip').find( el => el.tagName === 'BUTTON' || el.closest('button') ); if (submitBtn) { await user.click(submitBtn.closest('button') || submitBtn); } // Error: "Title is required" expect(await screen.findByText('Title is required')).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-010: typing title and submitting calls onSave', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue({ trip: buildTrip({ id: 99 }) }); render(); await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Paris 2026'); const submitBtns = screen.getAllByText('Create New Trip'); const submitBtn = submitBtns.find(el => el.closest('button')); await user.click(submitBtn!.closest('button')!); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ title: 'Paris 2026' })); }); it('FE-COMP-TRIPFORM-011: pre-fills title when editing trip', () => { const trip = buildTrip({ id: 1, title: 'Iceland Adventure' }); render(); expect(screen.getByDisplayValue('Iceland Adventure')).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-012: shows Title label', () => { render(); // dashboard.tripTitle = "Title" expect(screen.getByText('Title')).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-013: shows Cover Image section', () => { render(); expect(screen.getByText('Cover Image')).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-014: shows start and end date labels', () => { render(); // Uses CustomDatePicker with labels "Start Date" and "End Date" const startEls = screen.getAllByText('Start Date'); const endEls = screen.getAllByText('End Date'); expect(startEls.length).toBeGreaterThan(0); expect(endEls.length).toBeGreaterThan(0); }); it('FE-COMP-TRIPFORM-015: renders date picker components for start and end', () => { const trip = buildTrip({ id: 1, title: 'Test Trip', start_date: '2026-06-01', end_date: '2026-06-15' }); render(); // CustomDatePicker shows formatted dates as button text (locale-dependent) // Just verify labels and form render without error expect(screen.getByText('Start Date')).toBeInTheDocument(); expect(screen.getByText('End Date')).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-016: end-date validation shows error when end < start', async () => { const user = userEvent.setup(); const onSave = vi.fn(); // Trip with end_date before start_date; title is set so title validation passes const trip = buildTrip({ id: 1, title: 'Test Trip', start_date: '2026-06-15', end_date: '2026-06-01' } as any); render(); const updateBtn = screen.getByRole('button', { name: /Update/i }); await user.click(updateBtn); await screen.findByText('End date must be after start date'); expect(onSave).not.toHaveBeenCalled(); }); it('FE-COMP-TRIPFORM-017: day count field visible when no dates set', () => { render(); expect(screen.getByText('Number of Days')).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-018: day count hidden when trip has dates', () => { const trip = buildTrip({ id: 1, start_date: '2026-06-01', end_date: '2026-06-10' }); render(); expect(screen.queryByText('Number of Days')).not.toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-019: reminder buttons visible when tripRemindersEnabled=true', async () => { seedStore(useAuthStore, { tripRemindersEnabled: true }); render(); expect(screen.getByRole('button', { name: 'None' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '1 day' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '3 days' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: '9 days' })).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Custom' })).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-020: reminder section shows disabled hint when tripRemindersEnabled=false', () => { seedStore(useAuthStore, { tripRemindersEnabled: false }); render(); expect(screen.getByText(/Trip reminders are disabled/i)).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'None' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Custom' })).not.toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-021: custom reminder input appears and accepts value', async () => { const user = userEvent.setup(); seedStore(useAuthStore, { tripRemindersEnabled: true }); render(); await user.click(screen.getByRole('button', { name: 'Custom' })); // custom reminder input has max=30 const customInput = document.querySelector('input[max="30"]') as HTMLInputElement; expect(customInput).toBeInTheDocument(); // Use fireEvent.change to set the value directly (avoids clamping from char-by-char typing) fireEvent.change(customInput, { target: { value: '14' } }); expect(customInput.value).toBe('14'); }); it('FE-COMP-TRIPFORM-022: member selector not visible when editing existing trip', () => { const trip = buildTrip({ id: 1 }); render(); expect(screen.queryByText('Travel buddies')).not.toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-023: member selector appears when creating and other users exist', async () => { server.use( http.get('/api/auth/users', () => HttpResponse.json({ users: [{ id: 100, username: 'alice' }] }) ) ); render(); expect(await screen.findByText('Travel buddies')).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-024: selecting a member adds a chip', async () => { const user = userEvent.setup(); seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'me' }), isAuthenticated: true }); server.use( http.get('/api/auth/users', () => HttpResponse.json({ users: [{ id: 100, username: 'alice' }] }) ) ); render(); // Wait for member section to load await screen.findByText('Travel buddies'); // Click the CustomSelect trigger (placeholder "Add member") const selectTrigger = screen.getByText('Add member').closest('button')!; await user.click(selectTrigger); // alice option appears in portal (document.body) const aliceOption = await screen.findByRole('button', { name: 'alice' }); await user.click(aliceOption); // alice chip should now be in the member chip list expect(screen.getByText('alice')).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-025: removing a member chip deselects them', async () => { const user = userEvent.setup(); seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'me' }), isAuthenticated: true }); server.use( http.get('/api/auth/users', () => HttpResponse.json({ users: [{ id: 100, username: 'alice' }] }) ) ); render(); await screen.findByText('Travel buddies'); // Select alice const selectTrigger = screen.getByText('Add member').closest('button')!; await user.click(selectTrigger); const aliceOption = await screen.findByRole('button', { name: 'alice' }); await user.click(aliceOption); // alice chip is present const aliceChip = screen.getByText('alice'); expect(aliceChip).toBeInTheDocument(); // Click the chip to remove alice await user.click(aliceChip.closest('button')!); // alice chip should be gone await waitFor(() => expect(screen.queryByText('alice')).not.toBeInTheDocument()); }); it('FE-COMP-TRIPFORM-026: cover image paste fires URL.createObjectURL', async () => { const mockCreateObjectURL = vi.fn(() => 'blob:mock-paste-url'); const original = URL.createObjectURL; Object.defineProperty(URL, 'createObjectURL', { writable: true, configurable: true, value: mockCreateObjectURL }); render(); const form = document.querySelector('form')!; const file = new File(['img'], 'cover.png', { type: 'image/png' }); fireEvent.paste(form, { clipboardData: { items: [{ type: 'image/png', getAsFile: () => file }], }, }); // Cover selection now normalizes the file (HEIC -> JPEG) before previewing, so the // createObjectURL call lands a microtask later; a non-HEIC file passes through unchanged. await waitFor(() => expect(mockCreateObjectURL).toHaveBeenCalledWith(file)); Object.defineProperty(URL, 'createObjectURL', { writable: true, configurable: true, value: original }); }); it('FE-COMP-TRIPFORM-027: onSave error message is displayed', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockRejectedValue(new Error('Server error')); render(); await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'My Trip'); const submitBtns = screen.getAllByText('Create New Trip'); const submitBtn = submitBtns.find(el => el.closest('button'))!; await user.click(submitBtn.closest('button')!); expect(await screen.findByText('Server error')).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-028: loading spinner shown while submitting', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockImplementation(() => new Promise(() => {})); render(); await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'My Trip'); const submitBtns = screen.getAllByText('Create New Trip'); const submitBtn = submitBtns.find(el => el.closest('button'))!; await user.click(submitBtn.closest('button')!); await waitFor(() => expect(screen.getByText('Saving...')).toBeInTheDocument()); }); it('FE-COMP-TRIPFORM-029: clearing the day count leaves the field empty (no snap to 1)', () => { render(); const dayInput = document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`) as HTMLInputElement; expect(dayInput).toBeInTheDocument(); expect(dayInput.value).toBe('7'); fireEvent.change(dayInput, { target: { value: '' } }); expect(dayInput.value).toBe(''); }); it('FE-COMP-TRIPFORM-030: empty day count blocks submit with an error', async () => { const user = userEvent.setup(); const onSave = vi.fn(); render(); await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'No-date Trip'); const dayInput = document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`) as HTMLInputElement; fireEvent.change(dayInput, { target: { value: '' } }); const submitBtn = screen.getAllByText('Create New Trip').find(el => el.closest('button'))!; await user.click(submitBtn.closest('button')!); await screen.findByText('Number of days is required'); expect(onSave).not.toHaveBeenCalled(); }); it('FE-COMP-TRIPFORM-031: selects an Unsplash cover and saves it after trip creation', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue({ trip: buildTrip({ id: 99 }) }); let updateBody: unknown; server.use( http.get('/api/trips/cover-images/search', () => HttpResponse.json({ photos: [{ id: 'unsplash-1', url: 'https://images.example.com/regular.jpg', thumb: 'https://images.example.com/thumb.jpg', description: 'Mountain lake', photographer: 'Alice', link: 'https://unsplash.com/photos/unsplash-1', }], }) ), http.put('/api/trips/99', async ({ request }) => { updateBody = await request.json(); return HttpResponse.json({ trip: buildTrip({ id: 99, cover_image: 'https://images.example.com/regular.jpg' }) }); }), ); render(); await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Alpine Trip'); await user.type(screen.getByPlaceholderText('Search destination photos'), 'alps'); await user.click(screen.getByRole('button', { name: /Search Unsplash/i })); await user.click(await screen.findByRole('button', { name: /Use Unsplash photo by Alice/i })); const submitBtn = screen.getAllByText('Create New Trip').find(el => el.closest('button'))!; await user.click(submitBtn.closest('button')!); await waitFor(() => { expect(updateBody).toMatchObject({ cover_image: 'https://images.example.com/regular.jpg' }); }); }); // The trip currency is the base every expense and settlement is netted against, and // until #1543 the only way to set it was the legacy Budget addon panel. it('FE-COMP-TRIPFORM-032: pre-fills the currency of the trip being edited', () => { render(); expect(screen.getByText(/^RUB/)).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-033: defaults a new trip to EUR and sends the currency on save', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue({ trip: buildTrip({ id: 99 }) }); render(); await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Moscow 2026'); const submitBtn = screen.getAllByText('Create New Trip').find(el => el.closest('button'))!; await user.click(submitBtn.closest('button')!); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ currency: 'EUR' })); }); it('FE-COMP-TRIPFORM-033b: a new trip defaults to the user\'s default_currency (#1784)', async () => { seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'USD' } }); const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue({ trip: buildTrip({ id: 99 }) }); render(); await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'New York 2026'); const submitBtn = screen.getAllByText('Create New Trip').find(el => el.closest('button'))!; await user.click(submitBtn.closest('button')!); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ currency: 'USD' })); }); // Changing the start of a dated trip must go through the date-shift choice step (#1288). const changeStartDate = async (user: ReturnType, iso: string) => { await user.click(screen.getAllByRole('button', { name: 'Enter date manually' })[0]); const input = screen.getByPlaceholderText('DD.MM.YYYY'); fireEvent.change(input, { target: { value: iso } }); fireEvent.keyDown(input, { key: 'Enter' }); }; it('FE-COMP-TRIPFORM-035: changing the start date shows the choice step and saves with keep_bookings by default', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue({}); const trip = buildTrip({ id: 1, title: 'Dated Trip', start_date: '2025-06-01', end_date: '2025-06-05' }); render(); await changeStartDate(user, '2025-05-31'); await user.click(screen.getByRole('button', { name: /Update/i })); // The choice step appears instead of saving right away. await screen.findByText('Keep bookings on their dates'); expect(onSave).not.toHaveBeenCalled(); await user.click(screen.getByRole('button', { name: /Update/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ start_date: '2025-05-31', date_shift_mode: 'keep_bookings', })); }); it('FE-COMP-TRIPFORM-036: picking "Shift everything" sends shift_all', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue({}); const trip = buildTrip({ id: 1, title: 'Dated Trip', start_date: '2025-06-01', end_date: '2025-06-05' }); render(); await changeStartDate(user, '2025-06-02'); await user.click(screen.getByRole('button', { name: /Update/i })); await user.click(await screen.findByRole('radio', { name: /Shift everything/i })); await user.click(screen.getByRole('button', { name: /Update/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ date_shift_mode: 'shift_all' })); }); it('FE-COMP-TRIPFORM-037: Back returns to the form without saving', async () => { const user = userEvent.setup(); const onSave = vi.fn(); const trip = buildTrip({ id: 1, title: 'Dated Trip', start_date: '2025-06-01', end_date: '2025-06-05' }); render(); await changeStartDate(user, '2025-05-30'); await user.click(screen.getByRole('button', { name: /Update/i })); await screen.findByText('Keep bookings on their dates'); await user.click(screen.getByRole('button', { name: /Back/i })); await waitFor(() => expect(screen.queryByText('Keep bookings on their dates')).not.toBeInTheDocument()); expect(screen.getByDisplayValue('Dated Trip')).toBeInTheDocument(); expect(onSave).not.toHaveBeenCalled(); }); it('FE-COMP-TRIPFORM-038: an edit that keeps the dates saves directly without the choice step', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue({}); const trip = buildTrip({ id: 1, title: 'Dated Trip', start_date: '2025-06-01', end_date: '2025-06-05' }); render(); await user.click(screen.getByRole('button', { name: /Update/i })); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(screen.queryByText('Keep bookings on their dates')).not.toBeInTheDocument(); expect(onSave).toHaveBeenCalledWith(expect.not.objectContaining({ date_shift_mode: expect.anything() })); }); it('FE-COMP-TRIPFORM-034: picking a currency sends the new one on save', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue({}); render(); await user.click(screen.getByText(/^EUR/)); await user.click(await screen.findByText(/^USD/)); await user.click(screen.getByText('Update').closest('button')!); await waitFor(() => expect(onSave).toHaveBeenCalled()); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ currency: 'USD' })); }); // ── App config / prefill fallbacks ──────────────────────────────────────── it('FE-COMP-TRIPFORM-039: app config re-enables the reminder section', async () => { server.use(http.get('/api/auth/app-config', () => HttpResponse.json({ trip_reminders_enabled: true }))); render(); // Store default is false, so the section starts as the disabled hint. expect(screen.getByText(/Trip reminders are disabled/i)).toBeInTheDocument(); await screen.findByRole('button', { name: 'Custom' }); expect(useAuthStore.getState().tripRemindersEnabled).toBe(true); }); it('FE-COMP-TRIPFORM-040: a users payload without a list hides the member section', async () => { server.use(http.get('/api/auth/users', () => HttpResponse.json({}))); render(); await screen.findByText('Cover Image'); expect(screen.queryByText('Travel buddies')).not.toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-041: a trip with blank fields falls back to the form defaults', async () => { server.use(http.get('/api/trips/5/members', () => HttpResponse.json({}))); const bare = { ...buildTrip({ id: 5 }), title: '', description: null, start_date: null, end_date: null, currency: null, reminder_days: null, day_count: 0, } as unknown as Trip; render(); expect(screen.getByPlaceholderText(/Summer in Japan/i)).toHaveValue(''); expect(screen.getByText(/^EUR/)).toBeInTheDocument(); // No dates -> the day-count field appears and falls back to 7. expect(document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`)).toHaveValue('7'); }); // ── Create follow-ups: members and cover ────────────────────────────────── it('FE-COMP-TRIPFORM-042: a save handler that returns nothing still closes the modal', async () => { const user = userEvent.setup(); const onClose = vi.fn(); const onSave = vi.fn(() => undefined); render(); await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Void Trip'); await submitNewTrip(user); await waitFor(() => expect(onClose).toHaveBeenCalled()); }); it('FE-COMP-TRIPFORM-043: selected members are attached to the freshly created trip', async () => { const user = userEvent.setup(); seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'me' }), isAuthenticated: true }); const identifiers: string[] = []; server.use( http.get('/api/auth/users', () => HttpResponse.json({ users: [{ id: 100, username: 'alice' }] })), http.post('/api/trips/99/members', async ({ request }) => { identifiers.push(((await request.json()) as { identifier: string }).identifier); return HttpResponse.json({ success: true }); }), ); const onSave = vi.fn().mockResolvedValue({ trip: buildTrip({ id: 99 }) }); render(); await screen.findByText('Travel buddies'); await user.click(screen.getByText('Add member').closest('button')!); await user.click(await screen.findByRole('button', { name: 'alice' })); await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Group Trip'); await submitNewTrip(user); await waitFor(() => expect(identifiers).toEqual(['alice'])); expect(addToast).not.toHaveBeenCalled(); }); it('FE-COMP-TRIPFORM-044: a failing member add surfaces a toast but still closes', async () => { const user = userEvent.setup(); const onClose = vi.fn(); seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'me' }), isAuthenticated: true }); server.use( http.get('/api/auth/users', () => HttpResponse.json({ users: [{ id: 100, username: 'alice' }] })), http.post('/api/trips/99/members', () => HttpResponse.json({ error: 'nope' }, { status: 500 })), ); const onSave = vi.fn().mockResolvedValue({ trip: buildTrip({ id: 99 }) }); render(); await screen.findByText('Travel buddies'); await user.click(screen.getByText('Add member').closest('button')!); await user.click(await screen.findByRole('button', { name: 'alice' })); await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Group Trip'); await submitNewTrip(user); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to add', 'error', undefined)); expect(onClose).toHaveBeenCalled(); }); it('FE-COMP-TRIPFORM-045: a staged cover file is uploaded once the trip exists', async () => { const user = userEvent.setup(); const onCoverUpdate = vi.fn(); server.use( http.post('/api/trips/99/cover', () => HttpResponse.json({ cover_image: '/uploads/covers/new.jpg' })), ); const onSave = vi.fn().mockResolvedValue({ trip: buildTrip({ id: 99 }) }); render(); fireEvent.change(fileInput(), { target: { files: [pngFile()] } }); await waitFor(() => expect(createObjectURL).toHaveBeenCalled()); await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Cover Trip'); await submitNewTrip(user); await waitFor(() => expect(onCoverUpdate).toHaveBeenCalledWith(99, '/uploads/covers/new.jpg')); }); it('FE-COMP-TRIPFORM-046: a failing cover upload after create only warns', async () => { const user = userEvent.setup(); const onCoverUpdate = vi.fn(); server.use(http.post('/api/trips/99/cover', () => HttpResponse.json({}, { status: 500 }))); const onSave = vi.fn().mockResolvedValue({ trip: buildTrip({ id: 99 }) }); render(); fireEvent.change(fileInput(), { target: { files: [pngFile()] } }); await waitFor(() => expect(createObjectURL).toHaveBeenCalled()); await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Cover Trip'); await submitNewTrip(user); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to upload', 'error', undefined)); expect(onCoverUpdate).not.toHaveBeenCalled(); }); it('FE-COMP-TRIPFORM-047: a failing Unsplash cover save after create only warns', async () => { const user = userEvent.setup(); server.use( http.get('/api/trips/cover-images/search', () => HttpResponse.json({ photos: [{ id: 'p1', url: 'https://img/regular.jpg', thumb: 'https://img/t.jpg', photographer: 'Alice' }] }) ), http.put('/api/trips/99', () => HttpResponse.json({}, { status: 500 })), ); const onSave = vi.fn().mockResolvedValue({ trip: buildTrip({ id: 99 }) }); render(); await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Alpine Trip'); await user.click(screen.getByRole('button', { name: /Search Unsplash/i })); await user.click(await screen.findByRole('button', { name: /Use Unsplash photo by Alice/i })); await submitNewTrip(user); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to save cover image', 'error', undefined)); }); // ── Cover upload on an existing trip ────────────────────────────────────── it('FE-COMP-TRIPFORM-048: picking a file on an existing trip uploads it immediately', async () => { const onCoverUpdate = vi.fn(); server.use( http.post('/api/trips/1/cover', () => HttpResponse.json({ cover_image: '/uploads/covers/edit.jpg' })), ); render(); fireEvent.change(fileInput(), { target: { files: [pngFile()] } }); await waitFor(() => expect(onCoverUpdate).toHaveBeenCalledWith(1, '/uploads/covers/edit.jpg')); expect(addToast).toHaveBeenCalledWith('Cover image saved', 'success', undefined); expect(document.querySelector('img[src="/uploads/covers/edit.jpg"]')).toBeInTheDocument(); // Staging a file is only for new trips — nothing gets a blob URL here. expect(createObjectURL).not.toHaveBeenCalled(); }); it('FE-COMP-TRIPFORM-049: a failing immediate cover upload shows an error toast', async () => { const onCoverUpdate = vi.fn(); server.use(http.post('/api/trips/1/cover', () => HttpResponse.json({}, { status: 500 }))); render(); fireEvent.change(fileInput(), { target: { files: [pngFile()] } }); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to upload', 'error', undefined)); expect(onCoverUpdate).not.toHaveBeenCalled(); }); it('FE-COMP-TRIPFORM-050: clearing the file picker uploads nothing', async () => { let uploads = 0; server.use(http.post('/api/trips/1/cover', () => { uploads++; return HttpResponse.json({ cover_image: 'x' }); })); render(); fireEvent.change(fileInput(), { target: { files: [] } }); await waitFor(() => expect(fileInput().value).toBe('')); expect(uploads).toBe(0); expect(addToast).not.toHaveBeenCalled(); expect(screen.getByRole('button', { name: /Add cover image/i })).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-051: the Change button opens the hidden file picker', async () => { const user = userEvent.setup(); render(); const clickSpy = vi.spyOn(fileInput(), 'click').mockImplementation(() => {}); await user.click(screen.getByRole('button', { name: /Change/i })); expect(clickSpy).toHaveBeenCalled(); expect(document.querySelector('img[src="/uploads/covers/a.jpg"]')).toBeInTheDocument(); }); // ── Unsplash search ─────────────────────────────────────────────────────── it('FE-COMP-TRIPFORM-052: pressing Enter without a query or title asks for a search term', async () => { render(); const search = screen.getByPlaceholderText('Search destination photos'); fireEvent.keyDown(search, { key: 'a' }); expect(screen.queryByText('Enter a search term')).not.toBeInTheDocument(); fireEvent.keyDown(search, { key: 'Enter' }); await screen.findByText('Enter a search term'); }); it('FE-COMP-TRIPFORM-053: a search without results reports it', async () => { const user = userEvent.setup(); server.use(http.get('/api/trips/cover-images/search', () => HttpResponse.json({}))); render(); await user.type(screen.getByPlaceholderText('Search destination photos'), 'nowhere'); await user.click(screen.getByRole('button', { name: /Search Unsplash/i })); expect(await screen.findByText('No images found')).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-054: a failing search shows the server error', async () => { const user = userEvent.setup(); server.use( http.get('/api/trips/cover-images/search', () => HttpResponse.json({ error: 'Unsplash key missing' }, { status: 500 }) ), ); render(); await user.type(screen.getByPlaceholderText('Search destination photos'), 'alps'); await user.click(screen.getByRole('button', { name: /Search Unsplash/i })); expect(await screen.findByText('Unsplash key missing')).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-055: a photo without a photographer falls back in the label and drops the credit', async () => { const user = userEvent.setup(); server.use( http.get('/api/trips/cover-images/search', () => HttpResponse.json({ photos: [{ id: 'p1', url: '', thumb: 'https://img/t.jpg', description: null, photographer: null }] }) ), ); render(); await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Alps'); await user.click(screen.getByRole('button', { name: /Search Unsplash/i })); const photoBtn = await screen.findByRole('button', { name: 'Use Unsplash photo by Unsplash' }); expect(photoBtn.querySelector('img')).toHaveAttribute('alt', ''); expect(photoBtn.querySelector('span')).toBeNull(); // An empty url is not selectable — nothing is staged. await user.click(photoBtn); expect(document.querySelector('img[src="https://img/t.jpg"]')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Change/i })).not.toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-056: picking an Unsplash photo while editing saves it right away', async () => { const user = userEvent.setup(); const onCoverUpdate = vi.fn(); let putBody: Record | null = null; server.use( http.get('/api/trips/cover-images/search', () => HttpResponse.json({ photos: [{ id: 'p1', url: 'https://img/regular.jpg', thumb: 'https://img/t.jpg', photographer: 'Bob' }] }) ), http.put('/api/trips/1', async ({ request }) => { putBody = (await request.json()) as Record; return HttpResponse.json({ trip: buildTrip({ id: 1 }) }); }), ); render(); await user.type(screen.getByPlaceholderText('Search destination photos'), 'alps'); await user.click(screen.getByRole('button', { name: /Search Unsplash/i })); await user.click(await screen.findByRole('button', { name: /Use Unsplash photo by Bob/i })); await waitFor(() => expect(putBody).toMatchObject({ cover_image: 'https://img/regular.jpg' })); expect(onCoverUpdate).toHaveBeenCalledWith(1, 'https://img/regular.jpg'); expect(addToast).toHaveBeenCalledWith('Cover image saved', 'success', undefined); }); it('FE-COMP-TRIPFORM-057: a failing Unsplash save while editing shows the server error', async () => { const user = userEvent.setup(); server.use( http.get('/api/trips/cover-images/search', () => HttpResponse.json({ photos: [{ id: 'p1', url: 'https://img/regular.jpg', thumb: 'https://img/t.jpg', photographer: 'Bob' }] }) ), http.put('/api/trips/1', () => HttpResponse.json({ error: 'Cover rejected' }, { status: 500 })), ); render(); await user.type(screen.getByPlaceholderText('Search destination photos'), 'alps'); await user.click(screen.getByRole('button', { name: /Search Unsplash/i })); await user.click(await screen.findByRole('button', { name: /Use Unsplash photo by Bob/i })); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Cover rejected', 'error', undefined)); }); // ── Removing a cover ────────────────────────────────────────────────────── it('FE-COMP-TRIPFORM-058: removing a staged cover only clears the preview', async () => { const user = userEvent.setup(); let puts = 0; server.use(http.put('/api/trips/1', () => { puts++; return HttpResponse.json({}); })); render(); fireEvent.change(fileInput(), { target: { files: [pngFile()] } }); await waitFor(() => expect(document.querySelector('img[src="blob:cover"]')).toBeInTheDocument()); await user.click(screen.getByRole('button', { name: /Change/i }).nextElementSibling as HTMLElement); await waitFor(() => expect(document.querySelector('img[src="blob:cover"]')).not.toBeInTheDocument()); expect(puts).toBe(0); }); it('FE-COMP-TRIPFORM-059: removing an existing cover clears it on the server', async () => { const user = userEvent.setup(); const onCoverUpdate = vi.fn(); let putBody: Record | null = null; server.use( http.put('/api/trips/1', async ({ request }) => { putBody = (await request.json()) as Record; return HttpResponse.json({ trip: buildTrip({ id: 1 }) }); }), ); render(); await user.click(screen.getByRole('button', { name: /Change/i }).nextElementSibling as HTMLElement); await waitFor(() => expect(putBody).toMatchObject({ cover_image: null })); expect(onCoverUpdate).toHaveBeenCalledWith(1, null); expect(document.querySelector('img[src="/uploads/covers/a.jpg"]')).not.toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-060: a failing cover removal keeps the preview and warns', async () => { const user = userEvent.setup(); server.use(http.put('/api/trips/1', () => HttpResponse.json({}, { status: 500 }))); render(); await user.click(screen.getByRole('button', { name: /Change/i }).nextElementSibling as HTMLElement); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to remove', 'error', undefined)); expect(document.querySelector('img[src="/uploads/covers/a.jpg"]')).toBeInTheDocument(); }); // ── Drag & drop / paste ─────────────────────────────────────────────────── it('FE-COMP-TRIPFORM-061: the drop zone only accepts image drops', async () => { render(); const zone = screen.getByRole('button', { name: /Add cover image/i }); // Dragging over highlights the zone in the user's accent. This used to be // four handlers writing element.style, which is how a hard-coded indigo // outlived the switch to a configurable accent. fireEvent.dragOver(zone); expect(zone.className).toContain('border-accent'); fireEvent.dragLeave(zone); expect(zone.className).not.toContain('border-accent'); fireEvent.drop(zone, { dataTransfer: { files: [new File(['x'], 'notes.txt', { type: 'text/plain' })] } }); expect(createObjectURL).not.toHaveBeenCalled(); fireEvent.drop(zone, { dataTransfer: { files: [pngFile()] } }); await waitFor(() => expect(document.querySelector('img[src="blob:cover"]')).toBeInTheDocument()); }); it('FE-COMP-TRIPFORM-062: pastes without a usable image are ignored', () => { render(); const form = document.querySelector('form')!; fireEvent.paste(form, { clipboardData: {} }); fireEvent.paste(form, { clipboardData: { items: [{ type: 'text/plain', getAsFile: () => null }] } }); fireEvent.paste(form, { clipboardData: { items: [{ type: 'image/png', getAsFile: () => null }] } }); expect(createObjectURL).not.toHaveBeenCalled(); }); it('FE-COMP-TRIPFORM-063: without the cover permission the section and paste handler are inert', () => { seedStore(useAuthStore, { user: buildUser({ id: 7, role: 'user' }), isAuthenticated: true }); seedStore(usePermissionsStore, { permissions: { trip_cover_upload: 'admin', trip_edit: 'admin' } }); render(); expect(screen.queryByText('Cover Image')).not.toBeInTheDocument(); fireEvent.paste(document.querySelector('form')!, { clipboardData: { items: [{ type: 'image/png', getAsFile: () => pngFile() }] }, }); expect(createObjectURL).not.toHaveBeenCalled(); }); it('FE-COMP-TRIPFORM-064: without the edit permission the text fields are read-only', () => { seedStore(useAuthStore, { user: buildUser({ id: 7, role: 'user' }), isAuthenticated: true }); seedStore(usePermissionsStore, { permissions: { trip_edit: 'admin' } }); render(); const title = screen.getByPlaceholderText(/Summer in Japan/i); expect(title).toHaveAttribute('readonly'); fireEvent.change(title, { target: { value: 'Hacked' } }); expect(title).toHaveValue('Locked'); const desc = screen.getByPlaceholderText(/What is this trip about/i); fireEvent.change(desc, { target: { value: 'Hacked' } }); expect(desc).toHaveValue('read only'); }); // ── Dates, day count, currency, reminder ────────────────────────────────── it('FE-COMP-TRIPFORM-065: setting a start date without an end date mirrors it', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue({}); render(); await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Open Ended'); await user.click(screen.getAllByRole('button', { name: 'Enter date manually' })[0]); const input = screen.getByPlaceholderText('DD.MM.YYYY'); fireEvent.change(input, { target: { value: '2026-04-10' } }); fireEvent.keyDown(input, { key: 'Enter' }); // The day-count field disappears once the trip is dated. await waitFor(() => expect(document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`)).toBeNull()); await submitNewTrip(user); await waitFor(() => expect(onSave).toHaveBeenCalledWith( expect.objectContaining({ start_date: '2026-04-10', end_date: '2026-04-10' }) )); }); it('FE-COMP-TRIPFORM-066: an end date set first survives a later, earlier start date', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue({}); render(); await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Backwards'); await user.click(screen.getAllByRole('button', { name: 'Enter date manually' })[1]); const endInput = screen.getByPlaceholderText('DD.MM.YYYY'); fireEvent.change(endInput, { target: { value: '2026-03-01' } }); fireEvent.keyDown(endInput, { key: 'Enter' }); await user.click(screen.getAllByRole('button', { name: 'Enter date manually' })[0]); const startInput = screen.getByPlaceholderText('DD.MM.YYYY'); fireEvent.change(startInput, { target: { value: '2026-01-01' } }); fireEvent.keyDown(startInput, { key: 'Enter' }); await submitNewTrip(user); await waitFor(() => expect(onSave).toHaveBeenCalledWith( expect.objectContaining({ start_date: '2026-01-01', end_date: '2026-03-01' }) )); }); it('FE-COMP-TRIPFORM-067: the day count is clamped to the 1..MAX_TRIP_DAYS range', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue({}); render(); const dayInput = document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`) as HTMLInputElement; fireEvent.change(dayInput, { target: { value: String(MAX_TRIP_DAYS + 1) } }); expect(dayInput.value).toBe(String(MAX_TRIP_DAYS)); await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Long Trip'); await submitNewTrip(user); await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ day_count: MAX_TRIP_DAYS }))); }); it('FE-COMP-TRIPFORM-068: a currency without a known symbol is labelled with its code', () => { render(); expect(screen.getByText('XTS (XTS)')).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-069: a reminder preset is sent on save', async () => { const user = userEvent.setup(); seedStore(useAuthStore, { tripRemindersEnabled: true }); const onSave = vi.fn().mockResolvedValue({}); render(); await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Reminder Trip'); await user.click(screen.getByRole('button', { name: '9 days' })); await submitNewTrip(user); await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ reminder_days: 9 }))); }); it('FE-COMP-TRIPFORM-070: switching to a custom reminder seeds 7 and clamps an empty value to 1', async () => { const user = userEvent.setup(); seedStore(useAuthStore, { tripRemindersEnabled: true }); const onSave = vi.fn().mockResolvedValue({}); render(); await user.click(screen.getByRole('button', { name: 'Custom' })); const custom = document.querySelector('input[max="30"]') as HTMLInputElement; expect(custom.value).toBe('7'); fireEvent.change(custom, { target: { value: '' } }); expect(custom.value).toBe('1'); await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Custom Reminder'); await submitNewTrip(user); await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ reminder_days: 1 }))); }); it('FE-COMP-TRIPFORM-071: a trip with a custom reminder opens on the Custom tab', async () => { seedStore(useAuthStore, { user: buildUser({ id: 1 }), tripRemindersEnabled: true }); render(); const custom = document.querySelector('input[max="30"]') as HTMLInputElement; expect(custom.value).toBe('14'); }); // ── Members while editing ───────────────────────────────────────────────── const editMembersServer = (members: { id: number; username: string }[]) => { server.use( http.get('/api/auth/users', () => HttpResponse.json({ users: [{ id: 1, username: 'me' }, { id: 100, username: 'alice' }, { id: 200, username: 'bob' }] }) ), http.get('/api/trips/1/members', () => HttpResponse.json({ members })), ); }; it('FE-COMP-TRIPFORM-072: clicking a member chip removes them, the own chip is inert', async () => { const user = userEvent.setup(); seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'me' }), isAuthenticated: true }); let deletedId: string | null = null; editMembersServer([{ id: 1, username: 'me' }, { id: 100, username: 'alice' }]); server.use( http.delete('/api/trips/1/members/:userId', ({ params }) => { deletedId = params.userId as string; return HttpResponse.json({ success: true }); }), ); render(); const own = await screen.findByText('me'); await user.click(own); expect(deletedId).toBeNull(); await user.click(screen.getByText('alice')); await waitFor(() => expect(deletedId).toBe('100')); expect(addToast).toHaveBeenCalledWith('alice removed', 'success', undefined); await waitFor(() => expect(screen.queryByText('alice')).not.toBeInTheDocument()); }); it('FE-COMP-TRIPFORM-073: a failing member removal keeps the chip', async () => { const user = userEvent.setup(); seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'me' }), isAuthenticated: true }); editMembersServer([{ id: 100, username: 'alice' }]); server.use(http.delete('/api/trips/1/members/:userId', () => HttpResponse.json({}, { status: 500 }))); render(); await user.click(await screen.findByText('alice')); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to remove', 'error', undefined)); expect(screen.getByText('alice')).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-074: picking a user while editing adds them straight away', async () => { const user = userEvent.setup(); seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'me' }), isAuthenticated: true }); let identifier: string | null = null; editMembersServer([{ id: 100, username: 'alice' }]); server.use( http.post('/api/trips/1/members', async ({ request }) => { identifier = ((await request.json()) as { identifier: string }).identifier; return HttpResponse.json({ success: true }); }), ); render(); await screen.findByText('alice'); await user.click(screen.getByText('Add member').closest('button')!); // alice is already a member, so the option list offers only bob (her chip // outside the list is a button of its own — that is what removes her). const bobOption = await screen.findByRole('button', { name: 'bob' }); expect(within(bobOption.parentElement!).queryByRole('button', { name: 'alice' })).toBeNull(); await user.click(bobOption); await waitFor(() => expect(identifier).toBe('bob')); expect(addToast).toHaveBeenCalledWith('bob added', 'success', undefined); expect(screen.getByText('bob')).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-075: a failing member add while editing shows an error', async () => { const user = userEvent.setup(); seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'me' }), isAuthenticated: true }); editMembersServer([{ id: 100, username: 'alice' }]); server.use(http.post('/api/trips/1/members', () => HttpResponse.json({}, { status: 500 }))); render(); await screen.findByText('alice'); await user.click(screen.getByText('Add member').closest('button')!); await user.click(await screen.findByRole('button', { name: 'bob' })); await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to add', 'error', undefined)); }); // ── Save error paths ────────────────────────────────────────────────────── it('FE-COMP-TRIPFORM-076: a non-Error rejection falls back to the generic save error', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockRejectedValue('boom'); render(); await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Broken'); await submitNewTrip(user); expect(await screen.findByText('Failed to save')).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-077: a save error from the date-shift step is shown on that step', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockRejectedValue(new Error('Shift failed')); render(); await changeStartDate(user, '2025-05-31'); await user.click(screen.getByRole('button', { name: /Update/i })); await screen.findByText('Keep bookings on their dates'); await user.click(screen.getByRole('button', { name: /Update/i })); await waitFor(() => expect(screen.getAllByText('Shift failed').length).toBeGreaterThan(0)); expect(screen.getByText('Keep bookings on their dates')).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-078: the empty drop zone opens the hidden file picker', async () => { const user = userEvent.setup(); render(); const clickSpy = vi.spyOn(fileInput(), 'click').mockImplementation(() => {}); await user.click(screen.getByRole('button', { name: /Add cover image/i })); expect(clickSpy).toHaveBeenCalled(); }); it('FE-COMP-TRIPFORM-079: a typed description is sent on save', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue({}); render(); await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Described'); await user.type(screen.getByPlaceholderText(/What is this trip about/i), ' Two weeks off '); await submitNewTrip(user); await waitFor(() => expect(onSave).toHaveBeenCalledWith( expect.objectContaining({ description: 'Two weeks off' }) )); }); it('FE-COMP-TRIPFORM-080: the cover button reports the upload while it runs', async () => { server.use( http.post('/api/trips/1/cover', async () => { await delay(30); return HttpResponse.json({ cover_image: '/uploads/covers/late.jpg' }); }), ); render(); fireEvent.change(fileInput(), { target: { files: [pngFile()] } }); await screen.findByText(/Uploading/); await waitFor(() => expect(screen.getByRole('button', { name: /Change/i })).toBeInTheDocument()); expect(document.querySelector('img[src="/uploads/covers/late.jpg"]')).toBeInTheDocument(); }); // A slow first search must never overwrite the results of a newer one (#1277). it('FE-COMP-TRIPFORM-081: a stale successful search response is discarded', async () => { let staleResolved = false; server.use( http.get('/api/trips/cover-images/search', async ({ request }) => { const query = new URL(request.url).searchParams.get('query'); if (query === 'slow') { await delay(60); staleResolved = true; return HttpResponse.json({ photos: [{ id: 's', url: 'https://img/s.jpg', thumb: 'https://img/st.jpg', photographer: 'Stale' }] }); } return HttpResponse.json({ photos: [{ id: 'f', url: 'https://img/f.jpg', thumb: 'https://img/ft.jpg', photographer: 'Fresh' }] }); }), ); render(); const search = screen.getByPlaceholderText('Search destination photos'); fireEvent.change(search, { target: { value: 'slow' } }); fireEvent.keyDown(search, { key: 'Enter' }); fireEvent.change(search, { target: { value: 'fast' } }); fireEvent.keyDown(search, { key: 'Enter' }); await screen.findByRole('button', { name: /Use Unsplash photo by Fresh/i }); await waitFor(() => expect(staleResolved).toBe(true)); expect(screen.queryByRole('button', { name: /Use Unsplash photo by Stale/i })).not.toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-082: a stale failing search does not clobber fresh results', async () => { let staleResolved = false; server.use( http.get('/api/trips/cover-images/search', async ({ request }) => { const query = new URL(request.url).searchParams.get('query'); if (query === 'slow') { await delay(60); staleResolved = true; return HttpResponse.json({ error: 'Stale failure' }, { status: 500 }); } return HttpResponse.json({ photos: [{ id: 'f', url: 'https://img/f.jpg', thumb: 'https://img/ft.jpg', photographer: 'Fresh' }] }); }), ); render(); const search = screen.getByPlaceholderText('Search destination photos'); fireEvent.change(search, { target: { value: 'slow' } }); fireEvent.keyDown(search, { key: 'Enter' }); fireEvent.change(search, { target: { value: 'fast' } }); fireEvent.keyDown(search, { key: 'Enter' }); await screen.findByRole('button', { name: /Use Unsplash photo by Fresh/i }); await waitFor(() => expect(staleResolved).toBe(true)); expect(screen.queryByText('Stale failure')).not.toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-083: a closed modal fetches nothing until it is opened', async () => { // The trip planner keeps the modal mounted behind the page. const seen: string[] = []; server.use( http.get('/api/auth/users', () => { seen.push('users'); return HttpResponse.json({ users: [] }); }), http.get('/api/trips/:id/members', () => { seen.push('members'); return HttpResponse.json({ members: [] }); }), ); const trip = buildTrip({ id: 5 }); const { rerender } = render(); await new Promise(resolve => setTimeout(resolve, 0)); expect(seen).toEqual([]); rerender(); await waitFor(() => expect(seen).toContain('users')); expect(seen).toContain('members'); }); it('FE-COMP-TRIPFORM-084: staging another cover revokes the preview it replaces', async () => { const revokeObjectURL = vi.fn(); const original = URL.revokeObjectURL; Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: revokeObjectURL }); createObjectURL.mockReturnValueOnce('blob:first').mockReturnValueOnce('blob:second'); render(); fireEvent.change(fileInput(), { target: { files: [pngFile()] } }); await waitFor(() => expect(createObjectURL).toHaveBeenCalledTimes(1)); fireEvent.change(fileInput(), { target: { files: [pngFile()] } }); await waitFor(() => expect(revokeObjectURL).toHaveBeenCalledWith('blob:first')); Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: original }); }); it('FE-COMP-TRIPFORM-085: a trip longer than a year is saved with its full range (#2403)', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue({}); const trip = buildTrip({ id: 1, title: 'Gap year', start_date: '2025-01-26', end_date: '2026-01-28' }); render(); await user.click(screen.getByRole('button', { name: /Update/i })); await waitFor(() => expect(onSave).toHaveBeenCalledWith( expect.objectContaining({ start_date: '2025-01-26', end_date: '2026-01-28' }) )); }); it('FE-COMP-TRIPFORM-086: a range past MAX_TRIP_DAYS is refused before anything is sent', async () => { const user = userEvent.setup(); const onSave = vi.fn(); const trip = buildTrip({ id: 1, title: 'Week', start_date: '2026-01-01', end_date: '2026-01-07' }); render(); await user.click(screen.getAllByRole('button', { name: 'Enter date manually' })[1]); const endInput = screen.getByPlaceholderText('DD.MM.YYYY'); fireEvent.change(endInput, { target: { value: '2036-01-01' } }); fireEvent.keyDown(endInput, { key: 'Enter' }); await user.click(screen.getByRole('button', { name: /Update/i })); await screen.findByText(`A trip can span at most ${MAX_TRIP_DAYS} days`); expect(onSave).not.toHaveBeenCalled(); }); it('FE-COMP-TRIPFORM-087: a trip stored with a longer range can still be renamed', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue({}); const trip = buildTrip({ id: 1, title: 'Legacy', start_date: '2020-01-01', end_date: '2030-01-01' }); render(); await user.clear(screen.getByPlaceholderText(/Summer in Japan/i)); await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'Renamed'); await user.click(screen.getByRole('button', { name: /Update/i })); await waitFor(() => expect(onSave).toHaveBeenCalledWith( expect.objectContaining({ title: 'Renamed', start_date: '2020-01-01', end_date: '2030-01-01' }) )); }); // ── Shortening: the warning before days go ──────────────────────────────── /** A week in October with a place on its last two days, and the trip's bookings and stays. */ const shrinkableTrip = () => buildTrip({ id: 1, title: 'Coast week', start_date: '2026-10-01', end_date: '2026-10-07' }); const seedTripDays = (extras: { reservations?: Reservation[]; accommodations?: Accommodation[]; budget?: BudgetItem[]; empty?: boolean } = {}) => { const days = Array.from({ length: 7 }, (_, i) => buildDay({ id: 300 + i, trip_id: 1, day_number: i + 1, date: `2026-10-0${i + 1}`, assignments: !extras.empty && i >= 5 ? [buildAssignment({ day_id: 300 + i, place: buildPlace({ id: 400 + i }) })] : [], })); server.use( http.get('/api/trips/:id/days', () => HttpResponse.json({ days })), http.get('/api/trips/:id/reservations', () => HttpResponse.json({ reservations: extras.reservations ?? [] })), http.get('/api/trips/:id/accommodations', () => HttpResponse.json({ accommodations: extras.accommodations ?? [] })), http.get('/api/trips/:id/budget', () => HttpResponse.json({ items: extras.budget ?? [] })), ); }; const changeEndDate = async (user: ReturnType, iso: string) => { await user.click(screen.getAllByRole('button', { name: 'Enter date manually' })[1]); const input = screen.getByPlaceholderText('DD.MM.YYYY'); fireEvent.change(input, { target: { value: iso } }); fireEvent.keyDown(input, { key: 'Enter' }); }; it('FE-COMP-TRIPFORM-088: an earlier end asks first and names the days it takes, with what is on them', async () => { seedTripDays({ accommodations: [{ id: 9, trip_id: 1, start_day_id: 304, end_day_id: 306, place_name: 'Harbour Hotel' } as Accommodation], reservations: [buildReservation({ id: 70, day_id: 304, type: 'hotel', title: 'Harbour, 2 nights', accommodation_id: 9 })], budget: [buildBudgetItem({ reservation_id: 70, total_price: 240 })], }); const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue({}); render(); await changeEndDate(user, '2026-10-05'); await user.click(screen.getByRole('button', { name: /Update/i })); const list = await screen.findByRole('list', { name: 'Remove days?' }); expect(screen.getAllByText('Remove days?').length).toBeGreaterThan(0); expect(screen.getByText('Saving the new dates removes these days:')).toBeInTheDocument(); expect(within(list).getByText('Tue, Oct 6')).toBeInTheDocument(); expect(within(list).getByText('Wed, Oct 7')).toBeInTheDocument(); expect(within(list).getByText('Stay at Harbour Hotel').closest('li')).toHaveAttribute('data-tone', 'danger'); expect(within(list).getByText(/Its booking "Harbour, 2 nights" and its expense stay under Bookings/)).toBeInTheDocument(); expect(within(list).getByText('Planned places: 2')).toBeInTheDocument(); // No start moved, so no shift question. expect(screen.queryByText('Keep bookings on their dates')).not.toBeInTheDocument(); // The longest confirm label of any language stays on one line. expect(screen.getByRole('button', { name: 'Remove days and save' })).toHaveClass('bg-danger', 'whitespace-nowrap'); expect(onSave).not.toHaveBeenCalled(); }); it('FE-COMP-TRIPFORM-089: a later start that also shortens asks both in one step', async () => { seedTripDays(); const user = userEvent.setup(); render(); // Moving the start keeps the length, so the end is pulled back afterwards. await changeStartDate(user, '2026-10-03'); await changeEndDate(user, '2026-10-07'); await user.click(screen.getByRole('button', { name: /Update/i })); await screen.findByText('Keep bookings on their dates'); expect(screen.getByText('New start date')).toBeInTheDocument(); const list = screen.getByRole('list', { name: 'Remove days?' }); expect(within(list).getByText('The last days go, not the first')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Remove days and save' })).toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-090: a longer trip, or a shorter one that loses only empty days, saves without asking', async () => { seedTripDays({ empty: true }); const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue({}); render(); await changeEndDate(user, '2026-10-05'); await user.click(screen.getByRole('button', { name: /Update/i })); await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ end_date: '2026-10-05' }))); expect(onSave).toHaveBeenCalledWith(expect.not.objectContaining({ date_shift_mode: expect.anything() })); expect(screen.queryByRole('list', { name: 'Remove days?' })).not.toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-091: confirming saves the held payload, with the shift mode only when it was asked', async () => { seedTripDays(); const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue({}); render(); await changeEndDate(user, '2026-10-05'); await user.click(screen.getByRole('button', { name: /Update/i })); await user.click(await screen.findByRole('button', { name: 'Remove days and save' })); await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ start_date: '2026-10-01', end_date: '2026-10-05' })); expect(onSave).toHaveBeenCalledWith(expect.not.objectContaining({ date_shift_mode: expect.anything() })); }); it('FE-COMP-TRIPFORM-092: the booking hint follows the shift mode picked above the list', async () => { seedTripDays({ reservations: [buildReservation({ id: 71, day_id: 306, title: 'Ferry' })] }); const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue({}); render(); await changeStartDate(user, '2026-10-02'); await changeEndDate(user, '2026-10-06'); await user.click(screen.getByRole('button', { name: /Update/i })); const list = await screen.findByRole('list', { name: 'Remove days?' }); expect(within(list).getByText(/One whose date is still part of the trip goes back onto that day/)).toBeInTheDocument(); await user.click(screen.getByRole('radio', { name: /Shift everything/i })); expect(within(list).getByText('They stay under Bookings, without a day.')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Remove days and save' })); await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ date_shift_mode: 'shift_all' }))); }); it('FE-COMP-TRIPFORM-093: Back from the warning returns to the form and saves nothing', async () => { seedTripDays(); const user = userEvent.setup(); const onSave = vi.fn(); render(); await changeEndDate(user, '2026-10-05'); await user.click(screen.getByRole('button', { name: /Update/i })); await screen.findByRole('list', { name: 'Remove days?' }); await user.click(screen.getByRole('button', { name: /Back/i })); await waitFor(() => expect(screen.queryByRole('list', { name: 'Remove days?' })).not.toBeInTheDocument()); expect(screen.getByDisplayValue('Coast week')).toBeInTheDocument(); expect(onSave).not.toHaveBeenCalled(); }); it('FE-COMP-TRIPFORM-096: Escape in the warning goes back to the form, like Back, and keeps the edit', async () => { seedTripDays(); const user = userEvent.setup(); const onClose = vi.fn(); render(); const title = screen.getByDisplayValue('Coast week'); await user.clear(title); await user.type(title, 'Coast week, shorter'); await changeEndDate(user, '2026-10-05'); await user.click(screen.getByRole('button', { name: /Update/i })); await screen.findByRole('list', { name: 'Remove days?' }); fireEvent.keyDown(document, { key: 'Escape' }); await waitFor(() => expect(screen.queryByRole('list', { name: 'Remove days?' })).not.toBeInTheDocument()); expect(onClose).not.toHaveBeenCalled(); expect(screen.getByDisplayValue('Coast week, shorter')).toBeInTheDocument(); // Out of the warning, Escape closes the dialog as before. fireEvent.keyDown(document, { key: 'Escape' }); expect(onClose).toHaveBeenCalledTimes(1); }); it('FE-COMP-TRIPFORM-097: a removed stay whose booking carries no expense does not claim one', async () => { seedTripDays({ accommodations: [{ id: 9, trip_id: 1, start_day_id: 304, end_day_id: 306, place_name: 'Harbour Hotel' } as Accommodation], reservations: [buildReservation({ id: 70, day_id: 304, type: 'hotel', title: 'Harbour, 2 nights', accommodation_id: 9 })], }); const user = userEvent.setup(); render(); await changeEndDate(user, '2026-10-05'); await user.click(screen.getByRole('button', { name: /Update/i })); const list = await screen.findByRole('list', { name: 'Remove days?' }); expect(within(list).getByText('Checks in or out on a removed day, so the whole stay is removed. Its booking "Harbour, 2 nights" stays under Bookings.')).toBeInTheDocument(); expect(within(list).queryByText(/its expense/)).not.toBeInTheDocument(); }); it('FE-COMP-TRIPFORM-094: when the days cannot be read it still warns, in general terms', async () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); server.use(http.get('/api/trips/:id/days', () => HttpResponse.json({ error: 'boom' }, { status: 500 }))); const user = userEvent.setup(); render(); await changeEndDate(user, '2026-10-05'); await user.click(screen.getByRole('button', { name: /Update/i })); expect(await screen.findByRole('alert')).toHaveTextContent('The days of this trip could not be checked.'); expect(screen.queryByRole('list', { name: 'Remove days?' })).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Remove days and save' })).toBeInTheDocument(); spy.mockRestore(); }); it('FE-COMP-TRIPFORM-095: a trip without dates sends its day count only when it changed', async () => { const user = userEvent.setup(); const onSave = vi.fn().mockResolvedValue({}); const undated = buildTrip({ id: 1, title: 'Someday', start_date: null, end_date: null, day_count: 4 }); const { unmount } = render(); await user.click(screen.getByRole('button', { name: /Update/i })); await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); expect(onSave.mock.calls[0][0]).not.toHaveProperty('day_count'); unmount(); render(); const dayInput = document.querySelector(`input[max="${MAX_TRIP_DAYS}"]`) as HTMLInputElement; fireEvent.change(dayInput, { target: { value: '6' } }); await user.click(screen.getByRole('button', { name: /Update/i })); await waitFor(() => expect(onSave).toHaveBeenCalledTimes(2)); expect(onSave.mock.calls[1][0]).toMatchObject({ day_count: 6 }); }); });