// FE-STORE-JOURNEY-001 to FE-STORE-JOURNEY-015 import { http, HttpResponse } from 'msw'; import { server } from '../../tests/helpers/msw/server'; import { journeyApi } from '../api/client'; import { useJourneyStore } from './journeyStore'; import type { JourneyDetail, JourneyEntry, JourneyPhoto } from './journeyStore'; const initialState = useJourneyStore.getState(); // ── Helpers ────────────────────────────────────────────────────────────────── let _seq = 100; function nextId(): number { return ++_seq; } function buildJourney(overrides: Record = {}) { const id = (overrides.id as number) ?? nextId(); return { id, user_id: 1, title: `Journey ${id}`, subtitle: null, cover_gradient: null, cover_image: null, status: 'draft' as const, created_at: Date.now(), updated_at: Date.now(), ...overrides, }; } function buildJourneyDetail(overrides: Record = {}): JourneyDetail { const base = buildJourney(overrides); return { ...base, entries: [], trips: [], contributors: [], stats: { entries: 0, photos: 0, cities: 0 }, ...(overrides as any), }; } function buildEntry(overrides: Record = {}): JourneyEntry { const id = (overrides.id as number) ?? nextId(); return { id, journey_id: 1, source_trip_id: null, source_place_id: null, source_trip_name: null, author_id: 1, type: 'entry', title: `Entry ${id}`, story: null, entry_date: '2026-04-01', entry_time: null, location_name: null, location_lat: null, location_lng: null, mood: null, weather: null, tags: [], pros_cons: null, visibility: 'private', sort_order: 0, photos: [], created_at: Date.now(), updated_at: Date.now(), ...overrides, } as JourneyEntry; } function buildPhoto(overrides: Record = {}): JourneyPhoto { const id = (overrides.id as number) ?? nextId(); return { id, entry_id: 1, provider: 'local', asset_id: null, owner_id: null, file_path: `/uploads/photo_${id}.jpg`, thumbnail_path: null, caption: null, sort_order: 0, width: null, height: null, shared: 0, created_at: Date.now(), ...overrides, } as JourneyPhoto; } // ── Setup ──────────────────────────────────────────────────────────────────── beforeEach(() => { useJourneyStore.setState(initialState, true); server.resetHandlers(); }); // ── Tests ──────────────────────────────────────────────────────────────────── describe('journeyStore', () => { // ── loadJourneys ───────────────────────────────────────────────────────── it('FE-STORE-JOURNEY-001: loadJourneys populates store', async () => { const j1 = buildJourney({ id: 1 }); const j2 = buildJourney({ id: 2 }); server.use( http.get('/api/journeys', () => HttpResponse.json({ journeys: [j1, j2] }) ) ); await useJourneyStore.getState().loadJourneys(); expect(useJourneyStore.getState().journeys).toHaveLength(2); expect(useJourneyStore.getState().journeys[0].id).toBe(1); }); it('FE-STORE-JOURNEY-002: loadJourneys sets loading false on error', async () => { server.use( http.get('/api/journeys', () => HttpResponse.json({ error: 'server error' }, { status: 500 }) ) ); await expect(useJourneyStore.getState().loadJourneys()).rejects.toThrow(); expect(useJourneyStore.getState().loading).toBe(false); }); // ── loadJourney ────────────────────────────────────────────────────────── it('FE-STORE-JOURNEY-003: loadJourney sets current journey', async () => { const detail = buildJourneyDetail({ id: 5 }); server.use( http.get('/api/journeys/5', () => HttpResponse.json(detail) ) ); await useJourneyStore.getState().loadJourney(5); expect(useJourneyStore.getState().current?.id).toBe(5); expect(useJourneyStore.getState().loading).toBe(false); }); it('FE-STORE-JOURNEY-004: loadJourney sets loading false on error', async () => { server.use( http.get('/api/journeys/999', () => HttpResponse.json({ error: 'not found' }, { status: 404 }) ) ); await expect(useJourneyStore.getState().loadJourney(999)).rejects.toThrow(); expect(useJourneyStore.getState().loading).toBe(false); expect(useJourneyStore.getState().notFound).toBe(true); }); // ── createJourney ──────────────────────────────────────────────────────── it('FE-STORE-JOURNEY-005: createJourney adds to store and returns journey', async () => { const created = buildJourney({ id: 10, title: 'My Trip' }); server.use( http.post('/api/journeys', () => HttpResponse.json(created) ) ); const result = await useJourneyStore.getState().createJourney({ title: 'My Trip' }); expect(result.id).toBe(10); expect(useJourneyStore.getState().journeys).toContainEqual(created); }); it('FE-STORE-JOURNEY-006: createJourney throws on API error', async () => { server.use( http.post('/api/journeys', () => HttpResponse.json({ error: 'Validation failed' }, { status: 422 }) ) ); await expect(useJourneyStore.getState().createJourney({ title: '' })).rejects.toThrow(); }); // ── updateJourney ──────────────────────────────────────────────────────── it('FE-STORE-JOURNEY-007: updateJourney updates in list and current', async () => { const existing = buildJourney({ id: 20, title: 'Old' }); const detail = buildJourneyDetail({ id: 20, title: 'Old' }); useJourneyStore.setState({ journeys: [existing], current: detail }); server.use( http.patch('/api/journeys/20', () => HttpResponse.json({ title: 'New' }) ) ); await useJourneyStore.getState().updateJourney(20, { title: 'New' }); expect(useJourneyStore.getState().journeys[0].title).toBe('New'); expect(useJourneyStore.getState().current?.title).toBe('New'); }); // ── deleteJourney ──────────────────────────────────────────────────────── it('FE-STORE-JOURNEY-008: deleteJourney removes from list', async () => { const j1 = buildJourney({ id: 30 }); const j2 = buildJourney({ id: 31 }); useJourneyStore.setState({ journeys: [j1, j2] }); server.use( http.delete('/api/journeys/30', () => HttpResponse.json({}) ) ); await useJourneyStore.getState().deleteJourney(30); expect(useJourneyStore.getState().journeys).toHaveLength(1); expect(useJourneyStore.getState().journeys[0].id).toBe(31); }); it('FE-STORE-JOURNEY-009: deleteJourney clears current if matching', async () => { const detail = buildJourneyDetail({ id: 40 }); useJourneyStore.setState({ journeys: [buildJourney({ id: 40 })], current: detail }); server.use( http.delete('/api/journeys/40', () => HttpResponse.json({}) ) ); await useJourneyStore.getState().deleteJourney(40); expect(useJourneyStore.getState().current).toBeNull(); }); // ── createEntry ────────────────────────────────────────────────────────── it('FE-STORE-JOURNEY-010: createEntry adds entry to current', async () => { const detail = buildJourneyDetail({ id: 50 }); useJourneyStore.setState({ current: detail }); const newEntry = buildEntry({ id: 60, journey_id: 50 }); server.use( http.post('/api/journeys/50/entries', () => HttpResponse.json(newEntry) ) ); const result = await useJourneyStore.getState().createEntry(50, { title: 'Day 1' }); expect(result.id).toBe(60); expect(useJourneyStore.getState().current?.entries).toHaveLength(1); expect(useJourneyStore.getState().current?.entries[0].id).toBe(60); }); // ── updateEntry ────────────────────────────────────────────────────────── it('FE-STORE-JOURNEY-011: updateEntry updates entry in current', async () => { const entry = buildEntry({ id: 70, title: 'Old Title' }); const detail = buildJourneyDetail({ id: 50, entries: [entry] }); useJourneyStore.setState({ current: detail }); server.use( http.patch('/api/journeys/entries/70', () => HttpResponse.json({ title: 'New Title' }) ) ); await useJourneyStore.getState().updateEntry(70, { title: 'New Title' }); expect(useJourneyStore.getState().current?.entries[0].title).toBe('New Title'); }); // ── deleteEntry ────────────────────────────────────────────────────────── it('FE-STORE-JOURNEY-012: deleteEntry removes entry from current', async () => { const entry1 = buildEntry({ id: 80 }); const entry2 = buildEntry({ id: 81 }); const detail = buildJourneyDetail({ id: 50, entries: [entry1, entry2] }); useJourneyStore.setState({ current: detail }); server.use( http.delete('/api/journeys/entries/80', () => HttpResponse.json({}) ) ); await useJourneyStore.getState().deleteEntry(80); expect(useJourneyStore.getState().current?.entries).toHaveLength(1); expect(useJourneyStore.getState().current?.entries[0].id).toBe(81); }); // ── uploadPhotos ───────────────────────────────────────────────────────── it('FE-STORE-JOURNEY-013: uploadPhotos appends photos to entry', async () => { const existingPhoto = buildPhoto({ id: 90, entry_id: 100 }); const entry = buildEntry({ id: 100, photos: [existingPhoto] }); const detail = buildJourneyDetail({ id: 50, entries: [entry] }); useJourneyStore.setState({ current: detail }); const newPhoto = buildPhoto({ id: 91, entry_id: 100 }); // MSW's XHR interceptor calls request.arrayBuffer() on FormData bodies to // emit upload progress events, which hangs in jsdom+Node. Spy on the API // layer directly so this test exercises store state management only. const spy = vi.spyOn(journeyApi, 'uploadPhotos').mockResolvedValue({ photos: [newPhoto] } as any); const file = new File(['x'], 'photo.jpg', { type: 'image/jpeg' }); const result = await useJourneyStore.getState().uploadPhotos(100, [file]); expect(result.succeeded).toHaveLength(1); expect(result.succeeded[0].id).toBe(91); expect(result.failed).toHaveLength(0); const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100); expect(storedEntry?.photos).toHaveLength(2); spy.mockRestore(); }); it('FE-STORE-JOURNEY-017: uploadPhotos returns failed files and merges only succeeded on network error', async () => { const entry = buildEntry({ id: 100, photos: [] }); const detail = buildJourneyDetail({ id: 50, entries: [entry] }); useJourneyStore.setState({ current: detail }); server.use( http.post('/api/journeys/entries/100/photos', () => HttpResponse.error() ) ); const file = new File(['x'], 'fail.jpg', { type: 'image/jpeg' }); const result = await useJourneyStore.getState().uploadPhotos(100, [file]); expect(result.succeeded).toHaveLength(0); expect(result.failed).toHaveLength(1); expect(result.failed[0]).toBe(file); const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100); expect(storedEntry?.photos).toHaveLength(0); }); it('FE-STORE-JOURNEY-018: uploadPhotos merges each file result incrementally on partial success', async () => { const entry = buildEntry({ id: 100, photos: [] }); const detail = buildJourneyDetail({ id: 50, entries: [entry] }); useJourneyStore.setState({ current: detail }); const photo1 = buildPhoto({ id: 91, entry_id: 100 }); const photo2 = buildPhoto({ id: 92, entry_id: 100 }); let callCount = 0; // Spy on the API layer to avoid MSW's FormData body hang (see FE-STORE-JOURNEY-013). // Use a 4xx-shaped error for file2 so isRetryable returns false and the test runs instantly. const spy = vi.spyOn(journeyApi, 'uploadPhotos').mockImplementation(async () => { callCount++; if (callCount === 1) return { photos: [photo1] } as any; throw Object.assign(new Error('Bad Request'), { response: { status: 400 } }); }); const file1 = new File(['a'], 'ok.jpg', { type: 'image/jpeg' }); const file2 = new File(['b'], 'fail.jpg', { type: 'image/jpeg' }); const result = await useJourneyStore.getState().uploadPhotos(100, [file1, file2], undefined); expect(result.succeeded).toHaveLength(1); expect(result.succeeded[0].id).toBe(photo1.id); expect(result.failed).toHaveLength(1); const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100); expect(storedEntry?.photos).toHaveLength(1); void photo2; // referenced to avoid lint warning spy.mockRestore(); }); // ── deletePhoto ────────────────────────────────────────────────────────── it('FE-STORE-JOURNEY-014: deletePhoto removes photo from entry', async () => { const photo1 = buildPhoto({ id: 200, entry_id: 100 }); const photo2 = buildPhoto({ id: 201, entry_id: 100 }); const entry = buildEntry({ id: 100, photos: [photo1, photo2] }); const detail = buildJourneyDetail({ id: 50, entries: [entry] }); useJourneyStore.setState({ current: detail }); server.use( http.delete('/api/journeys/photos/200', () => HttpResponse.json({}) ) ); await useJourneyStore.getState().deletePhoto(200); const storedEntry = useJourneyStore.getState().current?.entries.find(e => e.id === 100); expect(storedEntry?.photos).toHaveLength(1); expect(storedEntry?.photos[0].id).toBe(201); }); // ── loadJourney silent refresh ─────────────────────────────────────────── it('FE-STORE-JOURNEY-016: loadJourney does not set loading when refreshing same journey', async () => { const existing = buildJourneyDetail({ id: 5, title: 'Old' }); useJourneyStore.setState({ current: existing, loading: false }); const loadingValues: boolean[] = []; const unsub = useJourneyStore.subscribe(s => loadingValues.push(s.loading)); const refreshed = buildJourneyDetail({ id: 5, title: 'Refreshed' }); server.use( http.get('/api/journeys/5', () => HttpResponse.json(refreshed)) ); await useJourneyStore.getState().loadJourney(5); unsub(); expect(loadingValues.every(v => v === false)).toBe(true); expect(useJourneyStore.getState().current?.title).toBe('Refreshed'); }); it('FE-STORE-JOURNEY-017: loadJourney sets loading on cold load (different journey)', async () => { const existing = buildJourneyDetail({ id: 5 }); useJourneyStore.setState({ current: existing, loading: false }); const loadingValues: boolean[] = []; const unsub = useJourneyStore.subscribe(s => loadingValues.push(s.loading)); const other = buildJourneyDetail({ id: 99 }); server.use( http.get('/api/journeys/99', () => HttpResponse.json(other)) ); await useJourneyStore.getState().loadJourney(99); unsub(); expect(loadingValues).toContain(true); expect(useJourneyStore.getState().current?.id).toBe(99); expect(useJourneyStore.getState().loading).toBe(false); }); // ── reorderEntries ─────────────────────────────────────────────────────── it('FE-STORE-JOURNEY-018: reorderEntries reorders by sort_order not entry_time', async () => { const a = buildEntry({ id: 201, entry_date: '2026-04-01', entry_time: '09:00', sort_order: 0 }); const b = buildEntry({ id: 202, entry_date: '2026-04-01', entry_time: '11:00', sort_order: 1 }); const c = buildEntry({ id: 203, entry_date: '2026-04-01', entry_time: '14:00', sort_order: 2 }); const detail = buildJourneyDetail({ id: 55, entries: [a, b, c] }); useJourneyStore.setState({ current: detail }); server.use( http.put('/api/journeys/55/entries/reorder', () => HttpResponse.json({ success: true })) ); await useJourneyStore.getState().reorderEntries(55, [202, 201, 203]); const ids = useJourneyStore.getState().current?.entries.map(e => e.id); expect(ids).toEqual([202, 201, 203]); }); it('FE-STORE-JOURNEY-019: reorderEntries rolls back on API failure', async () => { const a = buildEntry({ id: 211, entry_date: '2026-04-01', sort_order: 0 }); const b = buildEntry({ id: 212, entry_date: '2026-04-01', sort_order: 1 }); const detail = buildJourneyDetail({ id: 56, entries: [a, b] }); useJourneyStore.setState({ current: detail }); server.use( http.put('/api/journeys/56/entries/reorder', () => HttpResponse.json({}, { status: 403 })) ); await expect(useJourneyStore.getState().reorderEntries(56, [212, 211])).rejects.toBeTruthy(); const ids = useJourneyStore.getState().current?.entries.map(e => e.id); expect(ids).toEqual([211, 212]); }); // ── clear ──────────────────────────────────────────────────────────────── it('FE-STORE-JOURNEY-015: clear resets state', () => { useJourneyStore.setState({ journeys: [buildJourney()], current: buildJourneyDetail(), loading: true, }); useJourneyStore.getState().clear(); expect(useJourneyStore.getState().journeys).toEqual([]); expect(useJourneyStore.getState().current).toBeNull(); expect(useJourneyStore.getState().loading).toBe(false); }); });