Files
TREK/client/src/store/tripStore.test.ts
T
Maurice 5d99886b04 Keep a picked place image and its credit visible everywhere
Accommodation cards read the place from page-local state, so a new thumbnail
only showed up there after a reload. place:updated now asks the planner to
reload them, but only when the image actually changed - renaming a place must
not trigger a refetch.

The inspector shows the stored credit under the avatar. Crediting a CC BY-SA
picture only in the moment it is chosen is not enough; the obligation travels
with the picture.

Also lets a GIF hero through to the PDF. Uploads accept gif, the PDF extension
check did not, so one silently fell back to the category icon.
2026-08-10 12:24:15 +02:00

512 lines
22 KiB
TypeScript

// FE-TSTORE-001 to FE-TSTORE-021 (trip-scoped root store: load, hydrate, refresh, mutate)
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../tests/helpers/store';
import {
buildAssignment,
buildBudgetItem,
buildCategory,
buildDay,
buildDayNote,
buildPackingItem,
buildPlace,
buildReservation,
buildTag,
buildTodoItem,
buildTrip,
buildTripFile,
} from '../../tests/helpers/factories';
import { offlineDb } from '../db/offlineDb';
import { setForcedOffline } from '../sync/networkMode';
import { useTripStore } from './tripStore';
/** Every cache table loadTrip reads from, so one test can never see another's writes. */
async function clearCache(): Promise<void> {
await Promise.all([
offlineDb.trips.clear(),
offlineDb.days.clear(),
offlineDb.places.clear(),
offlineDb.packingItems.clear(),
offlineDb.todoItems.clear(),
offlineDb.budgetItems.clear(),
offlineDb.reservations.clear(),
offlineDb.tripFiles.clear(),
offlineDb.tags.clear(),
offlineDb.categories.clear(),
]);
}
beforeEach(async () => {
resetAllStores();
server.resetHandlers();
await clearCache();
});
afterEach(() => {
setForcedOffline(false);
vi.restoreAllMocks();
});
/** Days as the day list endpoint returns them: with embedded assignments + notes. */
function serverDays() {
const place = buildPlace({ id: 500, trip_id: 1 });
return [
buildDay({
id: 1,
trip_id: 1,
day_number: 1,
assignments: [buildAssignment({ id: 900, day_id: 1, place })],
notes_items: [buildDayNote({ id: 800, day_id: 1, text: 'Pick up keys' })],
}),
// A day row that arrives without the embedded arrays at all.
buildDay({ id: 2, trip_id: 1, day_number: 2, assignments: undefined, notes_items: undefined }),
];
}
describe('tripStore', () => {
describe('selectors and filters', () => {
it('FE-TSTORE-001: setSelectedDay, setPlacesFilter and setPlacesCategoryFilter write through', () => {
useTripStore.getState().setSelectedDay(42);
useTripStore.getState().setPlacesFilter('unplanned');
useTripStore.getState().setPlacesCategoryFilter(new Set(['3', '7']));
const state = useTripStore.getState();
expect(state.selectedDayId).toBe(42);
expect(state.placesFilter).toBe('unplanned');
expect([...state.placesCategoryFilter]).toEqual(['3', '7']);
useTripStore.getState().setSelectedDay(null);
expect(useTripStore.getState().selectedDayId).toBeNull();
});
});
describe('resetTrip', () => {
it('FE-TSTORE-002: clears every trip-scoped slice but keeps the global tags and categories', () => {
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
days: [buildDay({ id: 1, trip_id: 1 })],
places: [buildPlace({ trip_id: 1 })],
assignments: { '1': [buildAssignment({ day_id: 1 })] },
dayNotes: { '1': [buildDayNote({ day_id: 1 })] },
packingItems: [buildPackingItem({ trip_id: 1 })],
todoItems: [buildTodoItem({ trip_id: 1 })],
budgetItems: [buildBudgetItem({ trip_id: 1 })],
files: [buildTripFile({ trip_id: 1 })],
reservations: [buildReservation({ trip_id: 1 })],
tags: [buildTag({ id: 5 })],
categories: [buildCategory({ id: 6 })],
selectedDayId: 1,
placesFilter: 'unplanned',
placesCategoryFilter: new Set(['3']),
error: 'stale error',
});
useTripStore.getState().resetTrip();
const state = useTripStore.getState();
expect(state.trip).toBeNull();
expect(state.days).toEqual([]);
expect(state.places).toEqual([]);
expect(state.assignments).toEqual({});
expect(state.dayNotes).toEqual({});
expect(state.packingItems).toEqual([]);
expect(state.todoItems).toEqual([]);
expect(state.budgetItems).toEqual([]);
expect(state.files).toEqual([]);
expect(state.reservations).toEqual([]);
expect(state.selectedDayId).toBeNull();
expect(state.placesFilter).toBe('all');
expect(state.placesCategoryFilter.size).toBe(0);
expect(state.error).toBeNull();
// Global lookups survive a trip switch.
expect(state.tags.map(t => t.id)).toEqual([5]);
expect(state.categories.map(c => c.id)).toEqual([6]);
});
});
describe('loadTrip', () => {
it('FE-TSTORE-003: fills every slice and builds the assignments/dayNotes maps', async () => {
server.use(
http.get('/api/trips/1', () => HttpResponse.json({ trip: buildTrip({ id: 1, title: 'Paris' }) })),
http.get('/api/trips/1/days', () => HttpResponse.json({ days: serverDays() })),
http.get('/api/trips/1/places', () => HttpResponse.json({ places: [buildPlace({ id: 500, trip_id: 1 })] })),
http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [buildPackingItem({ id: 60, trip_id: 1 })] })),
http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [buildTodoItem({ id: 70, trip_id: 1 })] })),
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [buildBudgetItem({ id: 80, trip_id: 1 })] })),
http.get('/api/trips/1/reservations', () => HttpResponse.json({ reservations: [buildReservation({ id: 90, trip_id: 1 })] })),
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [buildTripFile({ id: 95, trip_id: 1 })] })),
http.get('/api/tags', () => HttpResponse.json({ tags: [buildTag({ id: 11 })] })),
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 12 })] })),
);
await useTripStore.getState().loadTrip(1);
const state = useTripStore.getState();
expect(state.trip?.title).toBe('Paris');
expect(state.days.map(d => d.id)).toEqual([1, 2]);
expect(state.places.map(p => p.id)).toEqual([500]);
expect(state.packingItems.map(i => i.id)).toEqual([60]);
expect(state.todoItems.map(i => i.id)).toEqual([70]);
expect(state.budgetItems.map(i => i.id)).toEqual([80]);
expect(state.reservations.map(r => r.id)).toEqual([90]);
expect(state.files.map(f => f.id)).toEqual([95]);
expect(state.tags.map(t => t.id)).toEqual([11]);
expect(state.categories.map(c => c.id)).toEqual([12]);
expect(state.assignments['1'].map(a => a.id)).toEqual([900]);
expect(state.dayNotes['1'].map(n => n.id)).toEqual([800]);
// A day row without the embedded arrays still gets an entry.
expect(state.assignments['2']).toEqual([]);
expect(state.dayNotes['2']).toEqual([]);
expect(state.isLoading).toBe(false);
expect(state.error).toBeNull();
});
it('FE-TSTORE-004: drops the previously loaded trip before fetching the next one', async () => {
seedStore(useTripStore, {
trip: buildTrip({ id: 9, title: 'Old trip' }),
places: [buildPlace({ id: 111, trip_id: 9 })],
});
let placesDuringLoad: number[] = [];
server.use(
http.get('/api/trips/1', () => {
placesDuringLoad = useTripStore.getState().places.map(p => p.id);
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: [] })),
);
await useTripStore.getState().loadTrip(1);
expect(placesDuringLoad).toEqual([]);
expect(useTripStore.getState().trip?.id).toBe(1);
});
it('FE-TSTORE-005: a failing budget/reservations/files fetch is non-fatal', async () => {
server.use(
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
http.get('/api/trips/1/budget', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
http.get('/api/trips/1/reservations', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
http.get('/api/trips/1/files', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
);
await useTripStore.getState().loadTrip(1);
const state = useTripStore.getState();
expect(state.trip?.id).toBe(1);
expect(state.budgetItems).toEqual([]);
expect(state.reservations).toEqual([]);
expect(state.files).toEqual([]);
expect(state.error).toBeNull();
});
it('FE-TSTORE-006: falls back to the cached tags and categories when their endpoints fail', async () => {
await offlineDb.tags.put(buildTag({ id: 31, name: 'Cached tag' }));
await offlineDb.categories.put(buildCategory({ id: 32, name: 'Cached category' }));
server.use(
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
http.get('/api/tags', () => HttpResponse.json({ error: 'offline' }, { status: 502 })),
http.get('/api/categories', () => HttpResponse.json({ error: 'offline' }, { status: 502 })),
);
await useTripStore.getState().loadTrip(1);
expect(useTripStore.getState().tags.map(t => t.name)).toEqual(['Cached tag']);
expect(useTripStore.getState().categories.map(c => c.name)).toEqual(['Cached category']);
});
it('FE-TSTORE-020: serves the whole trip from the offline cache when the app is forced offline', async () => {
await offlineDb.trips.put(buildTrip({ id: 1, title: 'Cached trip' }));
await offlineDb.days.bulkPut([buildDay({ id: 1, trip_id: 1, day_number: 1 })]);
await offlineDb.places.bulkPut([buildPlace({ id: 502, trip_id: 1 })]);
await offlineDb.packingItems.bulkPut([buildPackingItem({ id: 62, trip_id: 1 })]);
await offlineDb.todoItems.bulkPut([buildTodoItem({ id: 73, trip_id: 1 })]);
await offlineDb.budgetItems.bulkPut([buildBudgetItem({ id: 82, trip_id: 1 })]);
await offlineDb.reservations.bulkPut([buildReservation({ id: 93, trip_id: 1 })]);
await offlineDb.tripFiles.bulkPut([buildTripFile({ id: 97, trip_id: 1 })]);
await offlineDb.tags.put(buildTag({ id: 41, name: 'Offline tag' }));
await offlineDb.categories.put(buildCategory({ id: 42, name: 'Offline category' }));
// Any request reaching the network would mean the offline gate leaked.
const leaked: string[] = [];
server.use(
http.get('/api/*', ({ request }) => {
leaked.push(new URL(request.url).pathname);
return HttpResponse.json({}, { status: 500 });
}),
);
setForcedOffline(true);
await useTripStore.getState().loadTrip(1);
expect(leaked).toEqual([]);
const state = useTripStore.getState();
expect(state.trip?.title).toBe('Cached trip');
expect(state.days.map(d => d.id)).toEqual([1]);
expect(state.places.map(p => p.id)).toEqual([502]);
expect(state.packingItems.map(i => i.id)).toEqual([62]);
expect(state.todoItems.map(i => i.id)).toEqual([73]);
expect(state.budgetItems.map(i => i.id)).toEqual([82]);
expect(state.reservations.map(r => r.id)).toEqual([93]);
expect(state.files.map(f => f.id)).toEqual([97]);
expect(state.tags.map(t => t.name)).toEqual(['Offline tag']);
expect(state.categories.map(c => c.name)).toEqual(['Offline category']);
expect(state.isLoading).toBe(false);
});
it('FE-TSTORE-021: reports a cache miss as an error when forced offline', async () => {
setForcedOffline(true);
await expect(useTripStore.getState().loadTrip(1)).rejects.toThrow();
expect(useTripStore.getState().error).toBe('No cached trip data available offline');
expect(useTripStore.getState().isLoading).toBe(false);
});
it('FE-TSTORE-007: sets the error state and rethrows when the trip itself cannot be fetched', async () => {
server.use(
http.get('/api/trips/1', () => HttpResponse.json({ error: 'Forbidden' }, { status: 403 })),
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
);
await expect(useTripStore.getState().loadTrip(1)).rejects.toThrow();
const state = useTripStore.getState();
expect(state.isLoading).toBe(false);
expect(state.error).toContain('403');
expect(state.trip).toBeNull();
});
});
describe('hydrateActiveTrip', () => {
it('FE-TSTORE-008: silently re-pulls every collaborative slice and nudges the planner', async () => {
seedStore(useTripStore, { trip: buildTrip({ id: 1 }), places: [], days: [] });
server.use(
http.get('/api/trips/1/days', () => HttpResponse.json({ days: serverDays() })),
http.get('/api/trips/1/places', () => HttpResponse.json({ places: [buildPlace({ id: 501, trip_id: 1 })] })),
http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [buildPackingItem({ id: 61, trip_id: 1 })] })),
http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [buildTodoItem({ id: 71, trip_id: 1 })] })),
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [buildBudgetItem({ id: 81, trip_id: 1 })] })),
http.get('/api/trips/1/reservations', () => HttpResponse.json({ reservations: [buildReservation({ id: 91, trip_id: 1 })] })),
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [buildTripFile({ id: 96, trip_id: 1 })] })),
);
const nudged = vi.fn();
window.addEventListener('accommodations:refresh', nudged);
await useTripStore.getState().hydrateActiveTrip(1);
window.removeEventListener('accommodations:refresh', nudged);
const state = useTripStore.getState();
expect(state.days.map(d => d.id)).toEqual([1, 2]);
expect(state.places.map(p => p.id)).toEqual([501]);
expect(state.packingItems.map(i => i.id)).toEqual([61]);
expect(state.todoItems.map(i => i.id)).toEqual([71]);
expect(state.budgetItems.map(i => i.id)).toEqual([81]);
expect(state.reservations.map(r => r.id)).toEqual([91]);
expect(state.files.map(f => f.id)).toEqual([96]);
// The trip itself is not re-fetched — no splash, no resetTrip.
expect(state.isLoading).toBe(false);
expect(nudged).toHaveBeenCalledTimes(1);
});
it('FE-TSTORE-009: one failing resource does not wipe the others', async () => {
const stalePlace = buildPlace({ id: 111, trip_id: 1, name: 'Kept' });
seedStore(useTripStore, { places: [stalePlace], packingItems: [], todoItems: [] });
vi.spyOn(console, 'error').mockImplementation(() => {});
server.use(
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
http.get('/api/trips/1/places', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
http.get('/api/trips/1/packing', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [buildTodoItem({ id: 72, trip_id: 1 })] })),
);
await expect(useTripStore.getState().hydrateActiveTrip(1)).resolves.toBeUndefined();
expect(useTripStore.getState().places.map(p => p.name)).toEqual(['Kept']);
expect(useTripStore.getState().todoItems.map(i => i.id)).toEqual([72]);
});
});
describe('refreshDays', () => {
it('FE-TSTORE-010: rebuilds the days list plus the assignments and notes maps', async () => {
seedStore(useTripStore, { days: [], assignments: { '99': [] }, dayNotes: { '99': [] } });
server.use(http.get('/api/trips/1/days', () => HttpResponse.json({ days: serverDays() })));
await useTripStore.getState().refreshDays(1);
const state = useTripStore.getState();
expect(state.days.map(d => d.id)).toEqual([1, 2]);
expect(state.assignments['1'].map(a => a.id)).toEqual([900]);
expect(state.dayNotes['1'].map(n => n.id)).toEqual([800]);
// The maps are rebuilt from scratch, so a stale day key is gone.
expect(state.assignments['99']).toBeUndefined();
});
it('FE-TSTORE-011: swallows a failing day list and keeps the current days', async () => {
const day = buildDay({ id: 1, trip_id: 1 });
seedStore(useTripStore, { days: [day] });
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
server.use(
http.get('/api/trips/1/days', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
);
await expect(useTripStore.getState().refreshDays(1)).resolves.toBeUndefined();
expect(useTripStore.getState().days.map(d => d.id)).toEqual([1]);
expect(consoleError).toHaveBeenCalled();
});
});
describe('updateTrip', () => {
it('FE-TSTORE-012: persists the patch, refreshes days and re-pulls the re-anchored bookings', async () => {
seedStore(useTripStore, { trip: buildTrip({ id: 1, title: 'Old' }), days: [], reservations: [] });
let sent: Record<string, unknown> = {};
server.use(
http.put('/api/trips/1', async ({ request }) => {
sent = await request.json() as Record<string, unknown>;
return HttpResponse.json({ trip: buildTrip({ id: 1, title: 'New', start_date: '2025-06-01' }) });
}),
http.get('/api/trips/1/days', () => HttpResponse.json({ days: serverDays() })),
http.get('/api/trips/1/reservations', () =>
HttpResponse.json({ reservations: [buildReservation({ id: 92, trip_id: 1, title: 'Re-anchored' })] }),
),
);
const result = await useTripStore.getState().updateTrip(1, { title: 'New', start_date: '2025-06-01' });
expect(result.title).toBe('New');
expect(sent).toMatchObject({ title: 'New', start_date: '2025-06-01' });
expect(useTripStore.getState().trip?.title).toBe('New');
expect(useTripStore.getState().days.map(d => d.id)).toEqual([1, 2]);
expect(useTripStore.getState().assignments['1']).toHaveLength(1);
expect(useTripStore.getState().reservations.map(r => r.title)).toEqual(['Re-anchored']);
});
it('FE-TSTORE-013: forwards the date_shift_mode flag', async () => {
let sent: Record<string, unknown> = {};
server.use(
http.put('/api/trips/1', async ({ request }) => {
sent = await request.json() as Record<string, unknown>;
return HttpResponse.json({ trip: buildTrip({ id: 1 }) });
}),
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
);
await useTripStore.getState().updateTrip(1, { start_date: '2025-07-01', date_shift_mode: 'shift_all' });
expect(sent.date_shift_mode).toBe('shift_all');
});
it('FE-TSTORE-014: throws the server message and leaves the trip untouched', async () => {
const trip = buildTrip({ id: 1, title: 'Old' });
seedStore(useTripStore, { trip });
server.use(
http.put('/api/trips/1', () => HttpResponse.json({ error: 'Not the owner' }, { status: 403 })),
);
await expect(useTripStore.getState().updateTrip(1, { title: 'New' })).rejects.toThrow('Not the owner');
expect(useTripStore.getState().trip?.title).toBe('Old');
});
});
describe('addTag', () => {
it('FE-TSTORE-015: appends the created tag to the global list', async () => {
seedStore(useTripStore, { tags: [buildTag({ id: 1, name: 'Existing' })] });
server.use(
http.post('/api/tags', () => HttpResponse.json({ tag: buildTag({ id: 2, name: 'Food', color: '#00ff00' }) })),
);
const created = await useTripStore.getState().addTag({ name: 'Food', color: '#00ff00' });
expect(created.id).toBe(2);
expect(useTripStore.getState().tags.map(t => t.name)).toEqual(['Existing', 'Food']);
});
it('FE-TSTORE-016: throws the server message and keeps the list unchanged', async () => {
seedStore(useTripStore, { tags: [buildTag({ id: 1 })] });
server.use(
http.post('/api/tags', () => HttpResponse.json({ error: 'Tag exists' }, { status: 409 })),
);
await expect(useTripStore.getState().addTag({ name: 'Food' })).rejects.toThrow('Tag exists');
expect(useTripStore.getState().tags).toHaveLength(1);
});
});
describe('addCategory', () => {
it('FE-TSTORE-017: appends the created category to the global list', async () => {
seedStore(useTripStore, { categories: [buildCategory({ id: 1, name: 'Existing' })] });
server.use(
http.post('/api/categories', () =>
HttpResponse.json({ category: buildCategory({ id: 2, name: 'Museums', icon: 'landmark' }) }),
),
);
const created = await useTripStore.getState().addCategory({ name: 'Museums', icon: 'landmark' });
expect(created.icon).toBe('landmark');
expect(useTripStore.getState().categories.map(c => c.name)).toEqual(['Existing', 'Museums']);
});
it('FE-TSTORE-018: throws the server message and keeps the list unchanged', async () => {
seedStore(useTripStore, { categories: [buildCategory({ id: 1 })] });
server.use(
http.post('/api/categories', () => HttpResponse.json({ error: 'Category exists' }, { status: 409 })),
);
await expect(useTripStore.getState().addCategory({ name: 'Museums' })).rejects.toThrow('Category exists');
expect(useTripStore.getState().categories).toHaveLength(1);
});
});
describe('handleRemoteEvent', () => {
it('FE-TSTORE-019: routes a socket event into the store', () => {
const place = buildPlace({ id: 500, trip_id: 1, name: 'Before' });
seedStore(useTripStore, { places: [place] });
useTripStore.getState().handleRemoteEvent({
type: 'place:updated',
place: { ...place, name: 'After' },
});
expect(useTripStore.getState().places[0].name).toBe('After');
});
it('FE-TSTORE-019b: asks the planner to reload accommodations when a place image changes', () => {
// Accommodation cards keep their own copy of the place, so a new hero
// image would otherwise only show up after a reload.
const place = buildPlace({ id: 500, trip_id: 1, image_url: null });
seedStore(useTripStore, { places: [place] });
const listener = vi.fn();
window.addEventListener('accommodations:refresh', listener);
useTripStore.getState().handleRemoteEvent({
type: 'place:updated',
place: { ...place, image_url: '/api/maps/place-photo/way%3A1~p0/bytes' },
});
expect(listener).toHaveBeenCalledTimes(1);
window.removeEventListener('accommodations:refresh', listener);
});
it('FE-TSTORE-019c: does not reload accommodations for an edit that leaves the image alone', () => {
const place = buildPlace({ id: 500, trip_id: 1, name: 'Before', image_url: '/uploads/places/a.jpg' });
seedStore(useTripStore, { places: [place] });
const listener = vi.fn();
window.addEventListener('accommodations:refresh', listener);
useTripStore.getState().handleRemoteEvent({
type: 'place:updated',
place: { ...place, name: 'After' },
});
expect(listener).not.toHaveBeenCalled();
window.removeEventListener('accommodations:refresh', listener);
});
});
});