mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
@@ -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)']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user