mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
39b5af790e
setRefetchCallback was dead code, so on reconnect the queue flushed and Dexie re-seeded but the open trip's Zustand store was never refreshed — a collaborator's edits made while we were offline didn't appear until navigating away and back. - new tripStore.hydrateActiveTrip(): silent refresh of the active trip's collaborative state (days/places/packing/todo/budget/reservations/files), no resetTrip and no isLoading toggle so there's no splash on reconnect - syncTriggers wires setRefetchCallback to it (WS layer awaits the flush hook first) and re-hydrates open trips after the online-event syncAll; cleared on unregister - websocket exposes getActiveTrips() for the online-event path - tests: refetch wiring + ordering, silent hydrate without reset/splash
373 lines
16 KiB
TypeScript
373 lines
16 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { http, HttpResponse } from 'msw';
|
|
import { useTripStore } from '../../src/store/tripStore';
|
|
import { resetAllStores } from '../helpers/store';
|
|
import { buildTrip, buildDay, buildPlace, buildPackingItem, buildTodoItem, buildTag, buildCategory, buildAssignment, buildDayNote, buildBudgetItem, buildReservation, buildTripFile } from '../helpers/factories';
|
|
import { server } from '../helpers/msw/server';
|
|
|
|
vi.mock('../../src/api/websocket', () => ({
|
|
connect: vi.fn(),
|
|
disconnect: vi.fn(),
|
|
getSocketId: vi.fn(() => null),
|
|
joinTrip: vi.fn(),
|
|
leaveTrip: vi.fn(),
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
setRefetchCallback: vi.fn(),
|
|
setPreReconnectHook: vi.fn(),
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
resetAllStores();
|
|
});
|
|
|
|
/** Full set of MSW handlers for one trip's loadTrip fan-out. */
|
|
function tripHandlers(
|
|
id: number,
|
|
data: {
|
|
budget?: unknown[]; reservations?: unknown[]; files?: unknown[];
|
|
tags?: unknown[]; categories?: unknown[];
|
|
},
|
|
) {
|
|
return [
|
|
http.get(`/api/trips/${id}`, () => HttpResponse.json({ trip: buildTrip({ id }) })),
|
|
http.get(`/api/trips/${id}/days`, () => HttpResponse.json({ days: [] })),
|
|
http.get(`/api/trips/${id}/places`, () => HttpResponse.json({ places: [] })),
|
|
http.get(`/api/trips/${id}/packing`, () => HttpResponse.json({ items: [] })),
|
|
http.get(`/api/trips/${id}/todo`, () => HttpResponse.json({ items: [] })),
|
|
http.get(`/api/trips/${id}/budget`, () => HttpResponse.json({ items: data.budget ?? [] })),
|
|
http.get(`/api/trips/${id}/reservations`, () => HttpResponse.json({ reservations: data.reservations ?? [] })),
|
|
http.get(`/api/trips/${id}/files`, () => HttpResponse.json({ files: data.files ?? [] })),
|
|
http.get('/api/tags', () => HttpResponse.json({ tags: data.tags ?? [] })),
|
|
http.get('/api/categories', () => HttpResponse.json({ categories: data.categories ?? [] })),
|
|
];
|
|
}
|
|
|
|
describe('tripStore', () => {
|
|
describe('loadTrip', () => {
|
|
it('FE-TRIP-001: fires parallel API calls for trips, days, places, packing, todo, tags, categories', async () => {
|
|
const calledUrls: string[] = [];
|
|
server.use(
|
|
http.get('/api/trips/:id', ({ params }) => {
|
|
calledUrls.push(`/api/trips/${params.id}`);
|
|
return HttpResponse.json({ trip: buildTrip({ id: Number(params.id) }) });
|
|
}),
|
|
http.get('/api/trips/:id/days', ({ params }) => {
|
|
calledUrls.push(`/api/trips/${params.id}/days`);
|
|
return HttpResponse.json({ days: [] });
|
|
}),
|
|
http.get('/api/trips/:id/places', ({ params }) => {
|
|
calledUrls.push(`/api/trips/${params.id}/places`);
|
|
return HttpResponse.json({ places: [] });
|
|
}),
|
|
http.get('/api/trips/:id/packing', ({ params }) => {
|
|
calledUrls.push(`/api/trips/${params.id}/packing`);
|
|
return HttpResponse.json({ items: [] });
|
|
}),
|
|
http.get('/api/trips/:id/todo', ({ params }) => {
|
|
calledUrls.push(`/api/trips/${params.id}/todo`);
|
|
return HttpResponse.json({ items: [] });
|
|
}),
|
|
http.get('/api/tags', () => {
|
|
calledUrls.push('/api/tags');
|
|
return HttpResponse.json({ tags: [] });
|
|
}),
|
|
http.get('/api/categories', () => {
|
|
calledUrls.push('/api/categories');
|
|
return HttpResponse.json({ categories: [] });
|
|
}),
|
|
);
|
|
|
|
await useTripStore.getState().loadTrip(1);
|
|
|
|
expect(calledUrls).toContain('/api/trips/1');
|
|
expect(calledUrls).toContain('/api/trips/1/days');
|
|
expect(calledUrls).toContain('/api/trips/1/places');
|
|
expect(calledUrls).toContain('/api/trips/1/packing');
|
|
expect(calledUrls).toContain('/api/trips/1/todo');
|
|
expect(calledUrls).toContain('/api/tags');
|
|
expect(calledUrls).toContain('/api/categories');
|
|
});
|
|
|
|
it('FE-TRIP-002: after loadTrip, all store fields are populated', async () => {
|
|
const trip = buildTrip({ id: 1 });
|
|
const place = buildPlace({ trip_id: 1 });
|
|
const packingItem = buildPackingItem({ trip_id: 1 });
|
|
const todoItem = buildTodoItem({ trip_id: 1 });
|
|
const tag = buildTag();
|
|
const category = buildCategory();
|
|
|
|
server.use(
|
|
http.get('/api/trips/1', () => HttpResponse.json({ trip })),
|
|
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
|
|
http.get('/api/trips/1/places', () => HttpResponse.json({ places: [place] })),
|
|
http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [packingItem] })),
|
|
http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [todoItem] })),
|
|
http.get('/api/tags', () => HttpResponse.json({ tags: [tag] })),
|
|
http.get('/api/categories', () => HttpResponse.json({ categories: [category] })),
|
|
);
|
|
|
|
await useTripStore.getState().loadTrip(1);
|
|
const state = useTripStore.getState();
|
|
|
|
expect(state.trip).toEqual(trip);
|
|
expect(state.places).toEqual([place]);
|
|
expect(state.packingItems).toEqual([packingItem]);
|
|
expect(state.todoItems).toEqual([todoItem]);
|
|
expect(state.tags).toEqual([tag]);
|
|
expect(state.categories).toEqual([category]);
|
|
});
|
|
|
|
it('FE-TRIP-003: loadTrip extracts assignments map from days response', async () => {
|
|
const assignment = buildAssignment({ day_id: 10, order_index: 0 });
|
|
const day = buildDay({ id: 10, assignments: [assignment], notes_items: [] });
|
|
|
|
server.use(
|
|
http.get('/api/trips/1', () => HttpResponse.json({ trip: buildTrip({ id: 1 }) })),
|
|
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [day] })),
|
|
http.get('/api/trips/1/places', () => HttpResponse.json({ places: [] })),
|
|
http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [] })),
|
|
http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [] })),
|
|
http.get('/api/tags', () => HttpResponse.json({ tags: [] })),
|
|
http.get('/api/categories', () => HttpResponse.json({ categories: [] })),
|
|
);
|
|
|
|
await useTripStore.getState().loadTrip(1);
|
|
const { assignments } = useTripStore.getState();
|
|
|
|
expect(assignments['10']).toBeDefined();
|
|
expect(assignments['10']).toEqual([assignment]);
|
|
});
|
|
|
|
it('FE-TRIP-004: loadTrip extracts dayNotes map from days response', async () => {
|
|
const note = buildDayNote({ day_id: 10 });
|
|
const day = buildDay({ id: 10, assignments: [], notes_items: [note] });
|
|
|
|
server.use(
|
|
http.get('/api/trips/1', () => HttpResponse.json({ trip: buildTrip({ id: 1 }) })),
|
|
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [day] })),
|
|
http.get('/api/trips/1/places', () => HttpResponse.json({ places: [] })),
|
|
http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [] })),
|
|
http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [] })),
|
|
http.get('/api/tags', () => HttpResponse.json({ tags: [] })),
|
|
http.get('/api/categories', () => HttpResponse.json({ categories: [] })),
|
|
);
|
|
|
|
await useTripStore.getState().loadTrip(1);
|
|
const { dayNotes } = useTripStore.getState();
|
|
|
|
expect(dayNotes['10']).toBeDefined();
|
|
expect(dayNotes['10']).toEqual([note]);
|
|
});
|
|
|
|
it('FE-TRIP-005: loadTrip sets isLoading true during, false after', async () => {
|
|
let wasLoadingDuringFetch = false;
|
|
|
|
server.use(
|
|
http.get('/api/trips/1', () => {
|
|
wasLoadingDuringFetch = useTripStore.getState().isLoading;
|
|
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: [] })),
|
|
http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [] })),
|
|
http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [] })),
|
|
http.get('/api/tags', () => HttpResponse.json({ tags: [] })),
|
|
http.get('/api/categories', () => HttpResponse.json({ categories: [] })),
|
|
);
|
|
|
|
const promise = useTripStore.getState().loadTrip(1);
|
|
expect(useTripStore.getState().isLoading).toBe(true);
|
|
await promise;
|
|
expect(wasLoadingDuringFetch).toBe(true);
|
|
expect(useTripStore.getState().isLoading).toBe(false);
|
|
});
|
|
|
|
it('FE-TRIP-006: loadTrip on API failure sets error and isLoading: false', async () => {
|
|
server.use(
|
|
http.get('/api/trips/1', () => HttpResponse.json({ message: 'Not found' }, { status: 404 })),
|
|
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
|
|
http.get('/api/trips/1/places', () => HttpResponse.json({ places: [] })),
|
|
http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [] })),
|
|
http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [] })),
|
|
http.get('/api/tags', () => HttpResponse.json({ tags: [] })),
|
|
http.get('/api/categories', () => HttpResponse.json({ categories: [] })),
|
|
);
|
|
|
|
await expect(useTripStore.getState().loadTrip(1)).rejects.toThrow();
|
|
|
|
const state = useTripStore.getState();
|
|
expect(state.isLoading).toBe(false);
|
|
expect(state.error).not.toBeNull();
|
|
});
|
|
|
|
it('FE-TRIP-H5: loadTrip uniformly hydrates budget, reservations and files', async () => {
|
|
const budgetItem = buildBudgetItem({ trip_id: 1 });
|
|
const reservation = buildReservation({ trip_id: 1 });
|
|
const file = buildTripFile({ trip_id: 1 });
|
|
server.use(...tripHandlers(1, { budget: [budgetItem], reservations: [reservation], files: [file] }));
|
|
|
|
await useTripStore.getState().loadTrip(1);
|
|
const state = useTripStore.getState();
|
|
|
|
expect(state.budgetItems).toEqual([budgetItem]);
|
|
expect(state.reservations).toEqual([reservation]);
|
|
expect(state.files).toEqual([file]);
|
|
});
|
|
|
|
it('FE-TRIP-H4: switching trips does not leak budget/reservations/files from the previous trip', async () => {
|
|
// Trip 1 has budget/reservations/files; trip 2 has none.
|
|
server.use(...tripHandlers(1, {
|
|
budget: [buildBudgetItem({ trip_id: 1 })],
|
|
reservations: [buildReservation({ trip_id: 1 })],
|
|
files: [buildTripFile({ trip_id: 1 })],
|
|
}));
|
|
await useTripStore.getState().loadTrip(1);
|
|
expect(useTripStore.getState().budgetItems).toHaveLength(1);
|
|
|
|
server.use(...tripHandlers(2, {}));
|
|
await useTripStore.getState().loadTrip(2);
|
|
const state = useTripStore.getState();
|
|
|
|
expect(state.trip!.id).toBe(2);
|
|
expect(state.budgetItems).toEqual([]);
|
|
expect(state.reservations).toEqual([]);
|
|
expect(state.files).toEqual([]);
|
|
});
|
|
|
|
it('FE-TRIP-H4b: resetTrip clears every trip-scoped slice but keeps tags/categories', async () => {
|
|
server.use(...tripHandlers(1, {
|
|
budget: [buildBudgetItem({ trip_id: 1 })],
|
|
reservations: [buildReservation({ trip_id: 1 })],
|
|
files: [buildTripFile({ trip_id: 1 })],
|
|
tags: [buildTag()],
|
|
}));
|
|
await useTripStore.getState().loadTrip(1);
|
|
expect(useTripStore.getState().budgetItems).toHaveLength(1);
|
|
|
|
useTripStore.getState().resetTrip();
|
|
const state = useTripStore.getState();
|
|
|
|
expect(state.trip).toBeNull();
|
|
expect(state.places).toEqual([]);
|
|
expect(state.budgetItems).toEqual([]);
|
|
expect(state.reservations).toEqual([]);
|
|
expect(state.files).toEqual([]);
|
|
expect(state.selectedDayId).toBeNull();
|
|
// Global lookups survive a trip reset.
|
|
expect(state.tags).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('hydrateActiveTrip', () => {
|
|
const loadHandlers = (places: unknown[] = [], budget: unknown[] = []) => [
|
|
http.get('/api/trips/1', () => HttpResponse.json({ trip: buildTrip({ id: 1 }) })),
|
|
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
|
|
http.get('/api/trips/1/places', () => HttpResponse.json({ places })),
|
|
http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [] })),
|
|
http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [] })),
|
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: budget })),
|
|
http.get('/api/trips/1/reservations', () => HttpResponse.json({ reservations: [] })),
|
|
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })),
|
|
http.get('/api/tags', () => HttpResponse.json({ tags: [] })),
|
|
http.get('/api/categories', () => HttpResponse.json({ categories: [] })),
|
|
];
|
|
|
|
it('FE-TRIP-H1: silently refreshes resources without resetting or splashing', async () => {
|
|
server.use(...loadHandlers());
|
|
await useTripStore.getState().loadTrip(1);
|
|
expect(useTripStore.getState().trip!.id).toBe(1);
|
|
|
|
// New collaborative state arrives (as if edited by someone while we were offline).
|
|
const place = buildPlace({ trip_id: 1 });
|
|
const budgetItem = buildBudgetItem({ trip_id: 1 });
|
|
server.use(...loadHandlers([place], [budgetItem]));
|
|
|
|
await useTripStore.getState().hydrateActiveTrip(1);
|
|
const state = useTripStore.getState();
|
|
|
|
expect(state.places).toEqual([place]);
|
|
expect(state.budgetItems).toEqual([budgetItem]);
|
|
expect(state.trip!.id).toBe(1); // trip not reset
|
|
expect(state.isLoading).toBe(false); // no splash toggled
|
|
});
|
|
});
|
|
|
|
describe('refreshDays', () => {
|
|
it('FE-TRIP-007: refreshDays re-fetches days and rebuilds assignments/dayNotes maps', async () => {
|
|
const assignment = buildAssignment({ day_id: 20, order_index: 0 });
|
|
const note = buildDayNote({ day_id: 20 });
|
|
const day = buildDay({ id: 20, assignments: [assignment], notes_items: [note] });
|
|
|
|
server.use(
|
|
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [day] })),
|
|
);
|
|
|
|
await useTripStore.getState().refreshDays(1);
|
|
const state = useTripStore.getState();
|
|
|
|
expect(state.days).toHaveLength(1);
|
|
expect(state.assignments['20']).toEqual([assignment]);
|
|
expect(state.dayNotes['20']).toEqual([note]);
|
|
});
|
|
});
|
|
|
|
describe('updateTrip', () => {
|
|
it('FE-TRIP-008: updateTrip persists and refreshes trip + days', async () => {
|
|
const updatedTrip = buildTrip({ id: 1, title: 'Updated Trip' });
|
|
|
|
server.use(
|
|
http.put('/api/trips/1', () => HttpResponse.json({ trip: updatedTrip })),
|
|
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
|
|
);
|
|
|
|
const result = await useTripStore.getState().updateTrip(1, { title: 'Updated Trip' });
|
|
|
|
expect(result).toEqual(updatedTrip);
|
|
expect(useTripStore.getState().trip).toEqual(updatedTrip);
|
|
});
|
|
});
|
|
|
|
describe('setSelectedDay', () => {
|
|
it('FE-TRIP-009: setSelectedDay updates selectedDayId', () => {
|
|
useTripStore.getState().setSelectedDay(42);
|
|
expect(useTripStore.getState().selectedDayId).toBe(42);
|
|
|
|
useTripStore.getState().setSelectedDay(null);
|
|
expect(useTripStore.getState().selectedDayId).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('addTag', () => {
|
|
it('FE-TRIP-010: addTag creates tag and appends to tags', async () => {
|
|
const existingTag = buildTag();
|
|
useTripStore.setState({ tags: [existingTag] });
|
|
|
|
const newTagData = { name: 'New Tag', color: '#00ff00' };
|
|
|
|
const result = await useTripStore.getState().addTag(newTagData);
|
|
|
|
expect(result.name).toBe('New Tag');
|
|
const tags = useTripStore.getState().tags;
|
|
expect(tags).toHaveLength(2);
|
|
expect(tags[tags.length - 1].name).toBe('New Tag');
|
|
});
|
|
});
|
|
|
|
describe('addCategory', () => {
|
|
it('FE-TRIP-011: addCategory creates category and appends to categories', async () => {
|
|
const existingCategory = buildCategory();
|
|
useTripStore.setState({ categories: [existingCategory] });
|
|
|
|
const newCategoryData = { name: 'New Category', icon: 'hotel' };
|
|
|
|
const result = await useTripStore.getState().addCategory(newCategoryData);
|
|
|
|
expect(result.name).toBe('New Category');
|
|
const categories = useTripStore.getState().categories;
|
|
expect(categories).toHaveLength(2);
|
|
expect(categories[categories.length - 1].name).toBe('New Category');
|
|
});
|
|
});
|
|
});
|