PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 18s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m18s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 18s
The search page fetched LA averages with cache: 'force-cache', which serves any stored response, however old, without asking the server. One failed request (a staging deploy restart; the July proxy outage) was stored and replayed on every later visit, and the error was swallowed, so the "vs LA avg" delta silently vanished from every secondary row in that browser. A Playwright profile still held a 500 dated 5 July. The default cache mode honours the API's Cache-Control (five minutes), so a good answer is still reused and an error never is. Browsers holding a stored failure recover on their next visit. A journey now checks that a mainstream secondary's row shows the comparison: nothing did, which is how it could go missing unnoticed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
107 lines
5.6 KiB
TypeScript
107 lines
5.6 KiB
TypeScript
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
import { HomeView } from '@/components/HomeView';
|
|
import { fetchLAaverages, 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: {} } })),
|
|
}));
|
|
// Renders only the List/Map switch HomeView hands it, which lives in its row.
|
|
jest.mock('@/components/FilterBar', () => ({
|
|
FilterBar: ({ viewSwitch }: { viewSwitch?: unknown }) => viewSwitch || 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 () => {
|
|
// Name searches: a postcode search opens on the map, which has no Load more.
|
|
params = new URLSearchParams('search=abbey');
|
|
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('search=brecknock');
|
|
view.rerender(<HomeView initialSchools={response('Initial B')} filters={filters} />);
|
|
expect(signal?.aborted).toBe(true);
|
|
params = new URLSearchParams('search=abbey');
|
|
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');
|
|
});
|
|
|
|
test('LA averages are not fetched with force-cache, so one failure is not replayed for good', async () => {
|
|
// force-cache serves any stored response, however old, without asking the
|
|
// server. A request that failed once (a staging deploy restart, the July
|
|
// proxy outage) was stored and replayed on every later visit, and the
|
|
// "vs LA avg" delta vanished from every secondary row in that browser.
|
|
// The default mode honours the API's Cache-Control and never reuses an
|
|
// error.
|
|
params = new URLSearchParams('search=high');
|
|
const secondary: SchoolsResponse = {
|
|
...response('Alpha High'),
|
|
schools: [{ ...primaryFixture.schoolInfo, school_name: 'Alpha High', phase: 'Secondary', attainment_8_score: 50 }],
|
|
};
|
|
render(<HomeView initialSchools={secondary} filters={filters} />);
|
|
await act(async () => {});
|
|
expect(fetchLAaverages).toHaveBeenCalled();
|
|
for (const [options] of jest.mocked(fetchLAaverages).mock.calls) {
|
|
expect(options?.cache).not.toBe('force-cache');
|
|
}
|
|
});
|