Files
TREK/client/tests/unit/mobile/dashboard/MDashWidgets.test.tsx
T
Maurice 6c63a9bb60 fix(client): move to react-router 7.18.2 (#1804)
* refactor(client): opt into the v7 router behaviour while still on v6

Turning the behaviour on separately from the version bump keeps the two failure
modes apart: red here means a behaviour change, red on the bump means the import
rewrite. The suite is unchanged either way — 527 files, 11099 tests.

Only the two flags a non-data router understands are set; the rest belong to
createBrowserRouter, which this app doesn't use. Both are applied at all six
router mounts, so the tests exercise the same behaviour as production rather than
validating v6 while prod runs v7.

* fix(client): move to react-router 7.18.2

6.30.4 is affected by GHSA-wrjc-x8rr-h8h6 — open redirect through a backslash in
Link and useNavigate, which this app uses in 41 places — and the 6.x line has no
fix: react-router-dom 6.30.2 to 6.30.4 carries GHSA-jjmj-jmhj-qwj2 with no patched
version at all. Staying is not an option, and 7.0 to 7.17 only swaps it for
GHSA-chx6-hx7r-mcp5.

The package rename comes with it: react-router-dom is a re-export shim in v7, so
using it would only defer the same rewrite to the v8 jump. 77 occurrences across
60 files, all of them import specifiers and vi.mock strings.

The surface is small — useNavigate, useParams, useSearchParams, useLocation,
Routes, Route, Link, Navigate, useMatch, MemoryRouter, BrowserRouter — no data
router, no loaders or actions. Nothing else had to change; the future flags from
the previous commit are simply how v7 behaves, so they come back out.

npm audit still reports GHSA-qwww-vcr4-c8h2 on react-router (RSC mode CSRF, fixed
in 8.3.0). It needs RSC mode, which needs a server; this is a static SPA. Closing
it means the v8 major, which is its own piece of work.
2026-08-06 23:01:58 +02:00

421 lines
17 KiB
TypeScript

import React from 'react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useLocation } from 'react-router';
import { http, HttpResponse } from 'msw';
import { render, screen, fireEvent, waitFor } from '../../../helpers/render';
import { server } from '../../../helpers/msw/server';
import { resetAllStores, seedStore } from '../../../helpers/store';
import { buildSettings } from '../../../helpers/factories';
import {
resolveMobileDashOrder, useMobileDashOrder, useMobileDashVisibility, MobileDashWidget,
} from '../../../../src/mobile/screens/dashboard/MDashWidgets';
import { useSettingsStore } from '../../../../src/store/settingsStore';
import { useAddonStore } from '../../../../src/store/addonStore';
import type { UpcomingReservation } from '../../../../src/pages/dashboard/dashboardModel';
// FE-MOB-DWID-001 onwards
const RATES = [
{ date: '2026-07-01', base: 'EUR', quote: 'USD', rate: 1.1 },
{ date: '2026-07-01', base: 'EUR', quote: 'CHF', rate: 0.95 },
];
function fxHandler(body: unknown = RATES) {
server.use(http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json(body)));
}
function collectionsHandler(collections: unknown[]) {
server.use(http.get('/api/addons/collections', () => HttpResponse.json({ collections })));
}
function LocationEcho() {
const location = useLocation();
return <span data-testid="loc">{location.pathname}</span>;
}
function enableCollectionsAddon(enabled: boolean) {
useAddonStore.setState({
addons: [{ id: 'collections', name: 'Collections', type: 'global', icon: 'bookmark', enabled }],
loaded: true,
} as never);
}
beforeEach(() => {
resetAllStores();
fxHandler();
collectionsHandler([]);
});
afterEach(() => {
vi.useRealTimers();
});
describe('resolveMobileDashOrder', () => {
it('FE-MOB-DWID-001: falls back to the built-in order without a stored one', () => {
expect(resolveMobileDashOrder(undefined)).toEqual([
'trips', 'currency', 'collections', 'timezones', 'upcomingReservations',
]);
});
it('FE-MOB-DWID-002: keeps the stored order and appends the missing blocks', () => {
expect(resolveMobileDashOrder(['timezones', 'trips'])).toEqual([
'timezones', 'trips', 'currency', 'collections', 'upcomingReservations',
]);
});
it('FE-MOB-DWID-003: drops unknown and duplicated tokens', () => {
expect(resolveMobileDashOrder(['currency', 'nope', 'currency', 'trips'])).toEqual([
'currency', 'trips', 'collections', 'timezones', 'upcomingReservations',
]);
});
});
describe('mobile dashboard order + visibility hooks', () => {
it('FE-MOB-DWID-004: useMobileDashOrder reads the appearance blob', () => {
seedStore(useSettingsStore, {
settings: buildSettings({
appearance: { dashboard: { mobileOrder: ['upcomingReservations', 'trips'] } },
} as never),
});
const { result } = renderHook(() => useMobileDashOrder());
expect(result.current[0]).toBe('upcomingReservations');
expect(result.current[1]).toBe('trips');
});
it('FE-MOB-DWID-005: trips is always visible, widgets follow their flags', () => {
enableCollectionsAddon(true);
seedStore(useSettingsStore, {
settings: buildSettings({
appearance: { dashboard: { mobile: { currency: false, collections: true, timezones: true, upcomingReservations: false } } },
} as never),
});
const { result } = renderHook(() => useMobileDashVisibility());
expect(result.current).toEqual({
trips: true,
currency: false,
collections: true,
timezones: true,
upcomingReservations: false,
});
});
it('FE-MOB-DWID-006: collections stays hidden while the addon is off', () => {
enableCollectionsAddon(false);
const { result } = renderHook(() => useMobileDashVisibility());
expect(result.current.collections).toBe(false);
expect(result.current.currency).toBe(true);
});
});
describe('MobileDashWidget', () => {
it('FE-MOB-DWID-007: renders nothing for the trips block', () => {
const { container } = render(<MobileDashWidget id="trips" upcoming={[]} />);
expect(container).toBeEmptyDOMElement();
});
});
describe('MCurrencyWidget', () => {
it('FE-MOB-DWID-008: converts the amount with the fetched rate', async () => {
render(<MobileDashWidget id="currency" upcoming={[]} />);
expect(screen.getByText('Currency')).toBeInTheDocument();
await waitFor(() => expect(screen.getByText('1 EUR = 1.1000 USD')).toBeInTheDocument());
// 100 (default amount) * 1.1
expect(screen.getByText('110.00')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('From'), { target: { value: '250,5' } });
expect(screen.getByText('275.55')).toBeInTheDocument();
});
it('FE-MOB-DWID-009: a non-array response leaves the rate unavailable', async () => {
fxHandler({ error: 'nope' });
render(<MobileDashWidget id="currency" upcoming={[]} />);
await waitFor(() => expect(screen.getByText('Rate unavailable')).toBeInTheDocument());
expect(screen.getByText('—')).toBeInTheDocument();
});
it('FE-MOB-DWID-010: a failed request leaves the rate unavailable', async () => {
server.use(http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.error()));
render(<MobileDashWidget id="currency" upcoming={[]} />);
await waitFor(() => expect(screen.getByText('Rate unavailable')).toBeInTheDocument());
});
it('FE-MOB-DWID-011: swapping writes the reversed pair to the settings store', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ dashboard_fx_from: 'EUR', dashboard_fx_to: 'USD' }) });
render(<MobileDashWidget id="currency" upcoming={[]} />);
await waitFor(() => expect(screen.getByText('1 EUR = 1.1000 USD')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Swap currencies' }));
await waitFor(() => {
const s = useSettingsStore.getState().settings;
expect(s.dashboard_fx_from).toBe('USD');
expect(s.dashboard_fx_to).toBe('EUR');
});
});
it('FE-MOB-DWID-012: the native picker writes the chosen currency', async () => {
render(<MobileDashWidget id="currency" upcoming={[]} />);
await waitFor(() => expect(screen.getByText('1 EUR = 1.1000 USD')).toBeInTheDocument());
fireEvent.change(screen.getByLabelText('USD'), { target: { value: 'CHF' } });
await waitFor(() => expect(useSettingsStore.getState().settings.dashboard_fx_to).toBe('CHF'));
});
it('FE-MOB-DWID-013: refresh re-requests the rates', async () => {
let calls = 0;
server.use(http.get('https://api.frankfurter.dev/v2/rates', () => { calls++; return HttpResponse.json(RATES); }));
render(<MobileDashWidget id="currency" upcoming={[]} />);
await waitFor(() => expect(calls).toBe(1));
fireEvent.click(screen.getByRole('button', { name: 'Refresh rates' }));
await waitFor(() => expect(calls).toBe(2));
});
it('FE-MOB-DWID-014: migrates the legacy fx keys into settings once', async () => {
localStorage.setItem('trek_fx_from', 'CAD');
localStorage.setItem('trek_fx_to', 'CHF');
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
render(<MobileDashWidget id="currency" upcoming={[]} />);
await waitFor(() => {
expect(localStorage.getItem('trek_fx_from')).toBeNull();
expect(localStorage.getItem('trek_fx_to')).toBeNull();
});
expect(useSettingsStore.getState().settings.dashboard_fx_from).toBe('CAD');
});
it('FE-MOB-DWID-015: keeps the legacy fx keys when the write fails', async () => {
server.use(
http.put('/api/settings', () => new HttpResponse(null, { status: 500 })),
http.post('/api/settings/bulk', () => new HttpResponse(null, { status: 500 })),
);
localStorage.setItem('trek_fx_from', 'CAD');
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
render(<MobileDashWidget id="currency" upcoming={[]} />);
await waitFor(() => expect(useSettingsStore.getState().settings.dashboard_fx_from).toBe('CAD'));
expect(localStorage.getItem('trek_fx_from')).toBe('CAD');
});
});
describe('MCollectionsWidget', () => {
it('FE-MOB-DWID-016: shows the empty hint when there is no list', async () => {
render(<MobileDashWidget id="collections" upcoming={[]} />);
expect(await screen.findByText('No saved places yet')).toBeInTheDocument();
});
it('FE-MOB-DWID-017: renders at most four lists with their place counts', async () => {
collectionsHandler([
{ id: 1, name: 'Tokyo eats', color: '#ff0000', place_count: 12 },
{ id: 2, name: 'Museums', color: null, place_count: 3 },
{ id: 3, name: 'Bars', color: null, place_count: null },
{ id: 4, name: 'Parks', color: null, place_count: 1 },
{ id: 5, name: 'Hidden', color: null, place_count: 9 },
]);
render(<MobileDashWidget id="collections" upcoming={[]} />);
expect(await screen.findByText('Tokyo eats')).toBeInTheDocument();
expect(screen.getByText('Parks')).toBeInTheDocument();
expect(screen.queryByText('Hidden')).not.toBeInTheDocument();
// A list without a stored count renders a zero rather than blank.
expect(screen.getByText('0')).toBeInTheDocument();
});
it('FE-MOB-DWID-032: the header arrow and a badge open the collections pages', async () => {
collectionsHandler([{ id: 7, name: 'Tokyo eats', color: '#f00', place_count: 2 }]);
render(<><MobileDashWidget id="collections" upcoming={[]} /><LocationEcho /></>);
fireEvent.click(await screen.findByText('Tokyo eats'));
expect(screen.getByTestId('loc')).toHaveTextContent('/collections/7');
fireEvent.click(screen.getByRole('button', { name: 'Collections' }));
expect(screen.getByTestId('loc')).toHaveTextContent('/collections');
});
it('FE-MOB-DWID-018: a failed fetch falls back to the empty hint', async () => {
server.use(http.get('/api/addons/collections', () => new HttpResponse(null, { status: 500 })));
render(<MobileDashWidget id="collections" upcoming={[]} />);
expect(await screen.findByText('No saved places yet')).toBeInTheDocument();
});
});
describe('MTimezonesWidget', () => {
it('FE-MOB-DWID-019: renders the stored zones with their local time', () => {
seedStore(useSettingsStore, { settings: buildSettings({ dashboard_timezones: ['Asia/Tokyo'] }) });
render(<MobileDashWidget id="timezones" upcoming={[]} />);
expect(screen.getByText('Timezones')).toBeInTheDocument();
expect(screen.getByText('Tokyo')).toBeInTheDocument();
expect(screen.getByText(/^\d{2}:\d{2}$/)).toBeInTheDocument();
});
it('FE-MOB-DWID-020: an explicitly empty list shows the empty hint', () => {
seedStore(useSettingsStore, { settings: buildSettings({ dashboard_timezones: [] }) });
render(<MobileDashWidget id="timezones" upcoming={[]} />);
expect(screen.getByText('No other timezones yet — add one with +')).toBeInTheDocument();
});
it('FE-MOB-DWID-021: the edit mode adds and removes a zone', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ dashboard_timezones: ['Asia/Tokyo'] }) });
render(<MobileDashWidget id="timezones" upcoming={[]} />);
fireEvent.click(screen.getByRole('button', { name: 'Add timezone' }));
fireEvent.change(screen.getByRole('combobox', { name: 'Add timezone' }), { target: { value: 'Europe/Berlin' } });
await waitFor(() =>
expect(useSettingsStore.getState().settings.dashboard_timezones).toEqual(['Asia/Tokyo', 'Europe/Berlin']));
fireEvent.click(screen.getByRole('button', { name: 'Add timezone' }));
fireEvent.click(screen.getByRole('button', { name: 'Remove Tokyo' }));
await waitFor(() =>
expect(useSettingsStore.getState().settings.dashboard_timezones).toEqual(['Europe/Berlin']));
});
it('FE-MOB-DWID-022: picking an already-listed zone changes nothing', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ dashboard_timezones: ['Asia/Tokyo'] }) });
render(<MobileDashWidget id="timezones" upcoming={[]} />);
fireEvent.click(screen.getByRole('button', { name: 'Add timezone' }));
fireEvent.change(screen.getByRole('combobox', { name: 'Add timezone' }), { target: { value: '' } });
// The picker closes again and the stored list is untouched.
await waitFor(() => expect(screen.queryByRole('combobox', { name: 'Add timezone' })).not.toBeInTheDocument());
expect(useSettingsStore.getState().settings.dashboard_timezones).toEqual(['Asia/Tokyo']);
});
it('FE-MOB-DWID-023: the clock ticks on its interval', () => {
vi.useFakeTimers();
const nowSpy = vi.spyOn(Date.prototype, 'toLocaleTimeString');
seedStore(useSettingsStore, { settings: buildSettings({ dashboard_timezones: ['Asia/Tokyo'] }) });
render(<MobileDashWidget id="timezones" upcoming={[]} />);
const before = nowSpy.mock.calls.length;
act(() => { vi.advanceTimersByTime(30000); });
expect(nowSpy.mock.calls.length).toBeGreaterThan(before);
nowSpy.mockRestore();
});
it('FE-MOB-DWID-024: migrates the legacy timezone list into settings', async () => {
localStorage.setItem('trek_dashboard_tz', JSON.stringify(['America/New_York']));
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
render(<MobileDashWidget id="timezones" upcoming={[]} />);
await waitFor(() => expect(localStorage.getItem('trek_dashboard_tz')).toBeNull());
expect(useSettingsStore.getState().settings.dashboard_timezones).toEqual(['America/New_York']);
});
it('FE-MOB-DWID-025: drops a malformed legacy timezone value', async () => {
localStorage.setItem('trek_dashboard_tz', 'not-json');
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
render(<MobileDashWidget id="timezones" upcoming={[]} />);
await waitFor(() => expect(localStorage.getItem('trek_dashboard_tz')).toBeNull());
expect(useSettingsStore.getState().settings.dashboard_timezones).toBeUndefined();
});
it('FE-MOB-DWID-026: drops a legacy value that is not a list', async () => {
localStorage.setItem('trek_dashboard_tz', JSON.stringify({ tz: 'Asia/Tokyo' }));
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
render(<MobileDashWidget id="timezones" upcoming={[]} />);
await waitFor(() => expect(localStorage.getItem('trek_dashboard_tz')).toBeNull());
expect(useSettingsStore.getState().settings.dashboard_timezones).toBeUndefined();
});
it('FE-MOB-DWID-027: keeps the legacy value when the write fails', async () => {
server.use(
http.put('/api/settings', () => new HttpResponse(null, { status: 500 })),
http.post('/api/settings/bulk', () => new HttpResponse(null, { status: 500 })),
);
localStorage.setItem('trek_dashboard_tz', JSON.stringify(['America/New_York']));
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
render(<MobileDashWidget id="timezones" upcoming={[]} />);
await waitFor(() =>
expect(useSettingsStore.getState().settings.dashboard_timezones).toEqual(['America/New_York']));
expect(localStorage.getItem('trek_dashboard_tz')).toBe(JSON.stringify(['America/New_York']));
});
});
describe('legacy settings migration', () => {
it('FE-MOB-DWID-033: loaded settings without legacy keys write nothing', async () => {
seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true });
render(<MobileDashWidget id="currency" upcoming={[]} />);
render(<MobileDashWidget id="timezones" upcoming={[]} />);
await waitFor(() => expect(screen.getByText('1 EUR = 1.1000 USD')).toBeInTheDocument());
expect(useSettingsStore.getState().settings.dashboard_fx_from).toBeUndefined();
expect(useSettingsStore.getState().settings.dashboard_timezones).toBeUndefined();
});
});
describe('MUpcomingWidget', () => {
const res = (over: Partial<UpcomingReservation> = {}): UpcomingReservation => ({
id: 1, trip_id: 7, title: 'teamLab Planets', type: 'ticket',
reservation_time: null, day_date: '2026-09-02', location: 'Toyosu',
...over,
});
it('FE-MOB-DWID-028: shows the empty hint without reservations', () => {
render(<MobileDashWidget id="upcomingReservations" upcoming={[]} />);
expect(screen.getByText('Upcoming reservations')).toBeInTheDocument();
expect(screen.getByText('Nothing booked yet.')).toBeInTheDocument();
});
it('FE-MOB-DWID-029: builds the subtitle from date, time and place', () => {
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '24h' }) });
render(
<MobileDashWidget
id="upcomingReservations"
upcoming={[res({ reservation_time: '2026-09-03T19:30:00', location: null, place_name: 'Narita' })]}
/>,
);
expect(screen.getByText('Sep 3 · 19:30 · Narita')).toBeInTheDocument();
});
it('FE-MOB-DWID-030: an unparseable date degrades to the remaining parts', () => {
render(
<MobileDashWidget
id="upcomingReservations"
upcoming={[res({ day_date: 'not-a-date', location: null, place_name: null, trip_title: 'Japan' })]}
/>,
);
expect(screen.getByText('Japan')).toBeInTheDocument();
});
it('FE-MOB-DWID-031: tapping a reservation opens its trip on the bookings tab', () => {
render(<MobileDashWidget id="upcomingReservations" upcoming={[res({ type: 'flight' })]} />);
fireEvent.click(screen.getByText('teamLab Planets'));
expect(sessionStorage.getItem('trip-tab-7')).toBe('buchungen');
});
});