mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
feat(client): open views follow member, settings, tag, atlas and journey changes made elsewhere
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { offlineDb } from '../../../src/db/offlineDb';
|
||||
import { useAuthStore } from '../../../src/store/authStore';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import {
|
||||
buildAssignment,
|
||||
buildBudgetItem,
|
||||
buildDay,
|
||||
buildPackingItem,
|
||||
buildReservation,
|
||||
buildTodoItem,
|
||||
buildTrip,
|
||||
} from '../../helpers/factories';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
|
||||
/**
|
||||
* The trip-scoped events the broadcast parity work taught the store (plan 2.8):
|
||||
* changes written elsewhere (a fellow traveller, the assistant, an MCP client)
|
||||
* that used to be dropped and only showed after a reload.
|
||||
*/
|
||||
|
||||
const ME = 7;
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
useAuthStore.setState({ user: { id: ME, username: 'me', email: 'me@example.test' } as never });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const send = (event: Record<string, unknown>) => useTripStore.getState().handleRemoteEvent(event as { type: string });
|
||||
|
||||
describe('remoteEventHandler > broadcast parity', () => {
|
||||
it('FE-BCAST-001: assignment:participants sets who joins the visit and caches its day', async () => {
|
||||
const put = vi.spyOn(offlineDb.days, 'put');
|
||||
useTripStore.setState({
|
||||
days: [buildDay({ id: 10 }), buildDay({ id: 20 })],
|
||||
assignments: {
|
||||
'10': [buildAssignment({ id: 100, day_id: 10 })],
|
||||
'20': [buildAssignment({ id: 200, day_id: 20 }), buildAssignment({ id: 201, day_id: 20 })],
|
||||
},
|
||||
});
|
||||
const participants = [{ user_id: 3, username: 'ana', avatar: null }];
|
||||
send({ type: 'assignment:participants', assignmentId: 201, participants });
|
||||
const { assignments } = useTripStore.getState();
|
||||
expect(assignments['20'].find((a) => a.id === 201)?.participants).toEqual(participants);
|
||||
expect(assignments['20'].find((a) => a.id === 200)?.participants).not.toEqual(participants);
|
||||
expect(assignments['10'][0].participants).not.toEqual(participants);
|
||||
await vi.waitFor(() => expect(put).toHaveBeenCalledTimes(1));
|
||||
expect(put.mock.calls[0][0]).toMatchObject({ id: 20 });
|
||||
});
|
||||
|
||||
it('FE-BCAST-002: assignment:participants for an assignment this tab does not hold changes nothing', () => {
|
||||
const before = { '10': [buildAssignment({ id: 100, day_id: 10 })] };
|
||||
useTripStore.setState({ days: [buildDay({ id: 10 })], assignments: before });
|
||||
send({ type: 'assignment:participants', assignmentId: 999, participants: [] });
|
||||
expect(useTripStore.getState().assignments).toBe(before);
|
||||
});
|
||||
|
||||
it('FE-BCAST-003: packing:reordered numbers the named rows as the server does and caches them', async () => {
|
||||
const bulkPut = vi.spyOn(offlineDb.packingItems, 'bulkPut');
|
||||
useTripStore.setState({
|
||||
packingItems: [
|
||||
buildPackingItem({ id: 1, sort_order: 0 }),
|
||||
buildPackingItem({ id: 2, sort_order: 1 }),
|
||||
buildPackingItem({ id: 3, sort_order: 2 }),
|
||||
],
|
||||
});
|
||||
// 404 is a row this tab does not hold (somebody's private item): it still takes
|
||||
// index 1 on the server, so item 1 lands on 2. Item 2 is not named and keeps its order.
|
||||
send({ type: 'packing:reordered', orderedIds: [3, 404, 1] });
|
||||
const items = useTripStore.getState().packingItems;
|
||||
expect(items.map((i) => [i.id, i.sort_order])).toEqual([
|
||||
[3, 0],
|
||||
[1, 2],
|
||||
[2, 1],
|
||||
]);
|
||||
await vi.waitFor(() => expect(bulkPut).toHaveBeenCalledTimes(1));
|
||||
expect(bulkPut.mock.calls[0][0]).toEqual(items);
|
||||
});
|
||||
|
||||
it('FE-BCAST-004: todo:reordered applies the order the same way and caches it', async () => {
|
||||
const bulkPut = vi.spyOn(offlineDb.todoItems, 'bulkPut');
|
||||
useTripStore.setState({
|
||||
todoItems: [buildTodoItem({ id: 1, sort_order: 0 }), buildTodoItem({ id: 2, sort_order: 1 })],
|
||||
});
|
||||
send({ type: 'todo:reordered', orderedIds: [2, 1] });
|
||||
const items = useTripStore.getState().todoItems;
|
||||
expect(items.map((i) => [i.id, i.sort_order])).toEqual([
|
||||
[2, 0],
|
||||
[1, 1],
|
||||
]);
|
||||
await vi.waitFor(() => expect(bulkPut).toHaveBeenCalledTimes(1));
|
||||
expect(bulkPut.mock.calls[0][0]).toEqual(items);
|
||||
});
|
||||
|
||||
it('FE-BCAST-005: a reorder without ids leaves the list alone', () => {
|
||||
const packingItems = [buildPackingItem({ id: 1 })];
|
||||
useTripStore.setState({ packingItems });
|
||||
send({ type: 'packing:reordered' });
|
||||
expect(useTripStore.getState().packingItems).toBe(packingItems);
|
||||
});
|
||||
|
||||
it('FE-BCAST-006: trip:deleted of the open trip marks it gone, another trip does not', async () => {
|
||||
const drop = vi.spyOn(offlineDb.trips, 'delete');
|
||||
useTripStore.setState({ trip: buildTrip({ id: 5 }) });
|
||||
send({ type: 'trip:deleted', tripId: 6, id: 6 });
|
||||
expect(useTripStore.getState().tripGone).toBeNull();
|
||||
send({ type: 'trip:deleted', tripId: 5, id: 5 });
|
||||
expect(useTripStore.getState().tripGone).toEqual({ tripId: 5, reason: 'deleted' });
|
||||
await vi.waitFor(() => expect(drop).toHaveBeenCalledWith(5));
|
||||
});
|
||||
|
||||
it('FE-BCAST-007: member:removed marks the trip gone only when it is this user', () => {
|
||||
useTripStore.setState({ trip: buildTrip({ id: 5 }) });
|
||||
send({ type: 'member:removed', tripId: 5, userId: 99 });
|
||||
expect(useTripStore.getState().tripGone).toBeNull();
|
||||
send({ type: 'member:removed', tripId: 5, userId: ME });
|
||||
expect(useTripStore.getState().tripGone).toEqual({ tripId: 5, reason: 'removed' });
|
||||
});
|
||||
|
||||
it('FE-BCAST-025: member:updated renames the guest on every row that lists it and caches those rows', async () => {
|
||||
const putBudget = vi.spyOn(offlineDb.budgetItems, 'bulkPut');
|
||||
const putDay = vi.spyOn(offlineDb.days, 'put');
|
||||
const guest = { user_id: 3, username: 'Ana' };
|
||||
const other = { user_id: 4, username: 'Bo' };
|
||||
const untouched = buildBudgetItem({ id: 61, members: [{ ...other, paid: 0 }] });
|
||||
useTripStore.setState({
|
||||
days: [buildDay({ id: 10 })],
|
||||
budgetItems: [buildBudgetItem({ id: 60, members: [{ ...guest, paid: 0 }, { ...other, paid: 1 }] }), untouched],
|
||||
reservations: [buildReservation({ id: 70, travelers: [guest] })],
|
||||
packingItems: [buildPackingItem({ id: 80, recipients: [guest], contributors: [{ ...guest, status: 'packed' }] })],
|
||||
assignments: { '10': [buildAssignment({ id: 100, day_id: 10, participants: [guest] })] },
|
||||
});
|
||||
send({ type: 'member:updated', tripId: 5, member: { id: 3, username: 'Anna' } });
|
||||
const state = useTripStore.getState();
|
||||
expect(state.budgetItems[0].members?.map((m) => m.username)).toEqual(['Anna', 'Bo']);
|
||||
expect(state.budgetItems[1]).toBe(untouched);
|
||||
expect(state.reservations[0].travelers?.[0].username).toBe('Anna');
|
||||
expect(state.packingItems[0].recipients?.[0].username).toBe('Anna');
|
||||
expect(state.packingItems[0].contributors?.[0]).toEqual({ user_id: 3, username: 'Anna', status: 'packed' });
|
||||
expect(state.assignments['10'][0].participants?.[0].username).toBe('Anna');
|
||||
await vi.waitFor(() => expect(putDay).toHaveBeenCalledTimes(1));
|
||||
expect(putBudget.mock.calls[0][0]).toEqual([state.budgetItems[0]]);
|
||||
});
|
||||
|
||||
it('FE-BCAST-026: only this user's removal drops the open trip from the offline cache', async () => {
|
||||
const drop = vi.spyOn(offlineDb.trips, 'delete');
|
||||
useTripStore.setState({ trip: buildTrip({ id: 5 }) });
|
||||
send({ type: 'member:removed', tripId: 5, userId: 99 });
|
||||
send({ type: 'member:removed', tripId: 5, userId: ME });
|
||||
await vi.waitFor(() => expect(drop).toHaveBeenCalledWith(5));
|
||||
expect(drop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-BCAST-008: loading another trip forgets a trip that went away', () => {
|
||||
useTripStore.setState({ tripGone: { tripId: 5, reason: 'removed' } });
|
||||
useTripStore.getState().resetTrip();
|
||||
expect(useTripStore.getState().tripGone).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-BCAST-009: a burst of packing:template-applied reads the list once, after it settles', () => {
|
||||
vi.useFakeTimers();
|
||||
const reload = vi.fn(async () => undefined);
|
||||
useTripStore.setState({ trip: buildTrip({ id: 5 }), reloadPackingItems: reload });
|
||||
for (let i = 0; i < 5; i++) send({ type: 'packing:template-applied', tripId: 5, items: [] });
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(300);
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
expect(reload).toHaveBeenCalledWith(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { journeyApi, packingApi } from '../../../src/api/client';
|
||||
import { collectionsApi } from '../../../src/api/collections';
|
||||
import { addListener, removeListener } from '../../../src/api/websocket';
|
||||
import { useSaveToCollection } from '../../../src/components/Collections/useSaveToCollection';
|
||||
import { useBagTotalsPing } from '../../../src/components/Packing/useBagTotalsPing';
|
||||
import { usePackingCategoryAssignees } from '../../../src/components/Packing/usePackingCategoryAssignees';
|
||||
import { useInAppNotificationListener } from '../../../src/hooks/useInAppNotificationListener';
|
||||
import { useJourney } from '../../../src/pages/journey/useJourney';
|
||||
import { useTripRosterSync } from '../../../src/pages/tripPlanner/useTripRosterSync';
|
||||
import { useAuthStore } from '../../../src/store/authStore';
|
||||
import { useInAppNotificationStore, type InAppNotification } from '../../../src/store/inAppNotificationStore';
|
||||
import { useJourneyStore } from '../../../src/store/journeyStore';
|
||||
import { useSaveToCollectionStore } from '../../../src/store/saveToCollectionStore';
|
||||
import { useSettingsStore } from '../../../src/store/settingsStore';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
|
||||
/**
|
||||
* The views that read their data again after a change written elsewhere (plan
|
||||
* 2.8): every one folds a burst into one read, because one assistant step can
|
||||
* apply up to fifty changes, each its own event.
|
||||
*/
|
||||
|
||||
const nav = vi.hoisted(() => ({ navigate: vi.fn() }));
|
||||
const toast = vi.hoisted(() => ({ info: vi.fn(), error: vi.fn(), success: vi.fn(), warning: vi.fn() }));
|
||||
|
||||
vi.mock('react-router', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('react-router')>()),
|
||||
useNavigate: () => nav.navigate,
|
||||
}));
|
||||
vi.mock('../../../src/components/shared/Toast', () => ({ useToast: () => toast }));
|
||||
|
||||
/** A bell row as the notification store holds it. */
|
||||
function note(id: number, isRead: boolean): InAppNotification {
|
||||
return {
|
||||
id,
|
||||
type: 'simple',
|
||||
scope: 'trip',
|
||||
target: 5,
|
||||
sender_id: null,
|
||||
sender_username: null,
|
||||
sender_avatar: null,
|
||||
recipient_id: 7,
|
||||
title_key: 'notif.title',
|
||||
title_params: {},
|
||||
text_key: 'notif.text',
|
||||
text_params: {},
|
||||
positive_text_key: null,
|
||||
negative_text_key: null,
|
||||
response: null,
|
||||
navigate_text_key: null,
|
||||
navigate_target: null,
|
||||
is_read: isRead,
|
||||
created_at: '2026-10-01T09:00:00Z',
|
||||
};
|
||||
}
|
||||
|
||||
/** The same row as the server sends it, read state as a number. */
|
||||
const wire = (id: number, isRead: boolean) => ({ ...note(id, isRead), is_read: Number(isRead) }) as never;
|
||||
|
||||
/** Deliver an event to every listener still registered, the way the socket layer does. */
|
||||
function emit(event: Record<string, unknown>): void {
|
||||
const removed = new Set(vi.mocked(removeListener).mock.calls.map(([fn]) => fn));
|
||||
for (const [fn] of vi.mocked(addListener).mock.calls) if (!removed.has(fn)) fn(event);
|
||||
}
|
||||
|
||||
/** Past the 250ms window useServerPing collects a burst in. */
|
||||
async function settle(): Promise<void> {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.mocked(addListener).mockClear();
|
||||
vi.mocked(removeListener).mockClear();
|
||||
nav.navigate.mockClear();
|
||||
toast.info.mockClear();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('broadcast reloads', () => {
|
||||
it('FE-BCAST-010: the bag events and the totals ping of one burst are one read of the bags', async () => {
|
||||
const reload = vi.fn(async () => {});
|
||||
renderHook(() => useBagTotalsPing(true, reload));
|
||||
act(() => {
|
||||
emit({ type: 'packing:bag-created', bag: { id: 1 } });
|
||||
emit({ type: 'packing:bag-members-updated', bagId: 1, members: [] });
|
||||
emit({ type: 'packing:bag-updated', bag: { id: 1 } });
|
||||
emit({ type: 'packing:bag-totals' });
|
||||
emit({ type: 'packing:bag-deleted', bagId: 2 });
|
||||
emit({ type: 'packing:created', item: { id: 9 } });
|
||||
});
|
||||
await settle();
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-BCAST-011: category assignees set elsewhere are read again', async () => {
|
||||
const get = vi
|
||||
.spyOn(packingApi, 'getCategoryAssignees')
|
||||
.mockResolvedValueOnce({ assignees: {} })
|
||||
.mockResolvedValueOnce({ assignees: { Camp: [{ user_id: 3, username: 'anna' }] } });
|
||||
const { result } = renderHook(() =>
|
||||
usePackingCategoryAssignees({ tripId: 7, t: (k: string) => k, toast: { error: vi.fn() } })
|
||||
);
|
||||
await settle();
|
||||
act(() => {
|
||||
emit({ type: 'packing:assignees', category: 'Camp', assignees: [] });
|
||||
emit({ type: 'packing:assignees', category: 'Food', assignees: [] });
|
||||
});
|
||||
await settle();
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.categoryAssignees).toEqual({ Camp: [{ user_id: 3, username: 'anna' }] });
|
||||
});
|
||||
|
||||
it('FE-BCAST-012: a notification read or unread elsewhere moves the unread count', () => {
|
||||
const fetchUnreadCount = vi.fn(async () => {});
|
||||
useInAppNotificationStore.setState({
|
||||
notifications: [note(1, false), note(2, true)],
|
||||
unreadCount: 1,
|
||||
fetchUnreadCount,
|
||||
});
|
||||
const store = useInAppNotificationStore.getState();
|
||||
store.handleUpdatedNotification(wire(1, true));
|
||||
expect(useInAppNotificationStore.getState().unreadCount).toBe(0);
|
||||
store.handleUpdatedNotification(wire(2, false));
|
||||
store.handleUpdatedNotification(wire(1, false));
|
||||
expect(useInAppNotificationStore.getState().unreadCount).toBe(2);
|
||||
// One this tab never loaded says nothing about its old state: the count is asked for.
|
||||
store.handleUpdatedNotification(wire(77, true));
|
||||
expect(fetchUnreadCount).toHaveBeenCalledTimes(1);
|
||||
expect(useInAppNotificationStore.getState().unreadCount).toBe(2);
|
||||
});
|
||||
|
||||
it('FE-BCAST-013: notification:read-all clears the bell, settings and tags are read again once per burst', async () => {
|
||||
const loadSettings = vi.fn(async () => {});
|
||||
const reloadTags = vi.fn(async () => {});
|
||||
useAuthStore.setState({ isAuthenticated: true });
|
||||
useSettingsStore.setState({ loadSettings });
|
||||
useTripStore.setState({ reloadTags });
|
||||
useInAppNotificationStore.setState({
|
||||
notifications: [note(1, false), note(2, false)],
|
||||
unreadCount: 2,
|
||||
});
|
||||
renderHook(() => useInAppNotificationListener());
|
||||
act(() => {
|
||||
emit({ type: 'notification:read-all' });
|
||||
emit({ type: 'settings:updated', keys: ['dark_mode'] });
|
||||
emit({ type: 'settings:updated', keys: ['week_start'] });
|
||||
emit({ type: 'tags:changed' });
|
||||
emit({ type: 'tags:changed' });
|
||||
});
|
||||
const bell = useInAppNotificationStore.getState();
|
||||
expect(bell.unreadCount).toBe(0);
|
||||
expect(bell.notifications.every((n) => n.is_read)).toBe(true);
|
||||
await settle();
|
||||
expect(loadSettings).toHaveBeenCalledTimes(1);
|
||||
expect(reloadTags).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-BCAST-014: member events read the roster again, once per burst', async () => {
|
||||
const refresh = vi.fn(async () => {});
|
||||
renderHook(() => useTripRosterSync(5, refresh));
|
||||
act(() => {
|
||||
emit({ type: 'member:added', tripId: 5, member: { id: 3 } });
|
||||
emit({ type: 'member:updated', tripId: 5, member: { id: 3, username: 'Ana' } });
|
||||
emit({ type: 'member:removed', tripId: 5, userId: 3 });
|
||||
});
|
||||
await settle();
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-BCAST-015: a trip that went away is left for the trip list with a notice', () => {
|
||||
renderHook(() => useTripRosterSync(5, async () => {}));
|
||||
act(() => useTripStore.setState({ tripGone: { tripId: 6, reason: 'deleted' } }));
|
||||
expect(nav.navigate).not.toHaveBeenCalled();
|
||||
act(() => useTripStore.setState({ tripGone: { tripId: 5, reason: 'removed' } }));
|
||||
expect(toast.info).toHaveBeenCalledWith('trip.toast.accessLost');
|
||||
expect(nav.navigate).toHaveBeenCalledWith('/dashboard', { replace: true });
|
||||
expect(useTripStore.getState().tripGone).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-BCAST-016: a trip deleted elsewhere says so', () => {
|
||||
renderHook(() => useTripRosterSync(5, async () => {}));
|
||||
act(() => useTripStore.setState({ tripGone: { tripId: 5, reason: 'deleted' } }));
|
||||
expect(toast.info).toHaveBeenCalledWith('dashboard.toast.deleted');
|
||||
expect(nav.navigate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-BCAST-019: a place saved elsewhere re-checks the membership and the inspector bookmark once', async () => {
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue({ collections: [], incomingInvites: [] } as never);
|
||||
const membership = vi.spyOn(collectionsApi, 'membership').mockResolvedValue({ saved: false, lists: [] } as never);
|
||||
useSaveToCollectionStore.getState().open({ name: 'Louvre', lat: 48.86, lng: 2.34 });
|
||||
renderHook(() => useSaveToCollection());
|
||||
await settle();
|
||||
membership.mockClear();
|
||||
const version = useSaveToCollectionStore.getState().version;
|
||||
act(() => {
|
||||
for (let i = 0; i < 3; i++) emit({ type: 'collections:updated', collectionId: 4 });
|
||||
emit({ type: 'collections:invite', collectionId: 5, from: { id: 2, username: 'bo' } });
|
||||
});
|
||||
await settle();
|
||||
expect(membership).toHaveBeenCalledTimes(1);
|
||||
expect(useSaveToCollectionStore.getState().version).toBe(version + 1);
|
||||
});
|
||||
|
||||
it('FE-BCAST-021: the journey list reads itself again after journeys change elsewhere, once per burst', async () => {
|
||||
vi.spyOn(journeyApi, 'suggestions').mockResolvedValue({ trips: [] } as never);
|
||||
const loadJourneys = vi.fn(async () => {});
|
||||
useJourneyStore.setState({ loadJourneys });
|
||||
renderHook(() => useJourney(), { wrapper: MemoryRouter });
|
||||
await settle();
|
||||
loadJourneys.mockClear();
|
||||
act(() => {
|
||||
emit({ type: 'journey:updated', journeyId: 3 });
|
||||
emit({ type: 'journey:deleted', journeyId: 4 });
|
||||
emit({ type: 'journey:entry:created', journeyId: 3, entry: { id: 8 } });
|
||||
});
|
||||
await settle();
|
||||
expect(loadJourneys).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-BCAST-029: a contributor taken off a journey elsewhere reads the journey list again', async () => {
|
||||
vi.spyOn(journeyApi, 'suggestions').mockResolvedValue({ trips: [] } as never);
|
||||
const loadJourneys = vi.fn(async () => {});
|
||||
useJourneyStore.setState({ loadJourneys });
|
||||
renderHook(() => useJourney(), { wrapper: MemoryRouter });
|
||||
await settle();
|
||||
loadJourneys.mockClear();
|
||||
act(() => emit({ type: 'journey:contributor:changed', journeyId: 3, targetUserId: 2, role: null }));
|
||||
await settle();
|
||||
expect(loadJourneys).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-BCAST-023: opening a trip (a room join) re-reads trip data, never the account-wide state', async () => {
|
||||
const loadSettings = vi.fn(async () => {});
|
||||
const reloadTags = vi.fn(async () => {});
|
||||
const refresh = vi.fn(async () => {});
|
||||
useAuthStore.setState({ isAuthenticated: true });
|
||||
useSettingsStore.setState({ loadSettings });
|
||||
useTripStore.setState({ reloadTags });
|
||||
renderHook(() => useInAppNotificationListener());
|
||||
renderHook(() => useSaveToCollection());
|
||||
renderHook(() => useTripRosterSync(5, refresh));
|
||||
const version = useSaveToCollectionStore.getState().version;
|
||||
act(() => emit({ type: 'joined', tripId: 5 }));
|
||||
await settle();
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
expect(loadSettings).not.toHaveBeenCalled();
|
||||
expect(reloadTags).not.toHaveBeenCalled();
|
||||
expect(useSaveToCollectionStore.getState().version).toBe(version);
|
||||
});
|
||||
|
||||
it('FE-BCAST-028: accepting an invite to a list elsewhere re-checks the bookmark too', async () => {
|
||||
renderHook(() => useSaveToCollection());
|
||||
const version = useSaveToCollectionStore.getState().version;
|
||||
act(() => emit({ type: 'collections:accepted', collectionId: 4 }));
|
||||
await settle();
|
||||
expect(useSaveToCollectionStore.getState().version).toBe(version + 1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
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 { DEXIE_WRITERS, STATE_APPLIERS, STORE_REFRESHES } from '../../../src/store/slices/remoteEventHandler';
|
||||
import { AWAITING_SENDER_WS_EVENTS, HANDLED_OUTSIDE_TRIP_STORE, IGNORED_WS_EVENTS } from '../../../src/api/wsEventPolicy';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
@@ -10,16 +10,18 @@ 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, explicitly
|
||||
* ignored, or registered ahead of the write that will send it. A server-side event added without a client decision turns this
|
||||
* test red instead of vanishing into the old silent `default:` branches.
|
||||
* ignored with a reason, or registered ahead of the write that will send it.
|
||||
* 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),
|
||||
...Object.keys(STORE_REFRESHES),
|
||||
]);
|
||||
const handledElsewhere = new Set<string>(HANDLED_OUTSIDE_TRIP_STORE);
|
||||
const ignored = new Set<string>(IGNORED_WS_EVENTS);
|
||||
const ignored = new Set<string>(Object.keys(IGNORED_WS_EVENTS));
|
||||
const awaitingSender = new Set<string>(AWAITING_SENDER_WS_EVENTS);
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -80,9 +82,9 @@ describe('remoteEventHandler > registry parity', () => {
|
||||
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] });
|
||||
// 'todo:assignees' is on IGNORED_WS_EVENTS: the reducer must swallow it
|
||||
// instead of throwing or clearing a slice.
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'todo:assignees', category: 'Docs', assignees: [] });
|
||||
const state = useTripStore.getState();
|
||||
expect(state.packingItems.map(i => i.id)).toEqual([1, 2]);
|
||||
expect(state.places.map(p => p.id)).toEqual([5]);
|
||||
@@ -95,17 +97,17 @@ describe('remoteEventHandler > registry parity', () => {
|
||||
});
|
||||
|
||||
it('FE-WSEVT-REGISTRY-008: the events awaiting a sender only shrink', () => {
|
||||
// Registered by the broadcast parity work ahead of their writes. A new event
|
||||
// gets a handler or a deliberate IGNORED_WS_EVENTS slot, never a place here.
|
||||
const registeredAhead = [
|
||||
'member:updated',
|
||||
'todo:reordered',
|
||||
'trips:changed',
|
||||
'settings:updated',
|
||||
'tags:changed',
|
||||
'notification:read-all',
|
||||
'atlas:changed',
|
||||
];
|
||||
expect(AWAITING_SENDER_WS_EVENTS.filter(name => !registeredAhead.includes(name))).toEqual([]);
|
||||
// Every event registered ahead of its write by the broadcast parity work has
|
||||
// a handler now. A new event gets a handler or a deliberate IGNORED_WS_EVENTS
|
||||
// slot, never a place here.
|
||||
expect(AWAITING_SENDER_WS_EVENTS).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-REGISTRY-009: the ignored events only shrink, and each says why', () => {
|
||||
// Only the todo category assignees stay ignored: no screen shows them. A key
|
||||
// added here is a product decision, so this list is edited by hand with it.
|
||||
const allowed = ['todo:assignees'];
|
||||
expect(Object.keys(IGNORED_WS_EVENTS).filter(name => !allowed.includes(name))).toEqual([]);
|
||||
for (const reason of Object.values(IGNORED_WS_EVENTS)) expect(reason?.length ?? 0).toBeGreaterThan(20);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user