mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
68bf5316ad
reservation:created and reservation:updated come in two shapes. Normally they carry the reservation; the accommodation cascade sends them empty, as a "something changed, go and look" ping that the event contract allows for. The applier read the id off both. With no reservations loaded yet the existence check never ran its callback, so the cast slipped through and the store became [undefined]. From there every render that walked the list threw "Cannot read properties of undefined (reading 'endpoints')" into the route error boundary, and since the store is rebuilt from the same events, the trip stayed unreachable in that browser. The reporter could only get out of it by clearing localStorage. Both appliers now decline an event with nothing on it, and the Dexie writer does too rather than leaning on a swallowed catch. The predicate that walks the list is total as well: it guarded the property but not the element, and it is handed straight to Array.filter over a list it does not own. Declining the empty ping would have left a hotel booked by one member invisible to the others until they reloaded, so the ping is honoured the way day:reordered already honours its own — fetch the authoritative list. The server keeps sending what it sends. Making the cascade carry the full reservation would mean moving the REST, MCP and RPC paths together with their pinned tests, and would still leave the client fragile against the next emitter that does this. Closes #1979
95 lines
3.8 KiB
TypeScript
95 lines
3.8 KiB
TypeScript
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);
|
|
});
|
|
});
|