Files
TREK/client/tests/unit/remoteEventHandler/broadcastReloads.test.tsx
T

272 lines
12 KiB
TypeScript

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);
});
});