2026-07-07 10:36:39 +01:00
|
|
|
"""Tests for the GIAS-driven has_sixth_form flag (spec 2026-07-07 §3).
|
|
|
|
|
|
|
|
|
|
The filter and payloads must use dim_school.has_sixth_form, not the old
|
|
|
|
|
age_range-contains-"18" substring heuristic. The key regression case is a
|
|
|
|
|
16-19 sixth-form college: flag true, but "16-19" contains no "18".
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
import pandas as pd
|
|
|
|
|
import pytest
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _schools_df() -> pd.DataFrame:
|
|
|
|
|
"""Latest-year snapshot rows as produced by load_latest_school_data."""
|
|
|
|
|
base = {
|
|
|
|
|
"local_authority": "Testshire",
|
|
|
|
|
"school_type": "Academy",
|
|
|
|
|
"phase": "Secondary",
|
|
|
|
|
"address": "1 Test Street",
|
|
|
|
|
"town": "Testtown",
|
|
|
|
|
"postcode": "TS1 1AA",
|
|
|
|
|
"religious_denomination": None,
|
|
|
|
|
"gender": "Mixed",
|
|
|
|
|
"admissions_policy": None,
|
|
|
|
|
"ofsted_grade": np.nan,
|
|
|
|
|
"ofsted_date": None,
|
|
|
|
|
"ofsted_framework": None,
|
|
|
|
|
"latitude": 51.5,
|
|
|
|
|
"longitude": -0.1,
|
|
|
|
|
"year": 202425,
|
|
|
|
|
"total_pupils": 1000,
|
|
|
|
|
"rwm_expected_pct": np.nan,
|
|
|
|
|
"attainment_8_score": 50.0,
|
|
|
|
|
}
|
|
|
|
|
return pd.DataFrame(
|
|
|
|
|
[
|
|
|
|
|
# 11-18 school WITH a registered sixth form
|
|
|
|
|
{**base, "urn": 100001, "school_name": "Alpha High",
|
|
|
|
|
"age_range": "11-18", "has_sixth_form": True},
|
|
|
|
|
# 16-19 college: old heuristic said NO ("16-19" has no "18"),
|
|
|
|
|
# GIAS flag says YES — must appear in the yes-filter results
|
|
|
|
|
{**base, "urn": 100002, "school_name": "Beta Sixth Form College",
|
|
|
|
|
"age_range": "16-19", "has_sixth_form": True},
|
|
|
|
|
# 11-18 age range on paper but NO registered sixth form:
|
|
|
|
|
# old heuristic said YES, GIAS flag says NO
|
|
|
|
|
{**base, "urn": 100003, "school_name": "Gamma Academy",
|
|
|
|
|
"age_range": "11-18", "has_sixth_form": False},
|
|
|
|
|
# Missing flag (pipeline not yet re-run) — must not crash,
|
|
|
|
|
# must not match the yes-filter
|
|
|
|
|
{**base, "urn": 100004, "school_name": "Delta School",
|
|
|
|
|
"age_range": "11-16", "has_sixth_form": None},
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture()
|
|
|
|
|
def client(monkeypatch):
|
|
|
|
|
from backend import app as app_module
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(app_module, "load_latest_school_data", _schools_df)
|
|
|
|
|
monkeypatch.setattr(app_module, "load_school_data", _schools_df)
|
|
|
|
|
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
|
|
|
|
|
return TestClient(app_module.app, raise_server_exceptions=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _urns(resp):
|
|
|
|
|
return sorted(s["urn"] for s in resp.json()["schools"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_filter_yes_uses_flag_not_age_range(client):
|
|
|
|
|
resp = client.get("/api/schools?has_sixth_form=yes")
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
# 16-19 college included; 11-18-without-sixth-form excluded
|
|
|
|
|
assert _urns(resp) == [100001, 100002]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_filter_no_uses_flag_not_age_range(client):
|
|
|
|
|
resp = client.get("/api/schools?has_sixth_form=no")
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
# Gamma (flag false) and Delta (flag missing => not true)
|
|
|
|
|
assert _urns(resp) == [100003, 100004]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_list_payload_includes_flag(client):
|
|
|
|
|
resp = client.get("/api/schools")
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
by_urn = {s["urn"]: s for s in resp.json()["schools"]}
|
|
|
|
|
assert by_urn[100002]["has_sixth_form"] is True
|
|
|
|
|
assert by_urn[100003]["has_sixth_form"] is False
|
|
|
|
|
assert by_urn[100004]["has_sixth_form"] is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_detail_payload_includes_flag(client):
|
|
|
|
|
resp = client.get("/api/schools/100002")
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
assert resp.json()["school_info"]["has_sixth_form"] is True
|
2026-07-07 13:33:25 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_detail_payload_serializes_numpy_bool(monkeypatch):
|
|
|
|
|
"""Once the pipeline has run, has_sixth_form is a real bool dtype column
|
|
|
|
|
(dbt not_null test guarantees no NULLs), so row access yields
|
|
|
|
|
numpy.bool_ rather than a Python bool. convert_to_native must handle it —
|
|
|
|
|
otherwise FastAPI's jsonable_encoder raises ValueError and the detail
|
|
|
|
|
endpoint 500s (C2)."""
|
|
|
|
|
from backend import app as app_module
|
|
|
|
|
|
|
|
|
|
df = _schools_df()
|
|
|
|
|
# Drop the row with a None flag — this fixture models the post-pipeline
|
|
|
|
|
# state where the column is a genuine, fully-populated bool dtype.
|
|
|
|
|
df = df[df["has_sixth_form"].notna()].reset_index(drop=True)
|
|
|
|
|
df["has_sixth_form"] = df["has_sixth_form"].astype(bool)
|
|
|
|
|
assert df["has_sixth_form"].dtype == bool
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(app_module, "load_school_data", lambda: df)
|
|
|
|
|
monkeypatch.setattr(app_module, "get_supplementary_data", lambda db, urn: {})
|
|
|
|
|
client = TestClient(app_module.app, raise_server_exceptions=False)
|
|
|
|
|
|
|
|
|
|
resp = client.get("/api/schools/100002")
|
|
|
|
|
assert resp.status_code == 200, resp.text
|
|
|
|
|
assert resp.json()["school_info"]["has_sixth_form"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_load_school_data_survives_missing_has_sixth_form_column(monkeypatch):
|
|
|
|
|
"""Real prod state until the nightly pipeline first rebuilds the mart:
|
|
|
|
|
marts.dim_school lacks has_sixth_form entirely. The first query raises
|
|
|
|
|
UndefinedColumn; load_school_data_as_dataframe must retry without the
|
|
|
|
|
column (synthesizing it as None) rather than swallow the error and
|
|
|
|
|
return (and then have load_school_data cache) an empty DataFrame (C1)."""
|
|
|
|
|
import sqlalchemy.exc
|
|
|
|
|
from backend import data_loader
|
|
|
|
|
|
|
|
|
|
data_loader._df_cache = None
|
|
|
|
|
data_loader._df_latest_cache = None
|
|
|
|
|
|
|
|
|
|
good_df = pd.DataFrame(
|
|
|
|
|
[
|
|
|
|
|
{
|
|
|
|
|
"urn": 1,
|
|
|
|
|
"school_name": "Fallback School",
|
|
|
|
|
"school_type": "Academy",
|
|
|
|
|
"has_sixth_form": None,
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
calls = []
|
|
|
|
|
|
|
|
|
|
def fake_read_sql(query, con):
|
|
|
|
|
calls.append(query)
|
|
|
|
|
if len(calls) == 1:
|
2026-07-09 19:29:58 +01:00
|
|
|
# The statement text still contains phase_code, school_type_code,
|
|
|
|
|
# etc. (it's the full _MAIN_QUERY SELECT list) — that's exactly
|
|
|
|
|
# the collision this test guards against: matching must be done
|
|
|
|
|
# against exc.orig (the DBAPI error), not str(exc)/the statement.
|
2026-07-07 13:33:25 +01:00
|
|
|
raise sqlalchemy.exc.ProgrammingError(
|
2026-07-09 19:29:58 +01:00
|
|
|
statement=str(data_loader._MAIN_QUERY),
|
|
|
|
|
params=None,
|
|
|
|
|
orig=Exception(
|
2026-07-07 13:33:25 +01:00
|
|
|
"(psycopg2.errors.UndefinedColumn) column s.has_sixth_form "
|
|
|
|
|
"does not exist"
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
return good_df.copy()
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(data_loader.pd, "read_sql", fake_read_sql)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
df = data_loader.load_school_data_as_dataframe()
|
|
|
|
|
finally:
|
|
|
|
|
data_loader._df_cache = None
|
|
|
|
|
data_loader._df_latest_cache = None
|
|
|
|
|
|
|
|
|
|
assert len(calls) == 2, "must retry with the no-sixth-form query variant"
|
|
|
|
|
assert calls[1] is data_loader._MAIN_QUERY_NO_SIXTH_FORM
|
|
|
|
|
assert not df.empty
|
|
|
|
|
assert "has_sixth_form" in df.columns
|
|
|
|
|
assert df["has_sixth_form"].iloc[0] is None
|