Spaces:
Sleeping
Sleeping
File size: 14,738 Bytes
cd99321 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 | // 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(<TripFormModal {...defaultProps} />);
expect(document.body).toBeInTheDocument();
});
it('FE-COMP-TRIPFORM-002: shows Create New Trip title for new trip', () => {
render(<TripFormModal {...defaultProps} trip={null} />);
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(<TripFormModal {...defaultProps} trip={trip} />);
expect(screen.getByText('Edit Trip')).toBeInTheDocument();
});
it('FE-COMP-TRIPFORM-004: shows trip title input field', () => {
render(<TripFormModal {...defaultProps} />);
expect(screen.getByPlaceholderText(/Summer in Japan/i)).toBeInTheDocument();
});
it('FE-COMP-TRIPFORM-005: Cancel button is present', () => {
render(<TripFormModal {...defaultProps} />);
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(<TripFormModal {...defaultProps} onClose={onClose} />);
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(<TripFormModal {...defaultProps} trip={null} />);
// 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(<TripFormModal {...defaultProps} trip={trip} />);
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(<TripFormModal {...defaultProps} />);
// 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(<TripFormModal {...defaultProps} onSave={onSave} />);
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(<TripFormModal {...defaultProps} trip={trip} />);
expect(screen.getByDisplayValue('Iceland Adventure')).toBeInTheDocument();
});
it('FE-COMP-TRIPFORM-012: shows Title label', () => {
render(<TripFormModal {...defaultProps} />);
// dashboard.tripTitle = "Title"
expect(screen.getByText('Title')).toBeInTheDocument();
});
it('FE-COMP-TRIPFORM-013: shows Cover Image section', () => {
render(<TripFormModal {...defaultProps} />);
expect(screen.getByText('Cover Image')).toBeInTheDocument();
});
it('FE-COMP-TRIPFORM-014: shows start and end date labels', () => {
render(<TripFormModal {...defaultProps} />);
// 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(<TripFormModal {...defaultProps} trip={trip} />);
// 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(<TripFormModal {...defaultProps} trip={trip} onSave={onSave} />);
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(<TripFormModal {...defaultProps} trip={null} />);
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(<TripFormModal {...defaultProps} trip={trip} />);
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(<TripFormModal {...defaultProps} trip={null} />);
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(<TripFormModal {...defaultProps} trip={null} />);
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(<TripFormModal {...defaultProps} trip={null} />);
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(<TripFormModal {...defaultProps} trip={trip} />);
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(<TripFormModal {...defaultProps} trip={null} />);
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(<TripFormModal {...defaultProps} trip={null} />);
// 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(<TripFormModal {...defaultProps} trip={null} />);
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(<TripFormModal {...defaultProps} trip={null} />);
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(<TripFormModal {...defaultProps} onSave={onSave} trip={null} />);
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(<TripFormModal {...defaultProps} onSave={onSave} trip={null} />);
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(<TripFormModal {...defaultProps} trip={null} />);
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(<TripFormModal {...defaultProps} trip={null} onSave={onSave} />);
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();
});
});
|