2026-08-21 18:13:05 +01:00
|
|
|
"""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
|
|
|
|
|
|
|
|
|
|
|
2026-08-22 00:09:58 +01:00
|
|
|
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.
|
|
|
|
|
"""
|
2026-08-21 18:13:05 +01:00
|
|
|
body = client.get("/api/places/town/brentwood").json()
|
|
|
|
|
assert body["place"]["name"] == "Brentwood"
|
2026-08-22 00:09:58 +01:00
|
|
|
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]
|
2026-08-21 18:13:05 +01:00
|
|
|
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
|
2026-08-22 17:22:11 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-08-27 08:49:14 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _attributed_df() -> pd.DataFrame:
|
|
|
|
|
"""The same town, with the four attributes the place table now shows."""
|
|
|
|
|
df = _schools_df()
|
|
|
|
|
df["age_range"] = "4-11"
|
|
|
|
|
df["religious_denomination"] = "Church of England"
|
|
|
|
|
df["nursery_provision"] = True
|
|
|
|
|
df["parliamentary_constituency"] = "Brentwood and Ongar"
|
|
|
|
|
return df
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture()
|
|
|
|
|
def attributed_client(monkeypatch):
|
|
|
|
|
from backend import app as app_module
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(app_module, "load_school_data", _attributed_df)
|
|
|
|
|
monkeypatch.setattr(app_module, "load_latest_school_data", _attributed_df)
|
|
|
|
|
monkeypatch.setattr(app_module, "_place_registry", None)
|
|
|
|
|
return TestClient(app_module.app, raise_server_exceptions=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_place_detail_carries_the_attributes_the_table_shows(attributed_client):
|
|
|
|
|
"""age_range and religious_denomination ride in on SCHOOL_COLUMNS.
|
|
|
|
|
|
|
|
|
|
nursery_provision and parliamentary_constituency do not, and the place
|
|
|
|
|
table needs all four — a column the response cannot fill is a column of
|
|
|
|
|
dashes on ~3,900 pages.
|
|
|
|
|
"""
|
|
|
|
|
body = attributed_client.get("/api/places/town/brentwood").json()
|
|
|
|
|
school = body["schools"][0]
|
|
|
|
|
assert school["age_range"] == "4-11"
|
|
|
|
|
assert school["religious_denomination"] == "Church of England"
|
|
|
|
|
assert school["nursery_provision"] is True
|
|
|
|
|
assert school["parliamentary_constituency"] == "Brentwood and Ongar"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_place_detail_survives_a_mart_without_the_optional_columns(client):
|
|
|
|
|
"""The base fixture has neither column, as an unrebuilt mart does not.
|
|
|
|
|
|
|
|
|
|
data_loader degrades those to NULL rather than failing the load, so the
|
|
|
|
|
endpoint must not assume they are present.
|
|
|
|
|
"""
|
|
|
|
|
res = client.get("/api/places/town/brentwood")
|
|
|
|
|
assert res.status_code == 200
|
|
|
|
|
assert "nursery_provision" not in res.json()["schools"][0]
|