// FE-STORE-JOURNEY-001 to FE-STORE-JOURNEY-021 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(); }); /* * A picture uploaded onto an entry is a gallery row as well: the entry only * holds a link to it. Studio's content browser reads the gallery, so without * this a picture added from there vanished until the journey was fetched * again (#2064). */ it('FE-STORE-JOURNEY-013b: uploadPhotos puts the picture in the gallery the entry links to', async () => { const entry = buildEntry({ id: 100, photos: [] }); useJourneyStore.setState({ current: buildJourneyDetail({ id: 50, entries: [entry], gallery: [] }) }); const uploaded = buildPhoto({ id: 91, entry_id: 100, photo_id: 700, caption: 'a caption' }); const spy = vi.spyOn(journeyApi, 'uploadPhotos').mockResolvedValue({ photos: [uploaded] } as any); await useJourneyStore.getState().uploadPhotos(100, [new File(['x'], 'p.jpg', { type: 'image/jpeg' })]); const gallery = useJourneyStore.getState().current?.gallery ?? []; expect(gallery).toHaveLength(1); // The gallery's own shape: the journey it belongs to, and no entry on it. expect(gallery[0]).toMatchObject({ id: 91, photo_id: 700, caption: 'a caption', journey_id: 50 }); expect('entry_id' in gallery[0]).toBe(false); spy.mockRestore(); }); it('FE-STORE-JOURNEY-013c: the gallery row the route answers with wins over a derived one', async () => { useJourneyStore.setState({ current: buildJourneyDetail({ id: 50, entries: [buildEntry({ id: 100, photos: [] })], gallery: [] }), }); const uploaded = buildPhoto({ id: 91, entry_id: 100, photo_id: 700 }); const answered = { id: 91, journey_id: 50, photo_id: 700, caption: null, shared: 0, sort_order: 4, created_at: 7 }; const spy = vi.spyOn(journeyApi, 'uploadPhotos') .mockResolvedValue({ photos: [uploaded], gallery: [answered] } as any); await useJourneyStore.getState().uploadPhotos(100, [new File(['x'], 'p.jpg', { type: 'image/jpeg' })]); expect(useJourneyStore.getState().current?.gallery).toEqual([answered]); spy.mockRestore(); }); it('FE-STORE-JOURNEY-013d: a picture already in the gallery is not added to it twice', async () => { const known = { id: 91, journey_id: 50, photo_id: 700, caption: null, shared: 0, sort_order: 0, created_at: 0 }; useJourneyStore.setState({ current: buildJourneyDetail({ id: 50, entries: [buildEntry({ id: 100, photos: [] })], gallery: [known] }), }); const spy = vi.spyOn(journeyApi, 'uploadPhotos') .mockResolvedValue({ photos: [buildPhoto({ id: 91, entry_id: 100, photo_id: 700 })] } as any); await useJourneyStore.getState().uploadPhotos(100, [new File(['x'], 'p.jpg', { type: 'image/jpeg' })]); expect(useJourneyStore.getState().current?.gallery).toEqual([known]); 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); }); // ── provider photos (#1587) ────────────────────────────────────────────── it('FE-STORE-JOURNEY-020: addProviderPhotos sends the group to the entry and answers what was added', async () => { const detail = buildJourneyDetail({ id: 50 }); useJourneyStore.setState({ current: detail }); const bodies: Record[] = []; server.use( http.post('/api/journeys/entries/100/provider-photos', async ({ request }) => { bodies.push(await request.json() as Record); return HttpResponse.json({ photos: [{ id: 1 }], added: 1 }); }) ); const result = await useJourneyStore.getState().addProviderPhotos(100, 'immich', { assetIds: ['a1'], passphrase: 'pw', mediaTypes: ['video'], }); expect(result).toEqual({ photos: [{ id: 1 }], added: 1 }); expect(bodies).toEqual([{ provider: 'immich', asset_ids: ['a1'], passphrase: 'pw', media_types: ['video'] }]); // The caller reloads: where the photo sorts is only known once the server // has its capture time. expect(useJourneyStore.getState().current).toBe(detail); }); it('FE-STORE-JOURNEY-021: addProviderPhotosToGallery sends the group to the gallery, and a failure comes through', async () => { const bodies: Record[] = []; server.use( http.post('/api/journeys/50/gallery/provider-photos', async ({ request }) => { bodies.push(await request.json() as Record); return bodies.length === 1 ? HttpResponse.json({ photos: [], added: 2 }) : new HttpResponse(null, { status: 500 }); }) ); const result = await useJourneyStore.getState().addProviderPhotosToGallery(50, 'synologyphotos', { assetIds: ['s1', 's2'] }); expect(result).toEqual({ photos: [], added: 2 }); expect(bodies).toEqual([{ provider: 'synologyphotos', asset_ids: ['s1', 's2'] }]); await expect( useJourneyStore.getState().addProviderPhotosToGallery(50, 'synologyphotos', { assetIds: ['s3'] }), ).rejects.toMatchObject({ response: { status: 500 } }); }); // ── 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); }); });