2026-08-23 10:52:55 +01:00
|
|
|
"""Tests for the feature flag layer (spec 2026-08-23).
|
|
|
|
|
|
|
|
|
|
None of these need a running Unleash. That is the point: an unset UNLEASH_URL
|
|
|
|
|
means every flag is False, which is what local development and CI get.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from datetime import date, timedelta
|
|
|
|
|
|
|
|
|
|
from backend import flags
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_every_declared_flag_is_keyed_by_its_own_name():
|
|
|
|
|
# One string is the registry key, the Unleash flag name and the JSON key.
|
|
|
|
|
# A mismatch here would mean the UI toggles a flag the code never reads.
|
|
|
|
|
for key, flag in flags.REGISTRY.items():
|
|
|
|
|
assert key == flag.name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_flag_names_are_snake_case():
|
|
|
|
|
# Matches the API's existing convention (admission_distance,
|
|
|
|
|
# rwm_expected_pct) so no case transformation exists to get wrong.
|
|
|
|
|
for name in flags.REGISTRY:
|
|
|
|
|
assert name == name.lower()
|
|
|
|
|
assert "-" not in name and " " not in name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_an_unconfigured_client_evaluates_every_flag_false(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(flags, "_client", None)
|
|
|
|
|
for name in flags.REGISTRY:
|
|
|
|
|
assert flags.is_enabled(name) is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_an_undeclared_flag_is_false_rather_than_an_error(monkeypatch):
|
|
|
|
|
# A typo'd flag name must not raise in a request path. It is logged as an
|
|
|
|
|
# error, because an undeclared flag is always a bug.
|
|
|
|
|
monkeypatch.setattr(flags, "_client", None)
|
|
|
|
|
assert flags.is_enabled("no_such_flag") is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_an_exploding_client_is_false_rather_than_a_500(monkeypatch):
|
|
|
|
|
class Boom:
|
|
|
|
|
def is_enabled(self, *a, **kw):
|
|
|
|
|
raise RuntimeError("unleash is on fire")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(flags, "_client", Boom())
|
|
|
|
|
name = next(iter(flags.REGISTRY))
|
|
|
|
|
assert flags.is_enabled(name) is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_all_flags_reports_every_declared_flag(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(flags, "_client", None)
|
|
|
|
|
assert set(flags.all_flags()) == set(flags.REGISTRY)
|
|
|
|
|
assert all(v is False for v in flags.all_flags().values())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_a_flag_older_than_the_limit_fails_this_test():
|
|
|
|
|
"""A tripwire, not an assertion about correctness.
|
|
|
|
|
|
|
|
|
|
Flags are temporary scaffolding and the failure mode of every flag system
|
|
|
|
|
is accumulation. This fails on the day a flag turns 90, on whatever PR
|
|
|
|
|
happens to be open — which is the point: someone has to decide.
|
|
|
|
|
|
|
|
|
|
To fix: delete the flag and the branches that read it, or, if it genuinely
|
|
|
|
|
still needs to exist, move its `added` date and say why in the commit.
|
|
|
|
|
"""
|
|
|
|
|
stale = [
|
|
|
|
|
f.name for f in flags.REGISTRY.values()
|
|
|
|
|
if date.today() - f.added > timedelta(days=flags.MAX_FLAG_AGE_DAYS)
|
|
|
|
|
]
|
|
|
|
|
assert not stale, (
|
|
|
|
|
f"Flags older than {flags.MAX_FLAG_AGE_DAYS} days: {stale}. "
|
|
|
|
|
"Remove the flag and the code branches it guards, or move its `added` "
|
|
|
|
|
"date deliberately."
|
|
|
|
|
)
|
2026-08-23 10:54:26 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _client():
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
from backend import app as app_module
|
|
|
|
|
return TestClient(app_module.app, raise_server_exceptions=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_flags_endpoint_lists_every_declared_flag(monkeypatch):
|
|
|
|
|
monkeypatch.setattr(flags, "_client", None)
|
|
|
|
|
body = _client().get("/api/flags").json()
|
|
|
|
|
assert set(body) == set(flags.REGISTRY)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_the_flags_endpoint_answers_false_when_unleash_is_unreachable(monkeypatch):
|
|
|
|
|
# The endpoint must still answer. A frontend that cannot read flags renders
|
|
|
|
|
# everything dark, which is right; one that gets a 500 renders nothing.
|
|
|
|
|
monkeypatch.setattr(flags, "_client", None)
|
|
|
|
|
res = _client().get("/api/flags")
|
|
|
|
|
assert res.status_code == 200
|
|
|
|
|
assert all(v is False for v in res.json().values())
|
2026-08-23 10:56:33 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|