Files
TREK/client/tests/unit/mobile/admin/MAdmin.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

373 lines
15 KiB
TypeScript

import React from 'react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Route, Routes } from 'react-router';
import { http, HttpResponse } from 'msw';
import { fireEvent, render, screen, waitFor } from '../../../helpers/render';
import { server } from '../../../helpers/msw/server';
import { resetAllStores, seedStore } from '../../../helpers/store';
import { buildAdmin } from '../../../helpers/factories';
import { useAuthStore } from '../../../../src/store/authStore';
import { useAddonStore } from '../../../../src/store/addonStore';
import { ToastContainer } from '../../../../src/components/shared/Toast';
import MAdmin from '../../../../src/mobile/screens/admin/MAdmin';
// FE-MOB-ADMIN-001 onwards
// The permissions matrix is its own heavy panel — out of scope here. Mock the
// mobile panel MAdmin actually renders (it self-loads GET /api/admin/permissions).
vi.mock('../../../../src/mobile/screens/admin/MAdminPermissionsPanel', () => ({
default: () => <div data-testid="permissions-panel" />,
}));
// Every other section panel has its own suite; here only the routing into them
// matters, so they are reduced to markers.
vi.mock('../../../../src/mobile/screens/admin/MAdminPluginsPanel', () => ({
default: () => <div data-testid="plugins-panel" />,
}));
vi.mock('../../../../src/mobile/screens/admin/MAdminBackupPanel', () => ({
default: () => <div data-testid="backup-panel" />,
}));
vi.mock('../../../../src/mobile/screens/admin/MAdminCategoryManager', () => ({
default: () => <div data-testid="category-manager" />,
}));
vi.mock('../../../../src/mobile/screens/admin/MAdminPackingTemplateManager', () => ({
default: () => <div data-testid="packing-templates" />,
}));
vi.mock('../../../../src/mobile/screens/admin/MAdminDefaultUserSettings', () => ({
default: () => <div data-testid="default-user-settings" />,
}));
vi.mock('../../../../src/mobile/screens/admin/MAdminDevNotificationsPanel', () => ({
default: () => <div data-testid="dev-notifications" />,
}));
vi.mock('../../../../src/mobile/screens/admin/MAdminNotificationsSection', () => ({
default: () => <div data-testid="notifications-section" />,
}));
vi.mock('../../../../src/mobile/screens/admin/MAdminMcpTokensPanel', () => ({
default: () => <div data-testid="mcp-tokens-panel" />,
}));
vi.mock('../../../../src/mobile/screens/admin/MAdminAuditLogPanel', () => ({
default: ({ serverTimezone }: { serverTimezone?: string }) => (
<div data-testid="audit-panel">{serverTimezone}</div>
),
}));
vi.mock('../../../../src/mobile/screens/admin/MAdminGitHubPanel', () => ({
default: ({ isPrerelease }: { isPrerelease: boolean }) => (
<div data-testid="github-panel">{String(isPrerelease)}</div>
),
}));
// The addon manager is reduced to the two controls MAdmin wires itself.
vi.mock('../../../../src/mobile/screens/admin/MAdminAddonManager', () => ({
default: ({
bagTrackingEnabled,
onToggleBagTracking,
collabFeatures,
onToggleCollabFeature,
}: {
bagTrackingEnabled: boolean;
onToggleBagTracking: () => void;
collabFeatures: Record<string, boolean>;
onToggleCollabFeature: (key: string) => void;
}) => (
<div>
<span data-testid="bag-state">{String(bagTrackingEnabled)}</span>
<button type="button" onClick={onToggleBagTracking}>toggle bag</button>
<span data-testid="chat-state">{String(collabFeatures.chat)}</span>
<button type="button" onClick={() => onToggleCollabFeature('chat')}>toggle chat</button>
</div>
),
}));
function renderAdmin() {
return render(
<>
<ToastContainer />
<Routes>
<Route path="/admin" element={<MAdmin />} />
<Route path="/dashboard" element={<div>dashboard page</div>} />
</Routes>
</>,
{ initialEntries: ['/admin'] },
);
}
async function openSection(label: string) {
fireEvent.click(screen.getByRole('button', { name: 'Administration' }));
fireEvent.click(screen.getByRole('button', { name: label }));
}
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
server.use(
http.get('/api/admin/collab-features', () =>
HttpResponse.json({ chat: true, notes: true, polls: true, whatsnext: true }),
),
http.get('/api/admin/places-photos', () => HttpResponse.json({ enabled: false })),
http.get('/api/admin/places-autocomplete', () => HttpResponse.json({ enabled: false })),
http.get('/api/admin/places-details', () => HttpResponse.json({ enabled: false })),
http.get('/api/admin/invites/trips', () => HttpResponse.json({ trips: [] })),
);
});
afterEach(() => {
server.resetHandlers();
});
describe('MAdmin', () => {
it('FE-MOB-ADMIN-001: renders stats grid and the user list with role badges', async () => {
render(<MAdmin />);
// Users load from GET /api/admin/users (MSW: admin + alice)
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
expect(screen.getByText('Administrator')).toBeInTheDocument();
// Stats from GET /api/admin/stats: 42 places, 8 files
await waitFor(() => expect(screen.getByText('42')).toBeInTheDocument());
expect(screen.getByText('8')).toBeInTheDocument();
});
it('FE-MOB-ADMIN-002: the section dropdown switches to the settings section', async () => {
render(<MAdmin />);
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
// Open the switcher pill (labelled with the admin title)
fireEvent.click(screen.getByRole('button', { name: 'Administration' }));
// All standard sections are listed
expect(screen.getByRole('button', { name: 'User Defaults' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Backup' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Audit' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Settings' }));
// Settings section renders the auth methods card, user list is gone
await waitFor(() => expect(screen.getByText('Authentication Methods')).toBeInTheDocument());
expect(screen.queryByText('alice')).not.toBeInTheDocument();
});
it('FE-MOB-ADMIN-003: the back button leaves for the dashboard', async () => {
renderAdmin();
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
expect(screen.getByText('dashboard page')).toBeInTheDocument();
});
it('FE-MOB-ADMIN-004: the header plus opens the create-user sheet', async () => {
renderAdmin();
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Create User' }));
expect(screen.getByRole('dialog', { name: 'Create User' })).toBeInTheDocument();
});
it('FE-MOB-ADMIN-005: MCP Access and the dev section stay hidden unless enabled', async () => {
renderAdmin();
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Administration' }));
expect(screen.queryByRole('button', { name: 'MCP Access' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Dev: Notifications' })).not.toBeInTheDocument();
});
it('FE-MOB-ADMIN-006: the MCP addon adds its section to the dropdown', async () => {
useAddonStore.setState({
addons: [{ id: 'mcp', name: 'MCP', type: 'integration', icon: 'plug', enabled: true }],
bagTracking: false,
loaded: true,
});
renderAdmin();
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
await openSection('MCP Access');
expect(screen.getByTestId('mcp-tokens-panel')).toBeInTheDocument();
});
it('FE-MOB-ADMIN-007: dev mode adds the dev notifications section', async () => {
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), devMode: true });
renderAdmin();
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
await openSection('Dev: Notifications');
expect(screen.getByTestId('dev-notifications')).toBeInTheDocument();
});
it('FE-MOB-ADMIN-008: every remaining section is reachable from the dropdown', async () => {
renderAdmin();
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
await openSection('User Defaults');
expect(screen.getByTestId('default-user-settings')).toBeInTheDocument();
await openSection('Personalization');
expect(screen.getByTestId('packing-templates')).toBeInTheDocument();
expect(screen.getByTestId('category-manager')).toBeInTheDocument();
await openSection('Plugins');
expect(screen.getByTestId('plugins-panel')).toBeInTheDocument();
await openSection('Notifications');
expect(screen.getByTestId('notifications-section')).toBeInTheDocument();
await openSection('Backup');
expect(screen.getByTestId('backup-panel')).toBeInTheDocument();
});
it('FE-MOB-ADMIN-009: the audit and GitHub panels get the server timezone and prerelease flag', async () => {
server.use(
http.get('/api/admin/version-check', () =>
HttpResponse.json({
update_available: true,
latest: '3.5.0',
current: '3.4.0',
is_prerelease: true,
is_docker: true,
}),
),
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), serverTimezone: 'Europe/Berlin' });
renderAdmin();
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
await openSection('Audit');
expect(screen.getByTestId('audit-panel')).toHaveTextContent('Europe/Berlin');
await openSection('GitHub');
await waitFor(() => expect(screen.getByTestId('github-panel')).toHaveTextContent('true'));
});
it('FE-MOB-ADMIN-010: the update banner opens the how-to-update sheet', async () => {
server.use(
http.get('/api/admin/version-check', () =>
HttpResponse.json({ update_available: true, latest: '3.5.0', current: '3.4.0', is_docker: true }),
),
);
renderAdmin();
await screen.findByText('Update available');
expect(screen.getByText('Version v3.5.0 available · you have v3.4.0')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Update' }));
expect(screen.getByRole('dialog', { name: 'How to Update' })).toBeInTheDocument();
});
it('FE-MOB-ADMIN-011: no banner when the version check reports no update', async () => {
renderAdmin();
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
expect(screen.queryByText('Update available')).not.toBeInTheDocument();
});
it('FE-MOB-ADMIN-012: demo instances can save a baseline', async () => {
let saved = false;
server.use(
http.post('/api/admin/save-demo-baseline', () => {
saved = true;
return HttpResponse.json({ success: true });
}),
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), demoMode: true });
renderAdmin();
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Save Baseline' }));
await screen.findByText('Baseline saved! Resets will restore to this state.');
expect(saved).toBe(true);
});
it('FE-MOB-ADMIN-013: a failing baseline save surfaces the server message', async () => {
server.use(
http.post('/api/admin/save-demo-baseline', () =>
HttpResponse.json({ error: 'Not a demo instance' }, { status: 400 }),
),
);
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), demoMode: true });
renderAdmin();
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Save Baseline' }));
await screen.findByText('Not a demo instance');
});
it('FE-MOB-ADMIN-014: the bag tracking toggle persists and rolls back on failure', async () => {
const bodies: unknown[] = [];
server.use(
http.put('/api/admin/bag-tracking', async ({ request }) => {
bodies.push(await request.json());
return bodies.length === 1
? HttpResponse.json({ enabled: true })
: HttpResponse.json({ error: 'boom' }, { status: 500 });
}),
);
renderAdmin();
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
await openSection('Addons');
expect(screen.getByTestId('bag-state')).toHaveTextContent('false');
fireEvent.click(screen.getByRole('button', { name: 'toggle bag' }));
await waitFor(() => expect(screen.getByTestId('bag-state')).toHaveTextContent('true'));
// Second call fails → the optimistic value is reverted
fireEvent.click(screen.getByRole('button', { name: 'toggle bag' }));
await waitFor(() => expect(screen.getByTestId('bag-state')).toHaveTextContent('true'));
expect(bodies).toEqual([{ enabled: true }, { enabled: false }]);
});
it('FE-MOB-ADMIN-015: a collab feature toggle sends only the changed key', async () => {
let sent: Record<string, unknown> = {};
server.use(
http.put('/api/admin/collab-features', async ({ request }) => {
sent = (await request.json()) as Record<string, unknown>;
return HttpResponse.json({ ...sent });
}),
);
renderAdmin();
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
await openSection('Addons');
await waitFor(() => expect(screen.getByTestId('chat-state')).toHaveTextContent('true'));
fireEvent.click(screen.getByRole('button', { name: 'toggle chat' }));
await waitFor(() => expect(sent).toEqual({ chat: false }));
expect(screen.getByTestId('chat-state')).toHaveTextContent('false');
});
it('FE-MOB-ADMIN-016: a failing collab update restores the previous flags', async () => {
server.use(
http.put('/api/admin/collab-features', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
);
renderAdmin();
await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument());
await openSection('Addons');
await waitFor(() => expect(screen.getByTestId('chat-state')).toHaveTextContent('true'));
fireEvent.click(screen.getByRole('button', { name: 'toggle chat' }));
await waitFor(() => expect(screen.getByTestId('chat-state')).toHaveTextContent('true'));
});
it('FE-MOB-ADMIN-017: the stats grid falls back to zero files', async () => {
server.use(
http.get('/api/admin/stats', () =>
HttpResponse.json({ totalUsers: 3, totalTrips: 7, totalPlaces: 11 }),
),
);
renderAdmin();
await screen.findByText('11');
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('7')).toBeInTheDocument();
expect(screen.getByText('0')).toBeInTheDocument();
});
});