Files
TREK/client/src/components/Settings/useWandererWatch.test.ts
T
Maurice fbba4fd73b fix(tours): refresh the wanderer watch after a fixed token and truly disable its switch offline
The watch row only re-read its state when the connection came or went, so after
a passing connection test or a newly saved token it kept saying "Token refused"
until a reload. The connection card now hands it a key built from the saved
connection and its last check, and the row re-reads the watch whenever that
changes.

Without a connection the switch was only dimmed: the button stayed in the tab
order and was announced as active. ToggleSwitch gains a disabled prop that
disables the button and marks it aria-disabled, and the reason stays reachable
through a focusable wrapper carrying the tooltip.
2026-10-20 15:35:32 +02:00

149 lines
6.2 KiB
TypeScript

import { act, renderHook, waitFor } from '@testing-library/react';
import type { WandererSourceHintItem, WandererWatch } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useWandererWatch, watchStatusOf } from './useWandererWatch';
const repo = vi.hoisted(() => ({ getWatch: vi.fn(), setWatch: vi.fn() }));
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }));
const navigate = vi.hoisted(() => vi.fn());
vi.mock('../../repo/wandererRepo', () => ({ wandererRepo: repo }));
vi.mock('../shared/Toast', () => ({ useToast: () => toast }));
vi.mock('../../i18n', () => ({
useTranslation: () => ({
t: (key: string, params?: Record<string, unknown>) => (params ? `${key} ${JSON.stringify(params)}` : key),
locale: 'en',
}),
}));
vi.mock('react-router', async () => ({
...(await vi.importActual<typeof import('react-router')>('react-router')),
useNavigate: () => navigate,
}));
const t = (key: string) => key;
const off: WandererWatch = {
enabled: false,
connected: true,
lastRunAt: null,
pausedUntil: null,
blocked: false,
hints: [],
};
const hint: WandererSourceHintItem = {
placeId: 7,
tripId: 3,
tripTitle: 'Alps',
tourName: 'Ridge',
state: 'possible_change',
changes: [],
reason: null,
externalUpdated: '2026-10-14',
checkedAt: '2026-10-20T10:00:00Z',
};
beforeEach(() => {
vi.clearAllMocks();
repo.getWatch.mockResolvedValue(off);
repo.setWatch.mockImplementation(async (enabled: boolean) => ({ ...off, enabled }));
});
describe('watchStatusOf (WAN-02)', () => {
const now = Date.parse('2026-10-20T12:00:00Z');
it('says nothing while the watch is off', () => {
expect(watchStatusOf(null, t, 'en', now)).toBeNull();
expect(watchStatusOf(off, t, 'en', now)).toBeNull();
});
it('names a refused token first, then a pause still running, then the last look', () => {
const on = { ...off, enabled: true };
expect(watchStatusOf({ ...on, blocked: true, pausedUntil: '2026-10-21T00:00:00Z' }, t, 'en', now)).toEqual({
tone: 'danger',
text: 'wanderer.watch.blocked',
why: 'wanderer.watch.blockedHint',
});
expect(watchStatusOf({ ...on, pausedUntil: '2026-10-20T14:00:00Z' }, t, 'en', now)).toMatchObject({
tone: 'warning',
text: 'wanderer.watch.paused',
why: 'wanderer.watch.pausedHint',
});
expect(
watchStatusOf({ ...on, pausedUntil: '2026-10-20T11:00:00Z', lastRunAt: '2026-10-20T10:00:00Z' }, t, 'en', now)
).toEqual({ tone: 'neutral', text: 'wanderer.watch.lastRun', why: null });
expect(watchStatusOf(on, t, 'en', now)).toEqual({ tone: 'neutral', text: 'wanderer.watch.neverRun', why: null });
});
});
describe('useWandererWatch (WAN-02)', () => {
it('loads with the card and again when the connection comes or goes', async () => {
const { result, rerender } = renderHook(({ refreshKey }) => useWandererWatch(refreshKey), {
initialProps: { refreshKey: 'true||' },
});
await waitFor(() => expect(result.current.available).toBe(true));
expect(result.current.enabled).toBe(false);
expect(result.current.status).toBeNull();
rerender({ refreshKey: 'true||' });
expect(repo.getWatch).toHaveBeenCalledTimes(1);
rerender({ refreshKey: 'false||' });
await waitFor(() => expect(repo.getWatch).toHaveBeenCalledTimes(2));
});
it('drops a refused token once a connection test passes, without a reload', async () => {
repo.getWatch.mockResolvedValueOnce({ ...off, enabled: true, blocked: true, pausedUntil: '2999-01-01T00:00:00Z' });
repo.getWatch.mockResolvedValueOnce({ ...off, enabled: true, lastRunAt: '2026-10-20T10:00:00Z' });
const { result, rerender } = renderHook(({ refreshKey }) => useWandererWatch(refreshKey), {
initialProps: { refreshKey: 'true|2026-10-20T09:00:00Z|false' },
});
await waitFor(() => expect(result.current.status?.text).toBe('wanderer.watch.blocked'));
rerender({ refreshKey: 'true|2026-10-20T11:00:00Z|true' });
await waitFor(() => expect(result.current.status?.tone).toBe('neutral'));
expect(result.current.status?.text).toMatch(/^wanderer\.watch\.lastRun/);
});
it('switches at once, and says so either way', async () => {
const { result } = renderHook(() => useWandererWatch('true||'));
await waitFor(() => expect(result.current.available).toBe(true));
await act(async () => {
await result.current.toggle();
});
expect(repo.setWatch).toHaveBeenCalledWith(true);
expect(result.current.enabled).toBe(true);
expect(toast.success).toHaveBeenCalledWith('wanderer.watch.on');
repo.setWatch.mockRejectedValueOnce(new Error('down'));
await act(async () => {
await result.current.toggle();
});
expect(toast.error).toHaveBeenCalledWith('wanderer.watch.failed');
expect(result.current.enabled).toBe(true);
});
it('leaves the switch alone without a connection, or when the watch could not be read', async () => {
repo.getWatch.mockResolvedValueOnce({ ...off, connected: false });
const { result } = renderHook(() => useWandererWatch('false||'));
await waitFor(() => expect(repo.getWatch).toHaveBeenCalled());
await act(async () => {
await result.current.toggle();
});
expect(repo.setWatch).not.toHaveBeenCalled();
repo.getWatch.mockRejectedValueOnce(new Error('offline'));
const failed = renderHook(() => useWandererWatch('true||'));
await waitFor(() => expect(repo.getWatch).toHaveBeenCalledTimes(2));
expect(failed.result.current.available).toBe(false);
});
it('lists the hints and opens the tour one is about', async () => {
repo.getWatch.mockResolvedValue({ ...off, enabled: true, hints: [hint] });
const { result } = renderHook(() => useWandererWatch('true||'));
await waitFor(() => expect(result.current.hints).toEqual([hint]));
act(() => result.current.openHints());
expect(result.current.hintsOpen).toBe(true);
act(() => result.current.openTour(hint));
expect(navigate).toHaveBeenCalledWith('/trips/3?tab=plan&place=7');
expect(result.current.hintsOpen).toBe(false);
act(() => result.current.openHints());
act(() => result.current.closeHints());
expect(result.current.hintsOpen).toBe(false);
});
});