import { describe, it, expect, beforeEach, vi } from 'vitest'; import { http, HttpResponse } from 'msw'; import { useTripStore } from '../../src/store/tripStore'; import { resetAllStores } from '../helpers/store'; import { buildTrip, buildDay, buildPlace, buildPackingItem, buildTodoItem, buildTag, buildCategory, buildAssignment, buildDayNote } from '../helpers/factories'; import { server } from '../helpers/msw/server'; vi.mock('../../src/api/websocket', () => ({ connect: vi.fn(), disconnect: vi.fn(), getSocketId: vi.fn(() => null), joinTrip: vi.fn(), leaveTrip: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), setRefetchCallback: vi.fn(), })); beforeEach(() => { resetAllStores(); }); describe('tripStore', () => { describe('loadTrip', () => { it('FE-TRIP-001: fires parallel API calls for trips, days, places, packing, todo, tags, categories', async () => { const calledUrls: string[] = []; server.use( http.get('/api/trips/:id', ({ params }) => { calledUrls.push(`/api/trips/${params.id}`); return HttpResponse.json({ trip: buildTrip({ id: Number(params.id) }) }); }), http.get('/api/trips/:id/days', ({ params }) => { calledUrls.push(`/api/trips/${params.id}/days`); return HttpResponse.json({ days: [] }); }), http.get('/api/trips/:id/places', ({ params }) => { calledUrls.push(`/api/trips/${params.id}/places`); return HttpResponse.json({ places: [] }); }), http.get('/api/trips/:id/packing', ({ params }) => { calledUrls.push(`/api/trips/${params.id}/packing`); return HttpResponse.json({ items: [] }); }), http.get('/api/trips/:id/todo', ({ params }) => { calledUrls.push(`/api/trips/${params.id}/todo`); return HttpResponse.json({ items: [] }); }), http.get('/api/tags', () => { calledUrls.push('/api/tags'); return HttpResponse.json({ tags: [] }); }), http.get('/api/categories', () => { calledUrls.push('/api/categories'); return HttpResponse.json({ categories: [] }); }), ); await useTripStore.getState().loadTrip(1); expect(calledUrls).toContain('/api/trips/1'); expect(calledUrls).toContain('/api/trips/1/days'); expect(calledUrls).toContain('/api/trips/1/places'); expect(calledUrls).toContain('/api/trips/1/packing'); expect(calledUrls).toContain('/api/trips/1/todo'); expect(calledUrls).toContain('/api/tags'); expect(calledUrls).toContain('/api/categories'); }); it('FE-TRIP-002: after loadTrip, all store fields are populated', async () => { const trip = buildTrip({ id: 1 }); const place = buildPlace({ trip_id: 1 }); const packingItem = buildPackingItem({ trip_id: 1 }); const todoItem = buildTodoItem({ trip_id: 1 }); const tag = buildTag(); const category = buildCategory(); server.use( http.get('/api/trips/1', () => HttpResponse.json({ trip })), http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })), http.get('/api/trips/1/places', () => HttpResponse.json({ places: [place] })), http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [packingItem] })), http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [todoItem] })), http.get('/api/tags', () => HttpResponse.json({ tags: [tag] })), http.get('/api/categories', () => HttpResponse.json({ categories: [category] })), ); await useTripStore.getState().loadTrip(1); const state = useTripStore.getState(); expect(state.trip).toEqual(trip); expect(state.places).toEqual([place]); expect(state.packingItems).toEqual([packingItem]); expect(state.todoItems).toEqual([todoItem]); expect(state.tags).toEqual([tag]); expect(state.categories).toEqual([category]); }); it('FE-TRIP-003: loadTrip extracts assignments map from days response', async () => { const assignment = buildAssignment({ day_id: 10, order_index: 0 }); const day = buildDay({ id: 10, assignments: [assignment], notes_items: [] }); server.use( http.get('/api/trips/1', () => HttpResponse.json({ trip: buildTrip({ id: 1 }) })), http.get('/api/trips/1/days', () => HttpResponse.json({ days: [day] })), http.get('/api/trips/1/places', () => HttpResponse.json({ places: [] })), http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [] })), http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [] })), http.get('/api/tags', () => HttpResponse.json({ tags: [] })), http.get('/api/categories', () => HttpResponse.json({ categories: [] })), ); await useTripStore.getState().loadTrip(1); const { assignments } = useTripStore.getState(); expect(assignments['10']).toBeDefined(); expect(assignments['10']).toEqual([assignment]); }); it('FE-TRIP-004: loadTrip extracts dayNotes map from days response', async () => { const note = buildDayNote({ day_id: 10 }); const day = buildDay({ id: 10, assignments: [], notes_items: [note] }); server.use( http.get('/api/trips/1', () => HttpResponse.json({ trip: buildTrip({ id: 1 }) })), http.get('/api/trips/1/days', () => HttpResponse.json({ days: [day] })), http.get('/api/trips/1/places', () => HttpResponse.json({ places: [] })), http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [] })), http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [] })), http.get('/api/tags', () => HttpResponse.json({ tags: [] })), http.get('/api/categories', () => HttpResponse.json({ categories: [] })), ); await useTripStore.getState().loadTrip(1); const { dayNotes } = useTripStore.getState(); expect(dayNotes['10']).toBeDefined(); expect(dayNotes['10']).toEqual([note]); }); it('FE-TRIP-005: loadTrip sets isLoading true during, false after', async () => { let wasLoadingDuringFetch = false; server.use( http.get('/api/trips/1', () => { wasLoadingDuringFetch = useTripStore.getState().isLoading; return HttpResponse.json({ trip: buildTrip({ id: 1 }) }); }), http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })), http.get('/api/trips/1/places', () => HttpResponse.json({ places: [] })), http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [] })), http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [] })), http.get('/api/tags', () => HttpResponse.json({ tags: [] })), http.get('/api/categories', () => HttpResponse.json({ categories: [] })), ); const promise = useTripStore.getState().loadTrip(1); expect(useTripStore.getState().isLoading).toBe(true); await promise; expect(wasLoadingDuringFetch).toBe(true); expect(useTripStore.getState().isLoading).toBe(false); }); it('FE-TRIP-006: loadTrip on API failure sets error and isLoading: false', async () => { server.use( http.get('/api/trips/1', () => HttpResponse.json({ message: 'Not found' }, { status: 404 })), http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })), http.get('/api/trips/1/places', () => HttpResponse.json({ places: [] })), http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [] })), http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [] })), http.get('/api/tags', () => HttpResponse.json({ tags: [] })), http.get('/api/categories', () => HttpResponse.json({ categories: [] })), ); await expect(useTripStore.getState().loadTrip(1)).rejects.toThrow(); const state = useTripStore.getState(); expect(state.isLoading).toBe(false); expect(state.error).not.toBeNull(); }); }); describe('refreshDays', () => { it('FE-TRIP-007: refreshDays re-fetches days and rebuilds assignments/dayNotes maps', async () => { const assignment = buildAssignment({ day_id: 20, order_index: 0 }); const note = buildDayNote({ day_id: 20 }); const day = buildDay({ id: 20, assignments: [assignment], notes_items: [note] }); server.use( http.get('/api/trips/1/days', () => HttpResponse.json({ days: [day] })), ); await useTripStore.getState().refreshDays(1); const state = useTripStore.getState(); expect(state.days).toHaveLength(1); expect(state.assignments['20']).toEqual([assignment]); expect(state.dayNotes['20']).toEqual([note]); }); }); describe('updateTrip', () => { it('FE-TRIP-008: updateTrip persists and refreshes trip + days', async () => { const updatedTrip = buildTrip({ id: 1, name: 'Updated Trip' }); server.use( http.put('/api/trips/1', () => HttpResponse.json({ trip: updatedTrip })), http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })), ); const result = await useTripStore.getState().updateTrip(1, { name: 'Updated Trip' }); expect(result).toEqual(updatedTrip); expect(useTripStore.getState().trip).toEqual(updatedTrip); }); }); describe('setSelectedDay', () => { it('FE-TRIP-009: setSelectedDay updates selectedDayId', () => { useTripStore.getState().setSelectedDay(42); expect(useTripStore.getState().selectedDayId).toBe(42); useTripStore.getState().setSelectedDay(null); expect(useTripStore.getState().selectedDayId).toBeNull(); }); }); describe('addTag', () => { it('FE-TRIP-010: addTag creates tag and appends to tags', async () => { const existingTag = buildTag(); useTripStore.setState({ tags: [existingTag] }); const newTagData = { name: 'New Tag', color: '#00ff00' }; const result = await useTripStore.getState().addTag(newTagData); expect(result.name).toBe('New Tag'); const tags = useTripStore.getState().tags; expect(tags).toHaveLength(2); expect(tags[tags.length - 1].name).toBe('New Tag'); }); }); describe('addCategory', () => { it('FE-TRIP-011: addCategory creates category and appends to categories', async () => { const existingCategory = buildCategory(); useTripStore.setState({ categories: [existingCategory] }); const newCategoryData = { name: 'New Category', icon: 'hotel' }; const result = await useTripStore.getState().addCategory(newCategoryData); expect(result.name).toBe('New Category'); const categories = useTripStore.getState().categories; expect(categories).toHaveLength(2); expect(categories[categories.length - 1].name).toBe('New Category'); }); }); });