Files
school_compare/backend/tests/test_destinations_api.py
T
TudorandClaude Opus 5 102397fe69
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 17s
PR Checks / Build Frontend (no push) (pull_request) Successful in 45s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m14s
PR Checks / AI Code Review (Claude) (pull_request) Failing after 3m49s
fix(destinations): withhold at the API, not just in the chart
Code review found the disclosure the whole design was meant to prevent.
R1 was written as a rendering rule and implemented as one: canRenderBar
stopped the bar being drawn, but GET /api/schools/{urn} still carried the
cohort and every published category. cohort - sum(published) returned
Whitley Bay's withheld further-education figure exactly — 18 pupils — to
any caller, and the RSC payload put it in the browser too.

app.py already stated the principle for admission_distance: this endpoint
is public and unauthenticated, so a field left in the payload is a
published field. The same reasoning applies here and did not get applied.

_mask_for_disclosure now closes both identities before serialisation —
categories sum to the cohort, and disadvantaged + other = all — by adding
secondary suppression until every row and column hides none or at least
two. My first attempt picked the smallest published cell as the companion
and a new test caught it choosing a zero, which protects nothing: the
residual still resolved to 18. The companion must carry pupils.

DfE's own aggregates are no longer served. Nothing rendered them, and one
spanning a single suppressed component names it.

Cost, measured over 262 mainstream secondaries: the all-pupils bar
survives on 94% rather than 100%. Zero lone-suppressed groups remain.

The e2e helper now tells a missing feature apart from missing data: it
fails if the API serves no destinations key at all, and skips if the key
is served but the annual DAG has not populated the marts. Failing on the
second would redden the staging gate for unrelated commits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvdDKvFFSZuMVDH5fEyTob
2026-08-28 19:58:02 +01:00

198 lines
8.3 KiB
Python

