Files
TREK/client/src/pages/FilesPage.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

212 lines
6.8 KiB
TypeScript

import React from 'react';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor, act } from '../../tests/helpers/render';
import { Route, Routes } from 'react-router';
import { http, HttpResponse } from 'msw';
import { server } from '../../tests/helpers/msw/server';
import { resetAllStores, seedStore } from '../../tests/helpers/store';
import { buildUser, buildTrip, buildTripFile } from '../../tests/helpers/factories';
import { useAuthStore } from '../store/authStore';
import { useTripStore } from '../store/tripStore';
import FilesPage from './FilesPage';
vi.mock('../components/Files/FileManager', () => ({
default: ({ files }: { files: unknown[]; onUpload: unknown; onDelete: unknown }) =>
React.createElement('div', { 'data-testid': 'file-manager' }, `${files.length} files`),
}));
vi.mock('../components/Layout/Navbar', () => ({
default: ({ tripTitle }: { tripTitle?: string }) =>
React.createElement('nav', { 'data-testid': 'navbar' }, tripTitle),
}));
function renderFilesPage(tripId: number | string = 1) {
return render(
<Routes>
<Route path="/trips/:id/files" element={<FilesPage />} />
</Routes>,
{ initialEntries: [`/trips/${tripId}/files`] },
);
}
beforeEach(() => {
vi.clearAllMocks();
resetAllStores();
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
seedStore(useTripStore, {
files: [],
loadFiles: vi.fn().mockResolvedValue(undefined),
addFile: vi.fn().mockResolvedValue(undefined),
deleteFile: vi.fn().mockResolvedValue(undefined),
} as any);
});
describe('FilesPage', () => {
describe('FE-PAGE-FILES-001: Loading spinner shown while data fetches', () => {
it('shows a spinner while data is loading', async () => {
server.use(
http.get('/api/trips/:id', async () => {
await new Promise(resolve => setTimeout(resolve, 200));
const trip = buildTrip({ id: 1 });
return HttpResponse.json({ trip });
}),
);
renderFilesPage(1);
expect(document.querySelector('.animate-spin')).toBeInTheDocument();
});
});
describe('FE-PAGE-FILES-002: Trip name displayed in Navbar after load', () => {
it('passes the trip name to Navbar after data loads', async () => {
const trip = buildTrip({ id: 1, title: 'Rome Trip' });
server.use(
http.get('/api/trips/:id', () => HttpResponse.json({ trip })),
);
renderFilesPage(1);
await waitFor(() => {
expect(screen.getByTestId('navbar')).toHaveTextContent('Rome Trip');
});
});
});
describe('FE-PAGE-FILES-003: FileManager renders after load', () => {
it('renders the FileManager after data loads', async () => {
renderFilesPage(1);
await waitFor(() => {
expect(screen.getByTestId('file-manager')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-FILES-004: File count shown in header', () => {
it('shows the correct file count in the header', async () => {
const file1 = buildTripFile();
const file2 = buildTripFile();
seedStore(useTripStore, {
files: [file1, file2],
loadFiles: vi.fn().mockResolvedValue(undefined),
addFile: vi.fn().mockResolvedValue(undefined),
deleteFile: vi.fn().mockResolvedValue(undefined),
} as any);
renderFilesPage(1);
await waitFor(() => {
expect(screen.getByTestId('file-manager')).toBeInTheDocument();
});
expect(screen.getByText(/2 files for/i)).toBeInTheDocument();
});
});
describe('FE-PAGE-FILES-005: Back link navigates to trip planner', () => {
it('back link points to the trip planner page', async () => {
renderFilesPage(1);
await waitFor(() => {
expect(screen.getByTestId('file-manager')).toBeInTheDocument();
});
const backLink = screen.getByRole('link', { name: /back to planning/i });
expect(backLink.getAttribute('href')).toContain('/trips/1');
});
});
describe('FE-PAGE-FILES-006: loadFiles is called with trip ID on mount', () => {
it('calls tripStore.loadFiles with the trip ID from the URL', async () => {
const mockLoadFiles = vi.fn().mockResolvedValue(undefined);
seedStore(useTripStore, {
files: [],
loadFiles: mockLoadFiles,
addFile: vi.fn().mockResolvedValue(undefined),
deleteFile: vi.fn().mockResolvedValue(undefined),
} as any);
renderFilesPage(1);
await waitFor(() => {
expect(mockLoadFiles).toHaveBeenCalledWith(1);
});
});
});
describe('FE-PAGE-FILES-007: Navigation to /dashboard on fetch error', () => {
it('navigates to /dashboard when trip fetch fails', async () => {
server.use(
http.get('/api/trips/:id', () =>
HttpResponse.json({ error: 'Not found' }, { status: 404 }),
),
);
render(
<Routes>
<Route path="/trips/:id/files" element={<FilesPage />} />
<Route path="/dashboard" element={<div data-testid="dashboard">Dashboard</div>} />
</Routes>,
{ initialEntries: ['/trips/1/files'] },
);
await waitFor(() => {
expect(screen.getByTestId('dashboard')).toBeInTheDocument();
});
});
});
describe('FE-PAGE-FILES-008: Files update when tripStore.files changes', () => {
it('FileManager re-renders when store files change', async () => {
seedStore(useTripStore, {
files: [],
loadFiles: vi.fn().mockResolvedValue(undefined),
addFile: vi.fn().mockResolvedValue(undefined),
deleteFile: vi.fn().mockResolvedValue(undefined),
} as any);
renderFilesPage(1);
await waitFor(() => {
expect(screen.getByTestId('file-manager')).toBeInTheDocument();
});
expect(screen.getByTestId('file-manager')).toHaveTextContent('0 files');
// Simulate store update
act(() => {
useTripStore.setState({ files: [buildTripFile({ id: 99, original_name: 'document.pdf' })] } as any);
});
await waitFor(() => {
expect(screen.getByTestId('file-manager')).toHaveTextContent('1 files');
});
});
});
describe('FE-PAGE-FILES-009: Empty file list renders FileManager with 0 files', () => {
it('renders FileManager with 0 files when files array is empty', async () => {
renderFilesPage(1);
await waitFor(() => {
expect(screen.getByTestId('file-manager')).toBeInTheDocument();
});
expect(screen.getByTestId('file-manager')).toHaveTextContent('0 files');
});
});
describe('FE-PAGE-FILES-010: Page title heading present', () => {
it('renders the "Dateien & Dokumente" heading', async () => {
renderFilesPage(1);
await waitFor(() => {
expect(screen.getByTestId('file-manager')).toBeInTheDocument();
});
expect(screen.getByRole('heading', { name: /Files & Documents/i })).toBeInTheDocument();
});
});
});