Files
6krrt/tests/test_admin_runtime.py
adlee-was-taken e08560ea40 fix(admin): test degraded_warn_threshold bounds, restore challenger dial hints
POST /admin/api/runtime/classifier_degraded_warn_threshold with null is now
pinned as a clean 422 (it was a TypeError/500 before the neutral_path guard).
Covered: null, bool, string, 0.0, below-floor and above-max all 422 and leave
cfg untouched; floor, 0.2 and max return 200 and update cfg.

The float handler's 422 text had lost "or null for neutral" and "(null means
neutral, not 0.0)" for every float knob, including the challenger dial, which
does have a neutral. They are back, conditional on the knob having a
neutral_path, and degraded_warn_threshold's 422s never mention null. Tests pin
both directions.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkCGRantZsSwmcFpet6FTa
2026-10-05 01:43:40 -04:00

675 lines
26 KiB
Python

"""Tests for the /admin/api runtime config toggle endpoints.
``admin.py`` exposes GET /admin/api/runtime (persisted + in-memory state for
each toggle knob) and POST /admin/api/runtime/{knob} (flip the in-memory cfg
value only, never config.yaml). These tests drive a real TestClient against the
seeded temp DB, mirroring ``test_admin_health.py``'s ``seeded_client`` fixture.
The key contract under test: a POST flips the RUNTIME value but must leave the
PERSISTED (config.yaml) value untouched.
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
import pytest
from starlette.testclient import TestClient
import dispatcher
from admin import _INT_KNOBS
from config import (
DEGRADED_WARN_THRESHOLD_FLOOR,
DEGRADED_WARN_THRESHOLD_MAX,
STALENESS_SECONDS_MAX,
STALENESS_SECONDS_MIN,
load_config,
)
ROOT = Path(__file__).resolve().parent.parent
SCHEMA_SQL = (ROOT / "config" / "schema.sql").read_text()
CFG = load_config(str(ROOT / "config" / "config.yaml"))
def _make_db(tmp_path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(str(tmp_path / "test.db"))
conn.row_factory = sqlite3.Row
conn.executescript(SCHEMA_SQL)
return conn
def _seed_models(conn: sqlite3.Connection) -> None:
for model_id, tier, context, cost, vision in (
("cheap", 2, 262128, 0.30, 1),
("dear", 2, 262128, 9.00, 0),
("tiny", 1, 131072, 0.10, 1),
):
conn.execute(
"""
INSERT INTO models (
model_id, provider, base_model_id, tier, context_window,
effective_context_window, max_output_tokens,
cost_per_1m_prompt, cost_per_1m_completion,
supports_vision, supports_json_mode,
latency_class, reasoning_mode, context_variant,
access_level, availability, last_updated
) VALUES (?, 'neuralwatt', ?, ?, ?, 192500, 16384, ?, ?,
?, 1, 'standard', 'default', 'full', 'public', 'active',
'2026-08-22T00:00:00+00:00')
""",
(model_id, model_id, tier, context, cost, cost / 3, vision),
)
conn.commit()
@pytest.fixture
def seeded_client(tmp_path, monkeypatch):
"""A TestClient wired to a seeded temp DB, at /admin."""
conn = _make_db(tmp_path)
_seed_models(conn)
conn.close()
monkeypatch.setattr(dispatcher.cfg.database, "path", str(tmp_path / "test.db"))
monkeypatch.setattr(dispatcher.cfg.verification, "local_llm_enabled", False)
monkeypatch.setattr(dispatcher.cfg.routing, "require_vision", False)
monkeypatch.setenv("NEURALWATT_API_KEY", "test-key")
with TestClient(dispatcher.app) as client:
yield client
def test_runtime_GET_reports_every_knob(seeded_client):
"""GET /admin/api/runtime reports persisted + runtime for every knob."""
resp = seeded_client.get("/admin/api/runtime")
assert resp.status_code == 200
body = resp.json()
for knob in (
"log_route_decisions",
"log_energy_observations",
"circuit_breaker_enabled",
"local_llm_enabled",
"session_cache_enabled",
"pinch_enabled",
"pinch_prefix_probe",
"pinch_relevance_enabled",
"incumbent_cache_pricing",
"incumbent_challenger_cache_rate",
"session_cache_staleness_seconds",
"default_flex_preference",
):
assert knob in body
assert set(body[knob]) == {"persisted", "runtime", "config_key", "category", "advanced"}
# The runtime value mirrors the live dispatcher.cfg for the boolean knobs;
# the persisted value mirrors config.yaml. circuit_breaker_enabled is a
# flat bool like every other knob (used to be nested {"enabled": ...}
# for no reason, which was why the admin UI never rendered a toggle for
# it — fixed alongside this test).
assert body["log_route_decisions"]["runtime"] == dispatcher.cfg.logging.log_route_decisions
assert body["log_route_decisions"]["persisted"] == CFG.logging.log_route_decisions
assert body["circuit_breaker_enabled"] == {
"persisted": CFG.circuit_breaker.enabled,
"runtime": dispatcher.cfg.circuit_breaker.enabled,
"config_key": "circuit_breaker.enabled",
"category": "safety_nets",
"advanced": False,
}
assert body["default_flex_preference"] == {
"persisted": CFG.routing.default_flex_preference.value,
"runtime": dispatcher.cfg.routing.default_flex_preference.value,
"config_key": "routing.default_flex_preference",
"category": "routing_quality_cost",
"advanced": False,
}
def test_toggle_log_route_decisions_flips_runtime_not_persisted(
seeded_client, monkeypatch
):
"""POST a boolean knob flips the runtime value; config.yaml is untouched."""
# Guard shared global state: record the original so it restores after this
# test, since the POST mutates dispatcher.cfg in place.
monkeypatch.setattr(
dispatcher.cfg.logging,
"log_route_decisions",
dispatcher.cfg.logging.log_route_decisions,
)
initial = seeded_client.get("/admin/api/runtime").json()
assert initial["log_route_decisions"]["runtime"] is True
resp = seeded_client.post(
"/admin/api/runtime/log_route_decisions", json={"value": False}
)
assert resp.status_code == 200
after = seeded_client.get("/admin/api/runtime").json()
# Runtime flipped ...
assert after["log_route_decisions"]["runtime"] is False
# ... but persisted is unchanged and still matches config.yaml.
assert after["log_route_decisions"]["persisted"] == CFG.logging.log_route_decisions
assert after["log_route_decisions"]["persisted"] == initial["log_route_decisions"][
"persisted"
]
def test_toggle_pinch_prefix_probe_round_trips(seeded_client, monkeypatch):
"""pinch.prefix_probe reads, sets and reads back through the runtime knob.
The knob was deliberately left off ``_BOOL_KNOBS`` once, on the grounds
that a toggle whose only effect is to stop collecting evidence is odd UX.
The standing rule won: a knob reachable only by hand-editing
config.local.yaml is invisible to whoever operates the router. The UI
carries the warning instead.
"""
# Guard shared global state: the POST mutates dispatcher.cfg in place.
monkeypatch.setattr(
dispatcher.cfg.pinch, "prefix_probe", dispatcher.cfg.pinch.prefix_probe
)
initial = seeded_client.get("/admin/api/runtime").json()
assert initial["pinch_prefix_probe"]["runtime"] is True
resp = seeded_client.post(
"/admin/api/runtime/pinch_prefix_probe", json={"value": False}
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "pinch_prefix_probe": False}
after = seeded_client.get("/admin/api/runtime").json()
assert after["pinch_prefix_probe"]["runtime"] is False
assert dispatcher.cfg.pinch.prefix_probe is False
# config.yaml is untouched by a runtime flip.
assert after["pinch_prefix_probe"]["persisted"] == CFG.pinch.prefix_probe
def test_pinch_prefix_probe_rejects_a_non_boolean(seeded_client):
"""A string is refused rather than written straight onto cfg.
``_set_at`` bypasses every Pydantic validator (StrictModel sets
extra=forbid, not validate_assignment), so the endpoint's own type check
is the only thing standing between a bad body and a cfg field that reads
truthy forever.
"""
before = dispatcher.cfg.pinch.prefix_probe
resp = seeded_client.post(
"/admin/api/runtime/pinch_prefix_probe", json={"value": "false"}
)
assert resp.status_code == 422
assert "expects a boolean" in resp.json()["detail"]
assert dispatcher.cfg.pinch.prefix_probe is before
@pytest.fixture
def incumbent_cfg_guard(monkeypatch):
"""Restore both incumbent knobs on cfg after a test mutates them."""
obj = dispatcher.cfg.objective
monkeypatch.setattr(obj, "incumbent_cache_pricing", obj.incumbent_cache_pricing)
monkeypatch.setattr(
obj,
"incumbent_challenger_cache_rate",
obj.incumbent_challenger_cache_rate,
)
return obj
def test_toggle_incumbent_cache_pricing_round_trips(
seeded_client, incumbent_cfg_guard
):
"""The Wave 2 gate flips in memory, which is the whole point of the dial.
Without a runtime knob the feature can only be tuned by hand-editing
config.local.yaml and bouncing the service, and the acceptance procedure
for it assumes repeated changes with a re-measurement between each. Nobody
walks that loop through a restart.
"""
assert incumbent_cfg_guard.incumbent_cache_pricing is False
resp = seeded_client.post(
"/admin/api/runtime/incumbent_cache_pricing", json={"value": True}
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "incumbent_cache_pricing": True}
after = seeded_client.get("/admin/api/runtime").json()
assert after["incumbent_cache_pricing"]["runtime"] is True
assert dispatcher.cfg.objective.incumbent_cache_pricing is True
# config.yaml stays off — a runtime flip never persists.
assert after["incumbent_cache_pricing"]["persisted"] is False
def test_incumbent_cache_pricing_rejects_a_non_boolean(
seeded_client, incumbent_cfg_guard
):
"""``_set_at`` bypasses Pydantic, so the endpoint's type check is the guard."""
resp = seeded_client.post(
"/admin/api/runtime/incumbent_cache_pricing", json={"value": "true"}
)
assert resp.status_code == 422
assert "expects a boolean" in resp.json()["detail"]
assert dispatcher.cfg.objective.incumbent_cache_pricing is False
def test_challenger_dial_sets_a_real_float(seeded_client, incumbent_cfg_guard):
"""0.0 is accepted and lands on cfg as a genuine float, not a string."""
resp = seeded_client.post(
"/admin/api/runtime/incumbent_challenger_cache_rate", json={"value": 0.0}
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "incumbent_challenger_cache_rate": 0.0}
stored = dispatcher.cfg.objective.incumbent_challenger_cache_rate
assert isinstance(stored, float) and not isinstance(stored, bool)
assert stored == 0.0
assert (
seeded_client.get("/admin/api/runtime").json()[
"incumbent_challenger_cache_rate"
]["runtime"]
== 0.0
)
def test_blank_challenger_dial_is_neutral_not_zero(
seeded_client, incumbent_cfg_guard
):
"""null means NEUTRAL. It is the opposite end of the dial from 0.0.
This is the whole trap. ``null`` follows ``assumed_cache_rate``, so
challengers pay nothing for switching; ``0.0`` prices them as fully cold
prompts, the MAXIMUM penalty. An operator clearing the field to turn the
feature off must not get it turned all the way up, so a blank must not
become a zero anywhere between the input and cfg.
"""
assumed = dispatcher.cfg.objective.assumed_cache_rate
assert assumed != 0.0, "fixture would not distinguish neutral from full"
# Walk the dial to full penalty first, so the neutral write has to undo it.
seeded_client.post(
"/admin/api/runtime/incumbent_challenger_cache_rate", json={"value": 0.0}
)
assert dispatcher.cfg.objective.incumbent_challenger_cache_rate == 0.0
resp = seeded_client.post(
"/admin/api/runtime/incumbent_challenger_cache_rate", json={"value": None}
)
assert resp.status_code == 200
assert resp.json() == {
"ok": True,
"incumbent_challenger_cache_rate": assumed,
}
# Resolved, never stored as None: config.py's model validator makes the
# same substitution at load, and docs/routing.md promises downstream code
# never sees two representations of neutral.
assert dispatcher.cfg.objective.incumbent_challenger_cache_rate == assumed
@pytest.mark.parametrize("bad", [-0.1, 1.5, 100])
def test_challenger_dial_refuses_values_outside_zero_to_one(
seeded_client, incumbent_cfg_guard, bad
):
"""A cache RATE lives in [0, 1]; 100 is the confidence_threshold mistake.
That incident wrote a raw ``80`` meaning 80% into the overlay for a field
the loader wanted as 0.0-1.0, and it was caught before the restart only by
luck. Here the range check is in the endpoint, because a runtime write
never reaches a Pydantic validator at all.
"""
before = dispatcher.cfg.objective.incumbent_challenger_cache_rate
resp = seeded_client.post(
"/admin/api/runtime/incumbent_challenger_cache_rate", json={"value": bad}
)
assert resp.status_code == 422
assert "must be in [0.0, 1.0]" in resp.json()["detail"]
assert dispatcher.cfg.objective.incumbent_challenger_cache_rate == before
@pytest.mark.parametrize("bad", ["0.5", True, [0.5]])
def test_challenger_dial_refuses_a_non_number(
seeded_client, incumbent_cfg_guard, bad
):
"""A bool is the sharp one: True is an int in Python and would read 1.0."""
before = dispatcher.cfg.objective.incumbent_challenger_cache_rate
resp = seeded_client.post(
"/admin/api/runtime/incumbent_challenger_cache_rate", json={"value": bad}
)
assert resp.status_code == 422
assert "expects a number" in resp.json()["detail"]
assert dispatcher.cfg.objective.incumbent_challenger_cache_rate == before
def test_challenger_dial_422_still_advertises_null_as_neutral(
seeded_client, incumbent_cfg_guard
):
"""The dial HAS a neutral, so its 422s must keep telling the operator so.
These hints were dropped once when a second float knob without a neutral
shared the handler; they are conditional on the knob having one.
"""
url = "/admin/api/runtime/incumbent_challenger_cache_rate"
not_a_number = seeded_client.post(url, json={"value": "x"})
assert not_a_number.status_code == 422
assert "or null for neutral" in not_a_number.json()["detail"]
out_of_range = seeded_client.post(url, json={"value": 1.5})
assert out_of_range.status_code == 422
assert "(null means neutral, not 0.0)" in out_of_range.json()["detail"]
@pytest.fixture
def degraded_threshold_cfg_guard(monkeypatch):
"""Restore classifier.degraded_warn_threshold after a test writes it."""
classifier = dispatcher.cfg.classifier
monkeypatch.setattr(
classifier, "degraded_warn_threshold", classifier.degraded_warn_threshold
)
return classifier
DEGRADED_THRESHOLD_URL = "/admin/api/runtime/classifier_degraded_warn_threshold"
@pytest.mark.parametrize(
"bad",
[
None,
True,
"x",
0.0,
DEGRADED_WARN_THRESHOLD_FLOOR / 2,
DEGRADED_WARN_THRESHOLD_MAX + 0.5,
],
ids=["null", "bool", "string", "zero", "below-floor", "above-max"],
)
def test_degraded_warn_threshold_refuses_bad_values_with_422(
seeded_client, degraded_threshold_cfg_guard, bad
):
"""A runtime write skips Pydantic, so the registry bounds are the only guard.
``null`` is the sharp one: this knob has no neutral, and the float handler
used to resolve null through ``_get_at(cfg, neutral_path)``, which raised
TypeError (HTTP 500) when that path is None.
"""
before = degraded_threshold_cfg_guard.degraded_warn_threshold
resp = seeded_client.post(DEGRADED_THRESHOLD_URL, json={"value": bad})
assert resp.status_code == 422
assert degraded_threshold_cfg_guard.degraded_warn_threshold == before
def test_degraded_warn_threshold_422_never_advertises_null(
seeded_client, degraded_threshold_cfg_guard
):
"""No neutral means no ``null`` hint: it would send the operator into a 422."""
for bad in (None, "x", 1.5):
detail = seeded_client.post(DEGRADED_THRESHOLD_URL, json={"value": bad}).json()[
"detail"
]
assert "neutral" not in detail
assert "null" not in detail
@pytest.mark.parametrize(
"good",
[DEGRADED_WARN_THRESHOLD_FLOOR, 0.2, DEGRADED_WARN_THRESHOLD_MAX],
ids=["floor", "typical", "max"],
)
def test_degraded_warn_threshold_accepts_in_range_and_updates_cfg(
seeded_client, degraded_threshold_cfg_guard, good
):
resp = seeded_client.post(DEGRADED_THRESHOLD_URL, json={"value": good})
assert resp.status_code == 200
assert resp.json() == {"ok": True, "classifier_degraded_warn_threshold": good}
stored = degraded_threshold_cfg_guard.degraded_warn_threshold
assert isinstance(stored, float) and not isinstance(stored, bool)
assert stored == good
assert (
seeded_client.get("/admin/api/runtime").json()[
"classifier_degraded_warn_threshold"
]["runtime"]
== good
)
@pytest.fixture
def staleness_cfg_guard(monkeypatch):
"""Restore the session-cache window on cfg after a test moves it."""
sc = dispatcher.cfg.session_cache
monkeypatch.setattr(sc, "staleness_seconds", sc.staleness_seconds)
return sc
def test_staleness_window_sets_a_real_int(seeded_client, staleness_cfg_guard):
"""The runtime half is the valuable half: this knob is meant to be swept.
The window decides how long ONE classification keeps steering routing, and
the only settings the portal offered before this were "20 minutes" and "no
cache at all" (``session_cache_enabled``). Narrowing it and re-measuring
the replay share is a loop nobody walks through a tracked-file edit and a
``systemctl --user restart``; ``dispatcher`` reads
``cfg.session_cache.staleness_seconds`` per request, so a POST is live on
the next one.
"""
resp = seeded_client.post(
"/admin/api/runtime/session_cache_staleness_seconds", json={"value": 5}
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "session_cache_staleness_seconds": 5}
stored = dispatcher.cfg.session_cache.staleness_seconds
# A real int, not a float and not a bool: the field is declared ``int``,
# and a float here would be a value load_config could never produce, so
# the runtime and persisted halves of one control would disagree about
# their own type.
assert isinstance(stored, int) and not isinstance(stored, bool)
assert stored == 5
after = seeded_client.get("/admin/api/runtime").json()
assert after["session_cache_staleness_seconds"]["runtime"] == 5
# config.yaml is untouched by a runtime write.
assert (
after["session_cache_staleness_seconds"]["persisted"]
== CFG.session_cache.staleness_seconds
)
def test_blank_staleness_window_is_refused_not_read_as_zero(
seeded_client, staleness_cfg_guard
):
"""Clearing the field is not a setting, and the refusal says which one is.
``Number('')`` is 0 in JavaScript, and 0 here would look like "stop
reusing labels" while being nothing of the sort: ``session_cache.put``
still writes, and the classifier-failure cascade's ``stale_read`` ignores
staleness entirely, so the entry is still replayed whenever the classifier
is down. The switch that actually stops reuse is ``session_cache_enabled``,
so the 422 names it rather than leaving the operator to guess.
"""
before = dispatcher.cfg.session_cache.staleness_seconds
resp = seeded_client.post(
"/admin/api/runtime/session_cache_staleness_seconds", json={"value": None}
)
assert resp.status_code == 422
detail = resp.json()["detail"]
assert "no blank setting" in detail
assert "session_cache_enabled" in detail
assert dispatcher.cfg.session_cache.staleness_seconds == before
@pytest.mark.parametrize("bad", [0, -1, 4, 7201, 10000])
def test_staleness_window_refuses_values_outside_its_bounds(
seeded_client, staleness_cfg_guard, bad
):
"""0 is not "off" and there is no unbounded window.
Both ends are decisions. The floor is 5 because 0 reads as off and is not
(see the blank test). The ceiling is 7200 because an unbounded window is a
cache that never expires — 6x the shipped default and ~8.5x the longest
single-classification run measured on live traffic (107 consecutive turns
across 840 seconds), so it is above every value there is a reason to try.
"""
before = dispatcher.cfg.session_cache.staleness_seconds
resp = seeded_client.post(
"/admin/api/runtime/session_cache_staleness_seconds", json={"value": bad}
)
assert resp.status_code == 422
assert "must be in [5, 7200]" in resp.json()["detail"]
assert dispatcher.cfg.session_cache.staleness_seconds == before
@pytest.mark.parametrize("bad", ["20", True, False, 20.5, 20.0, [20]])
def test_staleness_window_refuses_a_non_integer(
seeded_client, staleness_cfg_guard, bad
):
"""``True`` is the sharp one: it is an ``int`` in Python and reads as 1.
A checkbox body landing on this field would silently set a 1-second
window. Floats are refused rather than truncated for the same reason a
string is: 20.5 quietly becoming 20 is a value the operator never chose,
and 20.0 pays the same price for one unambiguous rule.
"""
before = dispatcher.cfg.session_cache.staleness_seconds
resp = seeded_client.post(
"/admin/api/runtime/session_cache_staleness_seconds", json={"value": bad}
)
assert resp.status_code == 422
assert "expects a whole number of seconds" in resp.json()["detail"]
assert dispatcher.cfg.session_cache.staleness_seconds == before
def test_staleness_runtime_bounds_match_the_config_validator():
"""One range, two enforcement points, imported rather than written twice.
A runtime write bypasses every Pydantic validator (``StrictModel`` sets
``extra="forbid"``, not ``validate_assignment``), so the table in admin.py
is the ONLY guard on that path — and a table that drifted from the config
validator would let an operator set at runtime a value the service refuses
to boot with.
"""
path, low, high = _INT_KNOBS["session_cache_staleness_seconds"]
assert path == ("session_cache", "staleness_seconds")
assert (low, high) == (STALENESS_SECONDS_MIN, STALENESS_SECONDS_MAX)
def test_post_invalid_flex_value_returns_422(seeded_client):
"""A flex preference outside the 4 allowed values is rejected with 422."""
resp = seeded_client.post(
"/admin/api/runtime/default_flex_preference", json={"value": "banana"}
)
assert resp.status_code == 422
# The in-memory value must not have changed.
assert (
seeded_client.get("/admin/api/runtime").json()["default_flex_preference"][
"runtime"
]
== "auto"
)
def test_post_valid_flex_value_updates_runtime(seeded_client, monkeypatch):
"""A valid flex preference is accepted and reflected in runtime state."""
monkeypatch.setattr(
dispatcher.cfg.routing,
"default_flex_preference",
dispatcher.cfg.routing.default_flex_preference,
)
resp = seeded_client.post(
"/admin/api/runtime/default_flex_preference", json={"value": "force-flex"}
)
assert resp.status_code == 200
assert seeded_client.get("/admin/api/runtime").json()["default_flex_preference"][
"runtime"
] == "force-flex"
def test_post_unknown_knob_returns_400(seeded_client):
"""POSTing an unknown knob name is rejected with 400."""
resp = seeded_client.post(
"/admin/api/runtime/not_a_real_knob", json={"value": True}
)
assert resp.status_code == 400
def test_post_non_boolean_for_bool_knob_returns_422(seeded_client):
"""POSTing a non-boolean value for a boolean knob is rejected with 422."""
resp = seeded_client.post(
"/admin/api/runtime/session_cache_enabled", json={"value": "yes"}
)
assert resp.status_code == 422
# ── active_profile: switching what `auto` resolves to, without a restart ────
#
# The restart this removes was never required by the router: _resolve_profile
# reads cfg.routing.default_profile per request. It was required because the
# portal wrote the value to config.local.yaml and nothing touched the running
# cfg. So active_profile is the SAME config path as the persisted
# routing.default_profile, split by where it is written -- runtime here,
# persisted through /api/config.
def test_active_profile_is_reported_with_persisted_and_runtime(seeded_client):
resp = seeded_client.get("/admin/api/runtime")
assert resp.status_code == 200
assert set(resp.json()["active_profile"]) == {
"persisted",
"runtime",
"config_key",
"category",
"advanced",
}
def test_active_profile_switch_changes_what_auto_resolves_to(
seeded_client, monkeypatch
):
"""The whole point: no restart between the switch and the effect."""
# Guard shared global state: the POST mutates dispatcher.cfg in place,
# and cfg.routing.default_profile is read by every later test's routing.
monkeypatch.setattr(
dispatcher.cfg.routing,
"default_profile",
dispatcher.cfg.routing.default_profile,
)
resp = seeded_client.post(
"/admin/api/runtime/active_profile", json={"value": "batch"}
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "active_profile": "batch"}
# The value the request path actually reads on the next `auto` call.
assert dispatcher.cfg.routing.default_profile == "batch"
def test_active_profile_refuses_an_unknown_name(seeded_client, monkeypatch):
"""StrictModel sets extra=forbid but NOT validate_assignment, so _set_at
writes straight past RouterConfig's validators, including
default_profile_names_a_known_profile. Unchecked, an unknown name would be
accepted here and then 422 every `auto` request -- a failure landing far
from its cause."""
before = dispatcher.cfg.routing.default_profile
resp = seeded_client.post(
"/admin/api/runtime/active_profile", json={"value": "no-such-profile"}
)
assert resp.status_code == 422
assert "must name a known profile" in resp.json()["detail"]
assert dispatcher.cfg.routing.default_profile == before
def test_active_profile_endpoint_reports_active_boots_to_and_choices(
seeded_client, monkeypatch
):
# Guard shared global state: the POST mutates dispatcher.cfg in place,
# and cfg.routing.default_profile is read by every later test's routing.
monkeypatch.setattr(
dispatcher.cfg.routing,
"default_profile",
dispatcher.cfg.routing.default_profile,
)
seeded_client.post("/admin/api/runtime/active_profile", json={"value": "batch"})
body = seeded_client.get("/admin/api/active-profile").json()
assert body["active"] == "batch"
assert "boots_to" in body
assert "batch" in body["available"]
# boots_to is read from disk, active from memory: after a runtime-only
# switch they diverge, and that divergence is what the switcher must show
# rather than hide -- a restart silently reverts routing to boots_to.
assert body["boots_to"] == CFG.routing.default_profile