Files
TREK/client/src/store/tripStore.test.ts
T
Maurice adb88142ab fix(places): the place search looks where the plan map is looking
Autocomplete and the full search took their location hint only from the
open day or the whole trip, and dropped a trip spread over several cities
as too wide. Zoomed in on one city of such a trip, the search went global.

The plan map now hands its viewport to the trip store on desktop and on
the phone, and useLocationBias tries it first. A viewport wider than the
same 60 km radius, one across the antimeridian or one from another trip
is ignored, and the day and the trip follow as before. The viewport is
cleared when the plan map leaves the screen and on a trip switch.

Refs #2620
2026-10-12 17:18:17 +02:00

551 lines
25 KiB
TypeScript

// FE-TSTORE-001 to FE-TSTORE-022 (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']),
planMapViewport: { tripId: 1, south: 1, west: 1, north: 2, east: 2 },
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);
// The next trip's search must not lean on where this trip's map was.
expect(state.planMapViewport).toBeNull();
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');
});
it('FE-TSTORE-022: new dates nudge the planner to reload its stays, a rename does not', async () => {
const refreshes = vi.fn();
window.addEventListener('accommodations:refresh', refreshes);
try {
seedStore(useTripStore, { trip: buildTrip({ id: 1, title: 'Week', start_date: '2025-06-01', end_date: '2025-06-07' }) });
let answer = buildTrip({ id: 1, title: 'Renamed', start_date: '2025-06-01', end_date: '2025-06-07' });
server.use(
http.put('/api/trips/1', () => HttpResponse.json({ trip: answer })),
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
);
await useTripStore.getState().updateTrip(1, { title: 'Renamed' });
expect(refreshes).not.toHaveBeenCalled();
// A stay on a day the shorter range removed is gone on the server.
answer = buildTrip({ id: 1, title: 'Renamed', start_date: '2025-06-01', end_date: '2025-06-05' });
await useTripStore.getState().updateTrip(1, { end_date: '2025-06-05' });
expect(refreshes).toHaveBeenCalledTimes(1);
} finally {
window.removeEventListener('accommodations:refresh', refreshes);
}
});
});
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-019d: asks the planner to reload accommodations when a place or a stay is deleted elsewhere', () => {
// A deleted place takes the night booked at it along, and the stays live in the planner.
seedStore(useTripStore, { places: [buildPlace({ id: 500, trip_id: 1 })] });
const listener = vi.fn();
window.addEventListener('accommodations:refresh', listener);
useTripStore.getState().handleRemoteEvent({ type: 'place:deleted', placeId: 500 });
useTripStore.getState().handleRemoteEvent({ type: 'accommodation:deleted', accommodationId: 70 });
expect(listener).toHaveBeenCalledTimes(2);
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);
});
});
});