"""The destinations serialiser's contract.
Not rendering a figure is not the same as not publishing it. This endpoint is
public and unauthenticated, so whatever the payload carries is published,
whatever the UI draws. The categories sum to the cohort and the pupil groups
sum to each other, so a lone suppressed cell is solvable by subtraction — the
serialiser adds secondary suppression to prevent it.
See docs/superpowers/specs/2026-08-28-destination-measures-design.md.
"""
from backend.data_loader import _destinations_block, _format_cohort_year
def _row(group, measure, pupils, status, cohort=180, percentage=None, year=202223):
return {
"pupil_group": group,
"destination_measure": measure,
"pupils": pupils,
"percentage": percentage,
"status": status,
"cohort_pupils": cohort,
"year": year,
}
def test_suppressed_category_serialises_as_suppressed_with_null_pupils():
rows = [
_row("all", "school_sixth_form", 75, "published", percentage=41.7),
_row("all", "sixth_form_college", None, "suppressed"),
]
block = _destinations_block(rows)
cats = {c["category"]: c for c in block["groups"]["all"]["categories"]}
assert cats["sixth_form_college"]["status"] == "suppressed"
assert cats["sixth_form_college"]["pupils"] is None
assert cats["sixth_form_college"]["percentage"] is None
def test_published_category_keeps_its_figures():
block = _destinations_block([
_row("all", "school_sixth_form", 75, "published", percentage=41.7),
])
cat = block["groups"]["all"]["categories"][0]
assert cat["pupils"] == 75
assert cat["percentage"] == 41.7
assert cat["status"] == "published"
def test_only_the_latest_year_is_served():
rows = [
_row("all", "school_sixth_form", 60, "published", year=202122),
_row("all", "school_sixth_form", 75, "published", year=202223),
]
block = _destinations_block(rows)
assert block["cohort_year"] == "2022/23"
assert len(block["groups"]["all"]["categories"]) == 1
assert block["groups"]["all"]["categories"][0]["pupils"] == 75
def test_all_three_pupil_groups_are_carried():
rows = [
_row("all", "school_sixth_form", 75, "published"),
_row("disadvantaged", "school_sixth_form", 17, "published", cohort=62),
_row("other", "school_sixth_form", 58, "published", cohort=118),
]
block = _destinations_block(rows)
assert set(block["groups"]) == {"all", "disadvantaged", "other"}
assert block["groups"]["disadvantaged"]["cohort"] == 62
def test_cohort_year_is_reported_so_the_page_can_date_itself():
block = _destinations_block([_row("all", "school_sixth_form", 75, "published")])
assert block["cohort_year"] == "2022/23"
def test_format_cohort_year_handles_the_six_digit_form():
assert _format_cohort_year(202223) == "2022/23"
assert _format_cohort_year(None) is None
def test_empty_rows_yield_none_not_an_empty_shell():
assert _destinations_block([]) is None
# ── Disclosure control ──────────────────────────────────────────────────────
#
# The rendering guards in lib/destinations.ts stop a withheld figure being
# DRAWN. They do nothing about it being COMPUTED: this endpoint is public and
# unauthenticated, so whatever the payload carries is published. These tests
# are the ones that matter.
def _solve_residual(group):
"""What any caller can work out: cohort minus everything published."""
published = [c["pupils"] for c in group["categories"] if c["pupils"] is not None]
hidden = [c for c in group["categories"] if c["status"] == "suppressed"]
return group["cohort"] - sum(published), len(hidden)
def test_a_lone_suppressed_category_cannot_be_solved_for():
"""Whitley Bay High School's real 2022/23 disadvantaged group: further
education withheld, everything else published, cohort 41. Before secondary
suppression the payload gave the answer away as 41 - 23 = 18."""
rows = [
_row("disadvantaged", "school_sixth_form", 15, "published", cohort=41),
_row("disadvantaged", "sixth_form_college", 0, "published", cohort=41),
_row("disadvantaged", "further_education", None, "suppressed", cohort=41),
_row("disadvantaged", "apprenticeship", 1, "published", cohort=41),
_row("disadvantaged", "employment", 2, "published", cohort=41),
_row("disadvantaged", "not_sustained", 3, "published", cohort=41),
_row("disadvantaged", "not_captured", 2, "published", cohort=41),
]
group = _destinations_block(rows)["groups"]["disadvantaged"]
residual, hidden = _solve_residual(group)
assert hidden >= 2, "a lone suppressed cell must gain a companion"
assert residual != 18, "the withheld figure is recoverable from the payload"
def test_every_group_hides_none_or_at_least_two_categories():
rows = [
_row("all", "school_sixth_form", 75, "published"),
_row("all", "sixth_form_college", None, "suppressed"),
_row("all", "further_education", 61, "published"),
_row("all", "apprenticeship", 8, "published"),
_row("all", "employment", 6, "published"),
_row("all", "not_sustained", 5, "published"),
_row("all", "not_captured", 4, "published"),
]
group = _destinations_block(rows)["groups"]["all"]
hidden = [c for c in group["categories"] if c["status"] == "suppressed"]
assert len(hidden) >= 2
def test_a_category_hidden_in_one_group_is_hidden_in_a_second():
"""disadvantaged + other = all for every category, so a category withheld
in exactly one of the three is recoverable from the other two."""
rows = []
for measure, a, d, o in [
("school_sixth_form", 75, None, 58),
("further_education", 61, 27, 34),
("apprenticeship", 8, 4, 4),
("employment", 6, 1, 5),
("not_sustained", 5, 3, 2),
("not_captured", 4, 2, 2),
]:
rows.append(_row("all", measure, a, "published", cohort=159))
rows.append(_row("disadvantaged", measure, d,
"published" if d is not None else "suppressed", cohort=37))
rows.append(_row("other", measure, o, "published", cohort=122))
groups = _destinations_block(rows)["groups"]
for measure in ["school_sixth_form"]:
hidden = sum(
1 for key in ("all", "disadvantaged", "other")
for c in groups[key]["categories"]
if c["category"] == measure and c["status"] == "suppressed"
)
assert hidden >= 2, f"{measure} is solvable across the pupil groups"
def test_a_suppressed_cell_never_keeps_its_percentage():
"""percentage / pupils would hand back the cohort, and with it the residual."""
rows = [
_row("all", "school_sixth_form", 75, "published", percentage=41.7),
_row("all", "sixth_form_college", None, "suppressed", percentage=11.7),
_row("all", "further_education", 61, "published", percentage=33.9),
]
group = _destinations_block(rows)["groups"]["all"]
for cell in group["categories"]:
if cell["status"] != "published":
assert cell["pupils"] is None
assert cell["percentage"] is None
def test_aggregates_are_not_served():
"""An aggregate spanning exactly one suppressed component names it, and
nothing renders them today."""
rows = [
_row("all", "school_sixth_form", 75, "published"),
_row("all", "agg_sustained_all", 171, "published"),
]
group = _destinations_block(rows)["groups"]["all"]
assert [c["category"] for c in group["categories"]] == ["school_sixth_form"]
assert "aggregates" not in group
def test_a_fully_published_group_is_left_alone():
"""Secondary suppression must not cost anything where nothing is withheld —
this is the all-pupils view on every mainstream secondary."""
rows = [
_row("all", m, p, "published")
for m, p in [("school_sixth_form", 75), ("sixth_form_college", 21),
("further_education", 61), ("apprenticeship", 8),
("employment", 6), ("not_sustained", 5), ("not_captured", 4)]
]
group = _destinations_block(rows)["groups"]["all"]
assert all(c["status"] == "published" for c in group["categories"])
assert len(group["categories"]) == 7