// FE-COMP-TRIPFORM-001 to FE-COMP-TRIPFORM-028 import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; import { useAuthStore } from '../../store/authStore'; import { useTripStore } from '../../store/tripStore'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { buildUser, buildTrip } from '../../../tests/helpers/factories'; import { server } from '../../../tests/helpers/msw/server'; import TripFormModal from './TripFormModal'; const defaultProps = { isOpen: true, onClose: vi.fn(), onSave: vi.fn(), trip: null, onCoverUpdate: vi.fn(), }; beforeEach(() => { resetAllStores(); seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); seedStore(useTripStore, { trip: buildTrip({ id: 1 }) }); }); 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" await screen.findByText('Title is required'); }); 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(); await screen.findByText('Travel buddies'); }); 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('span')!); // 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')!); await screen.findByText('Server error'); }); 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="365"]') 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="365"]') 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(); }); });