Release branch for 4.0.0.
This commit is contained in:
jubnl
2026-08-27 13:22:39 +02:00
committed by GitHub
parent e60427f813
commit 77dddd5e8a
3355 changed files with 403744 additions and 67215 deletions
@@ -160,4 +160,119 @@ describe('remoteEventHandler > assignments', () => {
expect(item1?.order_index).toBe(1);
expect(item2?.order_index).toBe(2);
});
// A collaborator can touch a day this client has never populated (the day row
// exists, but no assignments key). Every applier must fall back to an empty list.
describe('days with no assignments entry yet', () => {
it('FE-WSEVT-ASSIGN-008: assignment:created seeds a fresh day key', () => {
useTripStore.setState({ days: [buildDay({ id: 30 })], assignments: {} });
useTripStore.getState().handleRemoteEvent({
type: 'assignment:created',
assignment: buildAssignment({ id: 300, day_id: 30 }),
});
expect(useTripStore.getState().assignments['30'].map(a => a.id)).toEqual([300]);
});
it('FE-WSEVT-ASSIGN-009: assignment:updated on an unseen day yields an empty list', () => {
useTripStore.setState({ days: [buildDay({ id: 30 })], assignments: {} });
useTripStore.getState().handleRemoteEvent({
type: 'assignment:updated',
assignment: buildAssignment({ id: 300, day_id: 30 }),
});
expect(useTripStore.getState().assignments['30']).toEqual([]);
});
it('FE-WSEVT-ASSIGN-010: assignment:deleted on an unseen day yields an empty list', () => {
useTripStore.setState({ days: [buildDay({ id: 30 })], assignments: {} });
useTripStore.getState().handleRemoteEvent({ type: 'assignment:deleted', assignmentId: 1, dayId: 30 });
expect(useTripStore.getState().assignments['30']).toEqual([]);
});
it('FE-WSEVT-ASSIGN-011: assignment:reordered on an unseen day yields an empty list', () => {
useTripStore.setState({ days: [buildDay({ id: 30 })], assignments: {} });
useTripStore.getState().handleRemoteEvent({ type: 'assignment:reordered', dayId: 30, orderedIds: [1] });
expect(useTripStore.getState().assignments['30']).toEqual([]);
});
it('FE-WSEVT-ASSIGN-011b: assignment:moved between two unseen days seeds both keys', () => {
const moved = buildAssignment({ id: 300, day_id: 40 });
useTripStore.setState({ days: [buildDay({ id: 30 }), buildDay({ id: 40 })], assignments: {} });
useTripStore.getState().handleRemoteEvent({
type: 'assignment:moved',
assignment: moved,
oldDayId: 30,
newDayId: 40,
});
const { assignments } = useTripStore.getState();
expect(assignments['30']).toEqual([]);
expect(assignments['40'].map(a => a.id)).toEqual([300]);
});
it('FE-WSEVT-ASSIGN-012: an event for a day that is not in the store leaves days untouched', () => {
// The Dexie write-through resolves the Day row from Zustand; a missing row
// must not blow up the (fire-and-forget) persist.
useTripStore.setState({ days: [], assignments: {} });
expect(() => useTripStore.getState().handleRemoteEvent({
type: 'assignment:created',
assignment: buildAssignment({ id: 300, day_id: 77 }),
})).not.toThrow();
expect(useTripStore.getState().days).toEqual([]);
});
});
it('FE-WSEVT-ASSIGN-013: assignment:updated only merges into the addressed assignment', () => {
useTripStore.setState({
days: [buildDay({ id: 10 })],
assignments: {
'10': [buildAssignment({ id: 100, day_id: 10, notes: 'keep' }), buildAssignment({ id: 101, day_id: 10, notes: 'keep too' })],
},
});
useTripStore.getState().handleRemoteEvent({
type: 'assignment:updated',
assignment: buildAssignment({ id: 101, day_id: 10, notes: 'changed' }),
});
const items = useTripStore.getState().assignments['10'];
expect(items[0].notes).toBe('keep');
expect(items[1].notes).toBe('changed');
});
it('FE-WSEVT-ASSIGN-014: assignment:moved drops a stale copy already sitting on the target day', () => {
const moved = buildAssignment({ id: 100, day_id: 20 });
useTripStore.setState({
days: [buildDay({ id: 10 }), buildDay({ id: 20 })],
assignments: {
'10': [moved],
// The target day still holds the previous copy plus an unrelated item.
'20': [buildAssignment({ id: 100, day_id: 20 }), buildAssignment({ id: 200, day_id: 20 })],
},
});
useTripStore.getState().handleRemoteEvent({
type: 'assignment:moved',
assignment: moved,
oldDayId: 10,
newDayId: 20,
});
const target = useTripStore.getState().assignments['20'];
expect(target.map(a => a.id)).toEqual([200, 100]);
expect(useTripStore.getState().assignments['10']).toHaveLength(0);
});
it('FE-WSEVT-ASSIGN-015: assignment:reordered drops ids that are no longer on the day', () => {
useTripStore.setState({
days: [buildDay({ id: 10 })],
assignments: { '10': [buildAssignment({ id: 1, day_id: 10 }), buildAssignment({ id: 2, day_id: 10 })] },
});
useTripStore.getState().handleRemoteEvent({ type: 'assignment:reordered', dayId: 10, orderedIds: [2, 999, 1] });
expect(useTripStore.getState().assignments['10'].map(a => a.id)).toEqual([2, 1]);
});
it('FE-WSEVT-ASSIGN-016: assignment:reordered without orderedIds leaves the day list alone', () => {
const existing = buildAssignment({ id: 1, day_id: 10 });
useTripStore.setState({
days: [buildDay({ id: 10 })],
assignments: { '10': [existing] },
});
useTripStore.getState().handleRemoteEvent({ type: 'assignment:reordered', dayId: 10 });
expect(useTripStore.getState().assignments['10']).toEqual([existing]);
});
});
@@ -80,14 +80,126 @@ describe('remoteEventHandler > budget', () => {
type: 'budget:member-paid-updated',
itemId: 1,
userId: 5,
paid: true,
// 1, not true: both emitters send `paid: body.paid ? 1 : 0`, and the handler
// stores the value verbatim. Asserting on `true` tested a payload the server
// never sends — the seeded value on the line below is a number for the same reason.
paid: 1,
});
const { budgetItems } = useTripStore.getState();
const item = budgetItems.find(i => i.id === 1);
const m = item?.members?.find(m => m.user_id === 5);
expect(m?.paid).toBe(true);
expect(m?.paid).toBe(1);
// Other item members unchanged (member2 keeps its seeded paid value)
const item2 = budgetItems.find(i => i.id === 2);
expect(item2?.members?.[0].paid).toBe(1);
});
it('FE-WSEVT-BUDGET-007: budget:members-updated for an unknown item leaves every item untouched', () => {
seedData();
useTripStore.getState().handleRemoteEvent({
type: 'budget:members-updated',
itemId: 4242,
members: [{ user_id: 9, paid: 0, username: 'ivan' }],
persons: 9,
});
const { budgetItems } = useTripStore.getState();
expect(budgetItems.map(i => i.persons)).toEqual([1, 2]);
expect(budgetItems[0].members).toEqual([{ ...member1 }]);
});
it('FE-WSEVT-BUDGET-008: budget:member-paid-updated only touches the addressed member', () => {
useTripStore.setState({
budgetItems: [
buildBudgetItem({ id: 1, members: [{ ...member1 }, { ...member2 }] }),
],
});
useTripStore.getState().handleRemoteEvent({
type: 'budget:member-paid-updated',
itemId: 1,
userId: 5,
paid: 1,
});
const members = useTripStore.getState().budgetItems[0].members ?? [];
expect(members.find(m => m.user_id === 5)?.paid).toBe(1);
expect(members.find(m => m.user_id === 6)?.paid).toBe(1); // seeded value, untouched
});
it('FE-WSEVT-BUDGET-009: budget:member-paid-updated on an item without members does not throw', () => {
useTripStore.setState({
budgetItems: [buildBudgetItem({ id: 1, members: undefined })],
});
useTripStore.getState().handleRemoteEvent({
type: 'budget:member-paid-updated',
itemId: 1,
userId: 5,
paid: 1,
});
expect(useTripStore.getState().budgetItems[0].members).toEqual([]);
});
describe('budget:reordered', () => {
it('FE-WSEVT-BUDGET-010: orderedIds rewrites sort_order and keeps unlisted items at the end', () => {
useTripStore.setState({
budgetItems: [
buildBudgetItem({ id: 1, sort_order: 0 }),
buildBudgetItem({ id: 2, sort_order: 1 }),
buildBudgetItem({ id: 3, sort_order: 2 }),
],
});
useTripStore.getState().handleRemoteEvent({ type: 'budget:reordered', orderedIds: [3, 1] });
const { budgetItems } = useTripStore.getState();
expect(budgetItems.map(i => i.id)).toEqual([3, 1, 2]);
// The whole array is reindexed, so re-sorting by sort_order reproduces the
// order that was just applied.
expect(budgetItems.map(i => i.sort_order)).toEqual([0, 1, 2]);
});
it('FE-WSEVT-BUDGET-011: an orderedIds entry for an unknown item is dropped', () => {
useTripStore.setState({
budgetItems: [buildBudgetItem({ id: 1 }), buildBudgetItem({ id: 2 })],
});
useTripStore.getState().handleRemoteEvent({ type: 'budget:reordered', orderedIds: [2, 999, 1] });
expect(useTripStore.getState().budgetItems.map(i => i.id)).toEqual([2, 1]);
});
it('FE-WSEVT-BUDGET-012: orderedCategories groups items and appends unlisted categories', () => {
useTripStore.setState({
budgetItems: [
buildBudgetItem({ id: 1, category: 'Food' }),
buildBudgetItem({ id: 2, category: 'Transport' }),
buildBudgetItem({ id: 3, category: 'Food' }),
buildBudgetItem({ id: 4, category: 'Lodging' }),
],
});
useTripStore.getState().handleRemoteEvent({
type: 'budget:reordered',
orderedCategories: ['Transport', 'Food', 'Souvenirs'],
});
// Transport, then both Food rows in their original order, then the
// category the server did not mention.
expect(useTripStore.getState().budgetItems.map(i => i.id)).toEqual([2, 1, 3, 4]);
});
it('FE-WSEVT-BUDGET-013: an item without a category is grouped under "Other"', () => {
useTripStore.setState({
budgetItems: [
buildBudgetItem({ id: 1, category: null }),
buildBudgetItem({ id: 2, category: 'Food' }),
],
});
useTripStore.getState().handleRemoteEvent({
type: 'budget:reordered',
orderedCategories: ['Other', 'Food'],
});
expect(useTripStore.getState().budgetItems.map(i => i.id)).toEqual([1, 2]);
});
it('FE-WSEVT-BUDGET-014: a payload with neither key is a no-op', () => {
useTripStore.setState({
budgetItems: [buildBudgetItem({ id: 1 }), buildBudgetItem({ id: 2 })],
});
useTripStore.getState().handleRemoteEvent({ type: 'budget:reordered' });
expect(useTripStore.getState().budgetItems.map(i => i.id)).toEqual([1, 2]);
});
});
});
@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores } from '../../helpers/store';
import { buildDayNote } from '../../helpers/factories';
import { buildDay, buildDayNote } from '../../helpers/factories';
beforeEach(() => {
resetAllStores();
@@ -50,6 +50,52 @@ describe('remoteEventHandler > dayNotes', () => {
expect(dayNotes['10']).toHaveLength(0);
});
it('FE-WSEVT-DAYNOTE-006: dayNote:updated only replaces the addressed note', () => {
useTripStore.setState({
days: [buildDay({ id: 10 })],
dayNotes: { '10': [buildDayNote({ id: 1, day_id: 10, text: 'keep' }), buildDayNote({ id: 2, day_id: 10, text: 'old' })] },
});
useTripStore.getState().handleRemoteEvent({
type: 'dayNote:updated',
dayId: 10,
note: buildDayNote({ id: 2, day_id: 10, text: 'new' }),
});
expect(useTripStore.getState().dayNotes['10'].map(n => n.text)).toEqual(['keep', 'new']);
});
// A collaborator can add the first note to a day this client never populated.
describe('days with no dayNotes entry yet', () => {
const seedBareDay = () => {
useTripStore.setState({ days: [buildDay({ id: 30 })], dayNotes: {} });
};
it('FE-WSEVT-DAYNOTE-007: dayNote:created seeds a fresh day key', () => {
seedBareDay();
useTripStore.getState().handleRemoteEvent({
type: 'dayNote:created',
dayId: 30,
note: buildDayNote({ id: 9, day_id: 30 }),
});
expect(useTripStore.getState().dayNotes['30'].map(n => n.id)).toEqual([9]);
});
it('FE-WSEVT-DAYNOTE-008: dayNote:updated on an unseen day yields an empty list', () => {
seedBareDay();
useTripStore.getState().handleRemoteEvent({
type: 'dayNote:updated',
dayId: 30,
note: buildDayNote({ id: 9, day_id: 30 }),
});
expect(useTripStore.getState().dayNotes['30']).toEqual([]);
});
it('FE-WSEVT-DAYNOTE-009: dayNote:deleted on an unseen day yields an empty list', () => {
seedBareDay();
useTripStore.getState().handleRemoteEvent({ type: 'dayNote:deleted', dayId: 30, noteId: 9 });
expect(useTripStore.getState().dayNotes['30']).toEqual([]);
});
});
it('FE-WSEVT-DAYNOTE-005: operations on day 10 do not affect day 20', () => {
seedData();
const newNote = buildDayNote({ id: 50, day_id: 10, text: 'Day 10 note' });
@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores } from '../../helpers/store';
import { buildDay, buildAssignment, buildDayNote } from '../../helpers/factories';
import { buildDay, buildAssignment, buildDayNote, buildTrip } from '../../helpers/factories';
beforeEach(() => {
resetAllStores();
@@ -77,4 +77,72 @@ describe('remoteEventHandler > days', () => {
expect('20' in assignments).toBe(true);
expect('20' in dayNotes).toBe(true);
});
// The reorder is applied optimistically from orderedIds, then the authoritative
// dates + re-stamped booking times are pulled (#589).
describe('day:reordered', () => {
const stubRefresh = () => {
const refreshDays = vi.fn(async () => {});
const loadReservations = vi.fn(async () => {});
useTripStore.setState({ refreshDays, loadReservations });
return { refreshDays, loadReservations };
};
const seedThreeDays = () => {
useTripStore.setState({
trip: buildTrip({ id: 7 }),
days: [
buildDay({ id: 10, day_number: 1 }),
buildDay({ id: 20, day_number: 2 }),
buildDay({ id: 30, day_number: 3 }),
],
});
};
it('FE-WSEVT-DAY-008: applies the new order and renumbers day_number', () => {
seedThreeDays();
stubRefresh();
useTripStore.getState().handleRemoteEvent({ type: 'day:reordered', orderedIds: [30, 10, 20] });
const { days } = useTripStore.getState();
expect(days.map(d => d.id)).toEqual([30, 10, 20]);
expect(days.map(d => d.day_number)).toEqual([1, 2, 3]);
});
it('FE-WSEVT-DAY-009: pulls the authoritative days + reservations after a reorder', () => {
seedThreeDays();
const { refreshDays, loadReservations } = stubRefresh();
useTripStore.getState().handleRemoteEvent({ type: 'day:reordered', orderedIds: [30, 10, 20] });
expect(refreshDays).toHaveBeenCalledWith(7);
expect(loadReservations).toHaveBeenCalledWith(7);
});
it('FE-WSEVT-DAY-010: a partial orderedIds list leaves the order untouched', () => {
seedThreeDays();
stubRefresh();
useTripStore.getState().handleRemoteEvent({ type: 'day:reordered', orderedIds: [30, 10] });
expect(useTripStore.getState().days.map(d => d.id)).toEqual([10, 20, 30]);
});
it('FE-WSEVT-DAY-011: an unknown day id leaves the order untouched', () => {
seedThreeDays();
stubRefresh();
useTripStore.getState().handleRemoteEvent({ type: 'day:reordered', orderedIds: [30, 10, 999] });
expect(useTripStore.getState().days.map(d => d.id)).toEqual([10, 20, 30]);
});
it('FE-WSEVT-DAY-012: a missing orderedIds payload is a no-op', () => {
seedThreeDays();
stubRefresh();
useTripStore.getState().handleRemoteEvent({ type: 'day:reordered' });
expect(useTripStore.getState().days.map(d => d.id)).toEqual([10, 20, 30]);
});
it('FE-WSEVT-DAY-013: without a loaded trip nothing is refetched', () => {
useTripStore.setState({ trip: null, days: [buildDay({ id: 10 }), buildDay({ id: 20 })] });
const { refreshDays, loadReservations } = stubRefresh();
useTripStore.getState().handleRemoteEvent({ type: 'day:reordered', orderedIds: [20, 10] });
expect(refreshDays).not.toHaveBeenCalled();
expect(loadReservations).not.toHaveBeenCalled();
});
});
});
@@ -47,6 +47,17 @@ describe('remoteEventHandler > files', () => {
expect(files).toHaveLength(0);
});
it('FE-WSEVT-FILE-006: file:updated leaves the other files untouched', () => {
useTripStore.setState({
files: [buildTripFile({ id: 1, original_name: 'a.pdf' }), buildTripFile({ id: 2, original_name: 'b.pdf' })],
});
useTripStore.getState().handleRemoteEvent({
type: 'file:updated',
file: buildTripFile({ id: 2, original_name: 'b-renamed.pdf' }),
});
expect(useTripStore.getState().files.map(f => f.original_name)).toEqual(['a.pdf', 'b-renamed.pdf']);
});
it('FE-WSEVT-FILE-005: file:created ordering — newest is first', () => {
seedData();
const f2 = buildTripFile({ id: 2, original_name: 'second.pdf' });
@@ -46,4 +46,15 @@ describe('remoteEventHandler > packing', () => {
const { packingItems } = useTripStore.getState();
expect(packingItems).toHaveLength(0);
});
it('FE-WSEVT-PACK-005: packing:updated leaves the other items untouched', () => {
useTripStore.setState({
packingItems: [buildPackingItem({ id: 1, name: 'Sunscreen' }), buildPackingItem({ id: 2, name: 'Hat' })],
});
useTripStore.getState().handleRemoteEvent({
type: 'packing:updated',
item: buildPackingItem({ id: 2, name: 'Sun hat' }),
});
expect(useTripStore.getState().packingItems.map(i => i.name)).toEqual(['Sunscreen', 'Sun hat']);
});
});
@@ -0,0 +1,90 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
/**
* What the offline cache is allowed to keep of another member's packing list
* (#1976).
*
* The server scopes these events now, so in ordinary running nothing here has
* anything to refuse. It refuses anyway, because a leak on the wire used to
* become permanent on this side: the write is a `put`, the offline read hands
* back every cached row for the trip without a privacy filter, and nothing ever
* prunes. One stray event put another member's item into this browser for good,
* and it surfaced every time a read fell back to the cache — offline, captive
* portal, dropped connection.
*
* So the interesting case is not that the item is skipped. It is that a row
* which arrived before any of this existed gets deleted when the next event
* about it comes past, which is what makes an already-leaked list heal itself.
*/
const { packingItems } = vi.hoisted(() => ({
packingItems: { put: vi.fn(async () => undefined), delete: vi.fn(async () => undefined) },
}));
vi.mock('../../../src/db/offlineDb', () => ({
offlineDb: {
packingItems,
// The handler module touches these at import time only.
trips: {}, days: {}, places: {}, assignments: {},
},
}));
import { useTripStore } from '../../../src/store/tripStore';
import { useAuthStore } from '../../../src/store/authStore';
import { resetAllStores } from '../../helpers/store';
import { buildPackingItem } from '../../helpers/factories';
const ME = 7;
const SOMEONE_ELSE = 99;
beforeEach(() => {
resetAllStores();
packingItems.put.mockClear();
packingItems.delete.mockClear();
useAuthStore.setState({ user: { id: ME, username: 'me', email: 'me@example.test' } as never });
});
const send = (item: unknown) =>
useTripStore.getState().handleRemoteEvent({ type: 'packing:updated', item } as never);
describe('a packing item arriving over the wire', () => {
it('is cached when it is shared with the whole trip', async () => {
send(buildPackingItem({ id: 1, is_private: 0 }));
expect(packingItems.put).toHaveBeenCalledTimes(1);
expect(packingItems.delete).not.toHaveBeenCalled();
});
it('is cached when it is my own private one', async () => {
send(buildPackingItem({ id: 2, is_private: 1, owner_id: ME }));
expect(packingItems.put).toHaveBeenCalledTimes(1);
});
it('is cached when it is private but shared with me', async () => {
send(buildPackingItem({
id: 3, is_private: 1, owner_id: SOMEONE_ELSE,
recipients: [{ user_id: ME, username: 'me' }],
}));
expect(packingItems.put).toHaveBeenCalledTimes(1);
});
/* The assertion that would have caught the leak. */
it('is not cached when it is somebody else s private one', async () => {
send(buildPackingItem({ id: 4, is_private: 1, owner_id: SOMEONE_ELSE }));
expect(packingItems.put).not.toHaveBeenCalled();
});
it('removes a copy that an earlier leak already left behind', async () => {
send(buildPackingItem({ id: 5, is_private: 1, owner_id: SOMEONE_ELSE }));
expect(packingItems.delete).toHaveBeenCalledWith(5);
});
/*
* A signed-out or not-yet-loaded session has no id to compare against.
* Refusing is the safe answer: there is no user whose list this could be.
*/
it('keeps a restricted item out when nobody is signed in', async () => {
useAuthStore.setState({ user: null });
send(buildPackingItem({ id: 6, is_private: 1, owner_id: SOMEONE_ELSE }));
expect(packingItems.put).not.toHaveBeenCalled();
});
});
@@ -51,6 +51,56 @@ describe('remoteEventHandler > places', () => {
expect(assignments['10'][0].place?.name).toBe('Cascaded Update');
});
it('FE-WSEVT-PLACE-004b: place:updated keeps a per-stop override', () => {
// The day view renders COALESCE(assignment_time, place_time), so an
// assignment that carries its own time keeps it when the pool row changes
// (the local reducer merges the same way).
const place = buildPlace({ id: 1, name: 'Original', place_time: '08:00', end_time: '09:00' });
const assignment = buildAssignment({
id: 100,
day_id: 10,
assignment_time: '14:00',
assignment_end_time: '15:30',
place: { ...place, place_time: '14:00', end_time: '15:30' },
});
useTripStore.setState({ places: [place], assignments: { '10': [assignment] } });
useTripStore.getState().handleRemoteEvent({
type: 'place:updated',
place: buildPlace({ id: 1, name: 'Renamed', place_time: '08:00', end_time: '09:00' }),
});
const embedded = useTripStore.getState().assignments['10'][0].place!;
expect(embedded.name).toBe('Renamed');
expect(embedded.place_time).toBe('14:00');
expect(embedded.end_time).toBe('15:30');
});
it('FE-WSEVT-PLACE-004c: without an override the pool time reaches the day card', () => {
// The other half of the COALESCE. An assignment with no assignment_time
// shows whatever the pool row says, so a collaborator moving the place from
// 08:00 to 10:00 has to land here - freezing the embedded copy would leave
// the day plan wrong until the next reload.
const place = buildPlace({ id: 1, name: 'Original', place_time: '08:00', end_time: '09:00' });
const assignment = buildAssignment({
id: 100,
day_id: 10,
assignment_time: null,
assignment_end_time: null,
place: { ...place },
});
useTripStore.setState({ places: [place], assignments: { '10': [assignment] } });
useTripStore.getState().handleRemoteEvent({
type: 'place:updated',
place: buildPlace({ id: 1, name: 'Original', place_time: '10:00', end_time: '11:00' }),
});
const embedded = useTripStore.getState().assignments['10'][0].place!;
expect(embedded.place_time).toBe('10:00');
expect(embedded.end_time).toBe('11:00');
});
it('FE-WSEVT-PLACE-005: place:deleted removes place from places array', () => {
seedData();
useTripStore.getState().handleRemoteEvent({ type: 'place:deleted', placeId: 1 });
@@ -64,4 +114,40 @@ describe('remoteEventHandler > places', () => {
const { assignments } = useTripStore.getState();
expect(assignments['10']).toHaveLength(0);
});
it('FE-WSEVT-PLACE-007: place:updated leaves other places and their assignments untouched', () => {
const one = buildPlace({ id: 1, name: 'One' });
const two = buildPlace({ id: 2, name: 'Two' });
useTripStore.setState({
places: [one, two],
assignments: {
'10': [
buildAssignment({ id: 100, place: one, day_id: 10 }),
buildAssignment({ id: 200, place: two, day_id: 10 }),
],
},
});
useTripStore.getState().handleRemoteEvent({ type: 'place:updated', place: buildPlace({ id: 2, name: 'Two renamed' }) });
const { places, assignments } = useTripStore.getState();
expect(places.map(p => p.name)).toEqual(['One', 'Two renamed']);
expect(assignments['10'].map(a => a.place?.name)).toEqual(['One', 'Two renamed']);
});
it('FE-WSEVT-PLACE-008: place:deleted keeps assignments of the other places', () => {
const one = buildPlace({ id: 1 });
const two = buildPlace({ id: 2 });
useTripStore.setState({
places: [one, two],
assignments: {
'10': [
buildAssignment({ id: 100, place: one, day_id: 10 }),
buildAssignment({ id: 200, place: two, day_id: 10 }),
],
},
});
useTripStore.getState().handleRemoteEvent({ type: 'place:deleted', placeId: 1 });
const { places, assignments } = useTripStore.getState();
expect(places.map(p => p.id)).toEqual([2]);
expect(assignments['10'].map(a => a.id)).toEqual([200]);
});
});
@@ -0,0 +1,92 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { TREK_WS_EVENT_NAMES, TREK_WS_TRIP_EVENT_NAMES } from '@trek/shared';
import { DEXIE_WRITERS, STATE_APPLIERS } from '../../../src/store/slices/remoteEventHandler';
import { HANDLED_OUTSIDE_TRIP_STORE, IGNORED_WS_EVENTS } from '../../../src/api/wsEventPolicy';
import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores } from '../../helpers/store';
import { buildPackingItem, buildPlace } from '../../helpers/factories';
/**
* The exhaustiveness ratchet (roadmap: WS event contract registry). Every
* event in the shared registry must be classified on the client — applied by
* the tripStore reducer, consumed by a dedicated listener, or explicitly
* ignored. A server-side event added without a client decision turns this
* test red instead of vanishing into the old silent `default:` branches.
*/
const handledInStore = new Set<string>([
...Object.keys(STATE_APPLIERS),
...Object.keys(DEXIE_WRITERS),
]);
const handledElsewhere = new Set<string>(HANDLED_OUTSIDE_TRIP_STORE);
const ignored = new Set<string>(IGNORED_WS_EVENTS);
beforeEach(() => {
resetAllStores();
});
describe('remoteEventHandler > registry parity', () => {
it('FE-WSEVT-REGISTRY-001: every registry event is handled or explicitly ignored', () => {
const unclassified = TREK_WS_EVENT_NAMES.filter(
name => !handledInStore.has(name) && !handledElsewhere.has(name) && !ignored.has(name),
);
expect(
unclassified,
`New WS event(s) with no client decision: ${unclassified.join(', ')}. ` +
'Handle them (remoteEventHandler / a dedicated listener) or add them to ' +
'IGNORED_WS_EVENTS in src/api/wsEventPolicy.ts as a deliberate choice.',
).toEqual([]);
});
it('FE-WSEVT-REGISTRY-002: the three buckets are disjoint (exactly one decision per event)', () => {
const doubleClassified = TREK_WS_EVENT_NAMES.filter(name => {
const buckets =
Number(handledInStore.has(name)) + Number(handledElsewhere.has(name)) + Number(ignored.has(name));
return buckets > 1;
});
expect(
doubleClassified,
`Event(s) classified in more than one bucket: ${doubleClassified.join(', ')}`,
).toEqual([]);
});
it('FE-WSEVT-REGISTRY-003: no stale names — every classified event still exists in the registry', () => {
const registry = new Set<string>(TREK_WS_EVENT_NAMES);
const stale = [...handledInStore, ...handledElsewhere, ...ignored].filter(
name => !registry.has(name),
);
expect(stale, `Name(s) not in the shared registry (renamed or removed?): ${stale.join(', ')}`).toEqual([]);
});
it('FE-WSEVT-REGISTRY-004: the tripStore lookups only handle trip-scoped events', () => {
const tripScoped = new Set<string>(TREK_WS_TRIP_EVENT_NAMES);
const misScoped = [...handledInStore].filter(name => !tripScoped.has(name));
expect(misScoped, `User-scoped event(s) in the tripStore lookups: ${misScoped.join(', ')}`).toEqual([]);
});
it('FE-WSEVT-REGISTRY-005: every Dexie write-through event also has a state applier', () => {
// writeToDexie reads the post-set() Zustand state, so a Dexie writer for an
// event the reducer ignores would persist stale state.
const writersWithoutApplier = Object.keys(DEXIE_WRITERS).filter(
name => !(name in STATE_APPLIERS),
);
expect(writersWithoutApplier).toEqual([]);
});
it('FE-WSEVT-REGISTRY-006: an explicitly ignored event leaves the store untouched', () => {
const items = [buildPackingItem({ id: 1 }), buildPackingItem({ id: 2 })];
useTripStore.setState({ packingItems: items, places: [buildPlace({ id: 5 })] });
// 'packing:reordered' is on IGNORED_WS_EVENTS — the reducer must swallow it
// instead of throwing or clearing the slice.
useTripStore.getState().handleRemoteEvent({ type: 'packing:reordered', orderedIds: [2, 1] });
const state = useTripStore.getState();
expect(state.packingItems.map(i => i.id)).toEqual([1, 2]);
expect(state.places.map(p => p.id)).toEqual([5]);
});
it('FE-WSEVT-REGISTRY-007: an unknown event name is a no-op', () => {
useTripStore.setState({ places: [buildPlace({ id: 5 })] });
useTripStore.getState().handleRemoteEvent({ type: 'not:a:real:event', payload: 1 });
expect(useTripStore.getState().places.map(p => p.id)).toEqual([5]);
});
});
@@ -0,0 +1,94 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useTripStore } from '../../../src/store/tripStore';
import { resetAllStores } from '../../helpers/store';
import { buildReservation, buildTrip } from '../../helpers/factories';
import { isRoutableReservation } from '../../../src/utils/reservationRoutes';
/**
* The reservation event that carries no reservation (#1979).
*
* `reservation:created` and `reservation:updated` come in two shapes: normally
* they carry the row, but the accommodation cascade sends them empty as a
* "something changed, go and look" ping, which the event contract allows.
*
* Reading the id off the empty one is how a trip broke for good. With no
* reservations loaded yet, `.some()` never ran its callback, so the cast slid
* through and the store became `[undefined]`. Every later render that walked
* that list threw into the route error boundary, and because the store is
* rebuilt from the same events on the next visit, the trip stayed unreachable.
* The reporter could only escape it by clearing localStorage.
*
* So the cases below are about the empty shape specifically: it must change
* nothing, it must not leave a hole in the list, and it must still cause the
* new booking to appear.
*/
beforeEach(() => {
resetAllStores();
});
const send = (type: string, payload: Record<string, unknown> = {}) =>
useTripStore.getState().handleRemoteEvent({ type, ...payload } as never);
describe('a reservation event with no reservation on it', () => {
it('leaves an empty list empty, rather than putting a hole in it', () => {
useTripStore.setState({ reservations: [] });
send('reservation:created');
// Before the fix this was [undefined], and every render after it threw.
expect(useTripStore.getState().reservations).toEqual([]);
});
it('leaves a loaded list untouched', () => {
const existing = buildReservation({ id: 5 });
useTripStore.setState({ reservations: [existing] });
send('reservation:updated');
expect(useTripStore.getState().reservations).toEqual([existing]);
});
it('still fetches the list, so a cascaded booking appears without a reload', () => {
const loadReservations = vi.fn();
useTripStore.setState({
trip: buildTrip({ id: 42 }),
reservations: [],
loadReservations,
} as never);
send('reservation:created');
expect(loadReservations).toHaveBeenCalledWith(42);
});
it('does not fetch when the event carries its reservation', () => {
const loadReservations = vi.fn();
useTripStore.setState({
trip: buildTrip({ id: 42 }),
reservations: [],
loadReservations,
} as never);
send('reservation:created', { reservation: buildReservation({ id: 9 }) });
expect(loadReservations).not.toHaveBeenCalled();
expect(useTripStore.getState().reservations).toHaveLength(1);
});
});
/*
* The second half of the same crash: the predicate that walks the list is
* handed straight to Array.filter, so it has to survive whatever is in there.
* It guarded the property but not the element.
*/
describe('the routable-reservation predicate', () => {
it('says no to nothing at all, rather than throwing', () => {
expect(isRoutableReservation(undefined)).toBe(false);
expect(isRoutableReservation(null)).toBe(false);
});
it('survives a list with a hole in it, which is the reported stack', () => {
const list = [buildReservation({ id: 1, endpoints: [] }), undefined, buildReservation({ id: 2 })];
expect(() => list.filter(isRoutableReservation)).not.toThrow();
});
it('still answers the ordinary questions the same way', () => {
expect(isRoutableReservation(buildReservation({ id: 1, endpoints: [] }))).toBe(false);
expect(isRoutableReservation({ endpoints: [{ id: 1 }, { id: 2 }] } as never)).toBe(true);
});
});
@@ -58,4 +58,43 @@ describe('remoteEventHandler > reservations', () => {
expect(reservations[1].id).toBe(2);
expect(reservations[2].id).toBe(1);
});
it('FE-WSEVT-RESERV-006: reservation:updated leaves the other reservations untouched', () => {
useTripStore.setState({
reservations: [buildReservation({ id: 1, title: 'Hotel' }), buildReservation({ id: 2, title: 'Flight' })],
});
useTripStore.getState().handleRemoteEvent({
type: 'reservation:updated',
reservation: buildReservation({ id: 2, title: 'Flight (rebooked)' }),
});
const { reservations } = useTripStore.getState();
expect(reservations[0].title).toBe('Hotel');
expect(reservations[1].title).toBe('Flight (rebooked)');
});
// Traveler assignment (#1517) arrives as its own event carrying only the list.
it('FE-WSEVT-RESERV-007: reservation:travelers-updated replaces travelers on the addressed booking', () => {
useTripStore.setState({
reservations: [buildReservation({ id: 1 }), buildReservation({ id: 2 })],
});
const travelers = [{ user_id: 3, username: 'ada' }, { user_id: 4, username: 'grace' }];
useTripStore.getState().handleRemoteEvent({
type: 'reservation:travelers-updated',
reservationId: 2,
travelers,
});
const { reservations } = useTripStore.getState();
expect(reservations[1].travelers).toEqual(travelers);
expect(reservations[0].travelers).toBeUndefined();
});
it('FE-WSEVT-RESERV-008: reservation:travelers-updated for an unknown booking changes nothing', () => {
useTripStore.setState({ reservations: [buildReservation({ id: 1 })] });
useTripStore.getState().handleRemoteEvent({
type: 'reservation:travelers-updated',
reservationId: 999,
travelers: [{ user_id: 3, username: 'ada' }],
});
expect(useTripStore.getState().reservations[0].travelers).toBeUndefined();
});
});
@@ -46,4 +46,15 @@ describe('remoteEventHandler > todo', () => {
const { todoItems } = useTripStore.getState();
expect(todoItems).toHaveLength(0);
});
it('FE-WSEVT-TODO-005: todo:updated leaves the other items untouched', () => {
useTripStore.setState({
todoItems: [buildTodoItem({ id: 1, name: 'Book flights' }), buildTodoItem({ id: 2, name: 'Renew passport' })],
});
useTripStore.getState().handleRemoteEvent({
type: 'todo:updated',
item: buildTodoItem({ id: 2, name: 'Renew passport (done)' }),
});
expect(useTripStore.getState().todoItems.map(i => i.name)).toEqual(['Book flights', 'Renew passport (done)']);
});
});