mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
6c63a9bb60
* 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.
421 lines
17 KiB
TypeScript
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');
|
|
});
|
|
});
|