Files
TREK/client/src/store/journeyStore.test.ts
T
Maurice c7c9ba24a3 fix(journey): select all takes every photo in the range and imports keep their order
The photo picker's Select all took only the pages already loaded, 50 at a
time, and replaced whatever had been picked on another tab. It now loads
the rest of a day, trip or date-range search before selecting (cancelled
by closing the picker or switching tab), adds to earlier picks, and
Deselect all only clears what is in view. Add stays disabled until that
loading is done. The whole-library tab keeps selecting what is loaded and
shows the count as "N+".

A date-filtered Immich search stopped at 1,000 photos without a word,
because its page budget was absolute; it is now relative to the page
asked for, with an absolute ceiling of 270 raw pages sized so the
picker's deepest page still gets a full answer. Adds go out in batches of
500 ids, under the JSON body limit, and each batch is sent oldest first,
so photos land in an entry in the order they were taken. When a later
batch fails, the photos already saved are counted and the journey
reloads while the error still shows, through one hook for desktop and
phone.

After a provider add, the capture-time lookup runs at most four at a time
per user, shared by all of that user's batches. When it fills a row, a
journey:photos:updated event reloads open journeys once per batch, from
the REST routes and the MCP add tool alike. Device uploads, which arrive
one file per request, get the same lookup without the event.

Addresses discussion #1587.
2026-09-24 23:48:49 +00:00

547 lines
24 KiB
TypeScript

// 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<string, unknown> = {}) {
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<string, unknown> = {}): JourneyDetail {
const base = buildJourney(overrides);
return {
...base,
entries: [],
trips: [],
contributors: [],
stats: { entries: 0, photos: 0, cities: 0 },
...(overrides as any),
};
}
function buildEntry(overrides: Record<string, unknown> = {}): 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<string, unknown> = {}): 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<string, unknown>[] = [];
server.use(
http.post('/api/journeys/entries/100/provider-photos', async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>);
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<string, unknown>[] = [];
server.use(
http.post('/api/journeys/50/gallery/provider-photos', async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>);
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);
});
});