"""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 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]