"""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." ) 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()) 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