PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 8s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m37s
Every place page built its phase links as /schools/[slug]/[phase], the shape that belongs to towns alone. On an authority page that pointed into the town namespace. For 87 of the 151 authorities the target does not exist and the link 404s; for the other 64 it resolves to the town of the same name — a different set of schools, which is precisely the near-duplicate the two namespaces were introduced to prevent. On an outcode page it 404s outright. Two causes behind it, both a rule written twice and inherited by only one of the places that needed it. The authority phase route was in the spec and dropped by the plan, which built the three bare routes and no fourth. The sitemap is generated from the place registry, which was right about them all along, so 302 authority phase URLs have been submitted to Google and every one 404s. Adding the route makes the sitemap true and serves a real query — admissions are authority-run, so "primary schools in Kent" is how a parent searches before they have settled on a town. The outcode variants were the opposite: the registry computed phases for outcodes although the spec gives them no route, and the sitemap knew to skip them while the API did not. The registry now decides alone, and the sitemap's duplicate of that rule is gone. Also: an authority under the five-school threshold has no page, so the API sends a null slug for it and the page names it without linking. Two English authorities are in that position. It was unreachable in today's data — verified across the EC and TR outcodes — but the thin place redirect would have sent a reader to a 404 the year it isn't. The e2e journey now walks every /schools link a page of each family emits and requires a 200, which is the check that was missing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
140 lines
5.2 KiB
Python
140 lines
5.2 KiB
Python
"""Tests for the places API (spec 2026-08-21)."""
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
def _schools_df() -> pd.DataFrame:
|
|
base = {
|
|
"local_authority": "Essex", "school_type": "Academy",
|
|
"phase": "Primary", "year": 202425, "ofsted_grade": 2.0,
|
|
"ofsted_date": None, "attainment_8_score": np.nan,
|
|
"town": "Brentwood", "postcode": "CM13 1AA", "status": "Open",
|
|
"address": "1 Test Street", "latitude": 51.6, "longitude": 0.3,
|
|
}
|
|
return pd.DataFrame([
|
|
{**base, "urn": 100000 + i, "school_name": f"Brentwood School {i}",
|
|
"rwm_expected_pct": 50.0 + i}
|
|
for i in range(6)
|
|
])
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(monkeypatch):
|
|
from backend import app as app_module
|
|
|
|
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
|
|
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
|
|
monkeypatch.setattr(app_module, "_place_registry", None)
|
|
return TestClient(app_module.app, raise_server_exceptions=False)
|
|
|
|
|
|
def test_registry_lists_each_published_place(client):
|
|
body = client.get("/api/places").json()
|
|
slugs = {(p["kind"], p["slug"]) for p in body["places"]}
|
|
assert ("town", "brentwood") in slugs
|
|
assert ("authority", "essex") in slugs
|
|
assert ("outcode", "cm13") in slugs
|
|
|
|
|
|
def test_registry_carries_a_count_per_place(client):
|
|
body = client.get("/api/places").json()
|
|
town = next(p for p in body["places"] if p["slug"] == "brentwood")
|
|
assert town["count"] == 6
|
|
|
|
|
|
def test_place_detail_returns_its_schools_alphabetically(client):
|
|
"""A place page is read by someone looking for a school they can name.
|
|
|
|
Scanning for it is what the order should serve, so the list is A-Z.
|
|
/api/rankings is where the league-table ordering lives.
|
|
"""
|
|
body = client.get("/api/places/town/brentwood").json()
|
|
assert body["place"]["name"] == "Brentwood"
|
|
names = [s["school_name"] for s in body["schools"]]
|
|
assert names == sorted(names, key=str.lower)
|
|
|
|
|
|
def test_place_ordering_ignores_case(client):
|
|
body = client.get("/api/places/town/brentwood").json()
|
|
names = [s["school_name"] for s in body["schools"]]
|
|
# A capitalised name must not sort ahead of every lowercase one.
|
|
assert names == sorted(names, key=str.lower)
|
|
|
|
|
|
def test_the_rankings_endpoint_still_ranks_by_metric(client):
|
|
# Alphabetical is a place-page decision, not a site-wide one.
|
|
body = client.get("/api/rankings?metric=rwm_expected_pct&phase=primary").json()
|
|
scores = [r["rwm_expected_pct"] for r in body.get("rankings", [])
|
|
if r.get("rwm_expected_pct") is not None]
|
|
assert scores == sorted(scores, reverse=True)
|
|
|
|
|
|
def test_place_detail_carries_the_local_average(client):
|
|
body = client.get("/api/places/town/brentwood").json()
|
|
# 50..55 inclusive
|
|
assert body["averages"]["rwm_expected_pct"] == pytest.approx(52.5)
|
|
|
|
|
|
def test_phase_filter_narrows_the_school_list(client):
|
|
body = client.get("/api/places/town/brentwood?phase=secondary").json()
|
|
assert body["schools"] == []
|
|
|
|
|
|
def test_unknown_place_404s(client):
|
|
assert client.get("/api/places/town/atlantis").status_code == 404
|
|
|
|
|
|
def test_unknown_kind_404s(client):
|
|
assert client.get("/api/places/planet/mars").status_code == 404
|
|
|
|
|
|
def _straddling_df() -> pd.DataFrame:
|
|
"""Eight schools in CM13: six in Essex, which has a page, and two in an
|
|
authority too small to have one.
|
|
|
|
Two, not one: the registry ignores an authority holding a single school in
|
|
a place, because GIAS carries occasional postcode errors."""
|
|
df = _schools_df()
|
|
extra = df.iloc[:2].copy()
|
|
extra["urn"] = [200000, 200001]
|
|
extra["school_name"] = ["Scilly School 0", "Scilly School 1"]
|
|
extra["local_authority"] = "Isles Of Scilly"
|
|
return pd.concat([df, extra], ignore_index=True)
|
|
|
|
|
|
@pytest.fixture()
|
|
def straddling_client(monkeypatch):
|
|
from backend import app as app_module
|
|
|
|
monkeypatch.setattr(app_module, "load_school_data", _straddling_df)
|
|
monkeypatch.setattr(app_module, "load_latest_school_data", _straddling_df)
|
|
monkeypatch.setattr(app_module, "_place_registry", None)
|
|
return TestClient(app_module.app, raise_server_exceptions=False)
|
|
|
|
|
|
def test_an_outcode_reports_no_phases_because_it_has_no_phase_route(client):
|
|
body = client.get("/api/places/outcode/cm13").json()
|
|
assert body["place"]["phases"] == []
|
|
|
|
|
|
def test_an_authority_reports_the_phases_it_publishes(client):
|
|
body = client.get("/api/places/authority/essex").json()
|
|
assert body["place"]["phases"] == ["primary"]
|
|
|
|
|
|
def test_an_authority_without_a_page_is_named_but_carries_no_slug(straddling_client):
|
|
"""Two English authorities — City of London and the Isles of Scilly — hold
|
|
fewer than the five schools a page needs, so they have no page.
|
|
|
|
Naming them is still right: the page says where the place is. Linking them
|
|
would not be. A null slug is what tells the page to print the name plainly
|
|
rather than invent a URL that 404s.
|
|
"""
|
|
body = straddling_client.get("/api/places/outcode/cm13").json()
|
|
by_name = {a["name"]: a for a in body["place"]["authorities"]}
|
|
assert by_name["Essex"]["slug"] == "essex"
|
|
assert by_name["Isles Of Scilly"]["slug"] is None
|