fix(web): show an outage as an outage, and drop superseded fetches

The home page caught every fetch failure and rendered its empty state,
so a backend outage looked like a site with no schools in it. School
pages turned any error into notFound(), which told visitors — and
crawlers — that a real school had ceased to exist. Place fetches did the
same by returning [] and null. Failures now reach a retryable error
boundary; only a genuine 404 still calls notFound().

"Load more" and the map fetch resolved against whatever state existed
when they returned, so results from an abandoned search appended
themselves to the new ones. Each fetch now carries an AbortController
and checks that its search scope is still current before touching state.
The map only records its cache key on success, so a failed load retries
instead of pinning the stale marker set.

Jest ignored .next/, whose build output otherwise shadowed real suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
TudorandClaude Opus 5 committed 2026-09-15 10:17:18 +01:00
1 parent 9b75f54206
commit 7b41218e6e
8 files changed
+231 -80

No files matched your search

@@ -0,0 +1,51 @@
import { fireEvent, render, screen } from '@testing-library/react';
import HomePage from '@/app/(frontend)/page';
import SchoolPage from '@/app/(frontend)/school/[slug]/page';
import ErrorPage from '@/app/(frontend)/error';
import { APIFetchError, fetchSchools, fetchFilters, fetchSchoolDetails } from '@/lib/api';
import { fetchPlace, fetchPlaces } from '@/lib/places';
jest.mock('@/lib/api', () => ({
...jest.requireActual('@/lib/api'),
fetchSchools: jest.fn(),
fetchSchoolDetails: jest.fn(),
fetchFilters: jest.fn(async () => ({})),
fetchDataInfo: jest.fn(async () => null),
fetchNationalAverages: jest.fn(async () => null),
}));
jest.mock('@/lib/flags', () => ({ getFlags: jest.fn(async () => ({})) }));
jest.mock('next/navigation', () => ({
notFound: () => { throw new Error('NEXT_NOT_FOUND'); },
redirect: jest.fn(),
}));
const realFetch = global.fetch;
afterEach(() => { global.fetch = realFetch; jest.clearAllMocks(); });
test('school outages propagate; only a real 404 becomes not found', async () => {
const request = { params: Promise.resolve({ slug: '100001-school' }) };
const outage = new APIFetchError('unavailable', 503);
jest.mocked(fetchSchoolDetails).mockRejectedValueOnce(outage);
await expect(SchoolPage(request)).rejects.toBe(outage);
jest.mocked(fetchSchoolDetails).mockRejectedValueOnce(new APIFetchError('missing', 404));
await expect(SchoolPage(request)).rejects.toThrow('NEXT_NOT_FOUND');
});
test('homepage search failure is not returned as an empty successful page', async () => {
jest.mocked(fetchSchools).mockRejectedValueOnce(new APIFetchError('unavailable', 503));
await expect(HomePage({ searchParams: Promise.resolve({ search: 'school' }) })).rejects.toThrow('unavailable');
});
test('a place is absent only on 404; other failures propagate', async () => {
global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 404 });
await expect(fetchPlace('town', 'example')).resolves.toBeNull();
jest.mocked(global.fetch).mockResolvedValue({ ok: false, status: 503 } as Response);
await expect(fetchPlace('town', 'example')).rejects.toMatchObject({ status: 503 });
await expect(fetchPlaces()).rejects.toMatchObject({ status: 503 });
});
test('the error boundary offers a retry without showing an empty search', () => {
const reset = jest.fn();
render(<ErrorPage error={new Error('offline')} reset={reset} />);
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(reset).toHaveBeenCalledTimes(1);
});
@@ -0,0 +1,81 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { HomeView } from '@/components/HomeView';
import { fetchSchools } from '@/lib/api';
import { primaryFixture } from '../support/schoolFixtures';
import type { SchoolsResponse, School } from '@/lib/types';
let params = new URLSearchParams('postcode=SW1A+1AA');
jest.mock('next/navigation', () => ({
useSearchParams: () => params,
usePathname: () => '/',
useRouter: () => ({ push: jest.fn(), replace: jest.fn() }),
}));
jest.mock('@/context/ComparisonContext', () => ({
useComparisonContext: () => ({ addSchool: jest.fn(), removeSchool: jest.fn(), selectedSchools: [] }),
}));
jest.mock('@/lib/api', () => ({
fetchSchools: jest.fn(),
fetchNationalAverages: jest.fn(async () => ({})),
fetchLAaverages: jest.fn(async () => ({ secondary: { attainment_8_by_la: {} } })),
}));
jest.mock('@/components/FilterBar', () => ({ FilterBar: () => null }));
jest.mock('@/components/SchoolRow', () => ({ SchoolRow: ({ school }: {school: School}) => <div>{school.school_name}</div> }));
jest.mock('@/components/SchoolMap', () => ({ SchoolMap: ({ schools }: {schools: School[]}) => <div data-testid="map">{schools.map(s => s.school_name).join(',')}</div> }));
const filters = { local_authorities: [], school_types: [], years: [], phases: [], genders: [], admissions_policies: [] };
function response(name: string): SchoolsResponse {
return { schools: [{ ...primaryFixture.schoolInfo, school_name: name }],
total: 2, page: 1, page_size: 1, total_pages: 2 };
}
function deferred() {
let resolve!: (value: SchoolsResponse) => void;
let reject!: (error: Error) => void;
const promise = new Promise<SchoolsResponse>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
beforeEach(() => {
params = new URLSearchParams('postcode=SW1A+1AA');
jest.mocked(fetchSchools).mockReset();
});
test('load-more results from an old search are discarded, even after returning to it', async () => {
const pending = deferred();
jest.mocked(fetchSchools).mockReturnValueOnce(pending.promise);
const view = render(<HomeView initialSchools={response('Initial A')} filters={filters} />);
fireEvent.click(screen.getByRole('button', { name: 'Load more schools' }));
const signal = jest.mocked(fetchSchools).mock.calls[0][1]?.signal;
params = new URLSearchParams('postcode=SW2+1AA');
view.rerender(<HomeView initialSchools={response('Initial B')} filters={filters} />);
expect(signal?.aborted).toBe(true);
params = new URLSearchParams('postcode=SW1A+1AA');
view.rerender(<HomeView initialSchools={response('Fresh A')} filters={filters} />);
await act(async () => pending.resolve(response('Stale append')));
expect(screen.queryByText('Stale append')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Load more schools' })).toBeEnabled();
});
test('an older map response cannot overwrite the current search', async () => {
const first = deferred(), second = deferred();
jest.mocked(fetchSchools).mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const initial = response('Initial A');
const view = render(<HomeView initialSchools={initial} filters={filters} />);
fireEvent.click(screen.getByRole('button', { name: 'Map' }));
params = new URLSearchParams('postcode=SW2+1AA');
view.rerender(<HomeView initialSchools={response('Initial B')} filters={filters} />);
await act(async () => second.resolve(response('Current map')));
await act(async () => first.resolve(response('Stale map')));
expect(screen.getByTestId('map')).toHaveTextContent('Current map');
expect(screen.getByTestId('map')).not.toHaveTextContent('Stale map');
});
test('failed map requests can be retried by reopening the map', async () => {
const pending = deferred();
jest.mocked(fetchSchools).mockReturnValueOnce(pending.promise).mockResolvedValue(response('Retry result'));
render(<HomeView initialSchools={response('Initial')} filters={filters} />);
fireEvent.click(screen.getByRole('button', { name: 'Map' }));
await act(async () => pending.reject(new Error('offline')));
fireEvent.click(screen.getByRole('button', { name: 'List' }));
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'Map' })));
expect(fetchSchools).toHaveBeenCalledTimes(2);
expect(screen.getByTestId('map')).toHaveTextContent('Retry result');
});