diff --git a/backend/app.py b/backend/app.py index 4828a8b..d352de5 100644 --- a/backend/app.py +++ b/backend/app.py @@ -808,7 +808,13 @@ async def get_school_details(request: Request, urn: int): "census": supplementary.get("census"), "admissions": supplementary.get("admissions"), "admissions_history": supplementary.get("admissions_history") or [], - "admission_distance": supplementary.get("admission_distance"), + # Behind a flag, and withheld at the source rather than rendered-but- + # hidden: this endpoint is public and unauthenticated, so a field left + # in the payload is a published field. The key is absent, not null — + # null would state that this school has no cut-off, which is a + # different claim from "we are not publishing cut-offs". + **({"admission_distance": supplementary.get("admission_distance")} + if flags.is_enabled("admission_distance") else {}), "sen_detail": supplementary.get("sen_detail"), "phonics": supplementary.get("phonics"), "deprivation": supplementary.get("deprivation"), diff --git a/backend/tests/test_flags.py b/backend/tests/test_flags.py index 114a219..24fdb53 100644 --- a/backend/tests/test_flags.py +++ b/backend/tests/test_flags.py @@ -93,3 +93,71 @@ def test_the_flags_endpoint_answers_false_when_unleash_is_unreachable(monkeypatc res = _client().get("/api/flags") assert res.status_code == 200 assert all(v is False for v in res.json().values()) + + +def _school_payload(monkeypatch, *, flag_on: bool): + """Fetch one school's payload with the distance flag forced on or off. + + The DataFrame shape is copied from test_school_details.py rather than + minimised: the endpoint reads a wide set of GIAS columns, and a trimmed + frame fails for reasons that have nothing to do with flags. + """ + import numpy as np + import pandas as pd + from fastapi.testclient import TestClient + from backend import app as app_module + + df = pd.DataFrame([{ + "urn": 150275, + "school_name": "West London Performing Arts Academy", + "phase": "Secondary", + "school_type": "Special post 16 institution", + "trust_name": None, + "religious_denomination": "Does not apply", + "gender": None, + "age_range": "16-25", + "admissions_policy": None, + "capacity": np.nan, + "gias_total_pupils": np.nan, + "headteacher_name": None, + "website": None, + "ofsted_grade": np.nan, + "local_authority": "Ealing", + "address": "268 Northfield Avenue, London, W5 4UB", + "postcode": "W5 4UB", + "latitude": 51.4986, + "longitude": -0.3148, + "year": np.nan, + "total_pupils": np.nan, + "eligible_pupils": np.nan, + "rwm_expected_pct": np.nan, + }]) + + monkeypatch.setattr(app_module, "load_school_data", lambda: df) + # Two arguments: get_supplementary_data(db, urn). See backend/app.py. + monkeypatch.setattr( + app_module, "get_supplementary_data", + lambda db, urn: {"admission_distance": {"distance_m": 772.49, + "year": 2024}}) + monkeypatch.setattr(flags, "is_enabled", lambda name: flag_on) + + client = TestClient(app_module.app, raise_server_exceptions=False) + res = client.get("/api/schools/150275") + assert res.status_code == 200, res.text + return res.json() + + +def test_the_distance_field_is_absent_when_the_flag_is_off(monkeypatch): + """Absent, not null, and withheld at the source. + + /api/schools/ is public and unauthenticated. Leaving a withheld field in + the payload while declining to render it hands the record to anyone who + opens the network tab — the reasoning already recorded in c9a1892. + """ + body = _school_payload(monkeypatch, flag_on=False) + assert "admission_distance" not in body + + +def test_the_distance_field_is_present_when_the_flag_is_on(monkeypatch): + body = _school_payload(monkeypatch, flag_on=True) + assert body["admission_distance"]["distance_m"] == 772.49 diff --git a/nextjs-app/lib/types.ts b/nextjs-app/lib/types.ts index 7c1f751..601fd1f 100644 --- a/nextjs-app/lib/types.ts +++ b/nextjs-app/lib/types.ts @@ -361,7 +361,12 @@ export interface SchoolDetailsResponse { * held back as a paid feature and are not part of this public payload — see * data_loader._admission_distance. */ - admission_distance: SchoolAdmissionDistance | null; + /** + * Absent — not null — when the admission_distance flag is off. Null means + * "this school has no published cut-off"; absent means "cut-offs are not + * being published at all". They are different claims and the type says so. + */ + admission_distance?: SchoolAdmissionDistance | null; deprivation: SchoolDeprivation | null; finance: SchoolFinance | null; }