meshai/tests/test_adapter_config_api.py

344 lines
10 KiB
Python
Raw Normal View History

feat(v0.6-3c): adapter_config REST API + dashboard editor Closes the audit-doc Section A keystone (the GUI editor). Together with v0.6-3a foundation, v0.6-3a.1 trim, and v0.6-3b handler wiring, every Rule-17 CONFIG knob from the audit is now editable in the dashboard without a container restart. API (meshai/dashboard/api/adapter_config_routes.py): GET /api/adapter-config -- {adapter: [{key, value, default, type, description}]} GET /api/adapter-config/<adapter> -- one adapter list GET /api/adapter-config/<adapter>/<key> -- single row PUT /api/adapter-config/<adapter>/<key> body {value} -- typed validation int: int or whole-number float; rejects bool, fractional float, str float: int or float; rejects bool str: str only bool: bool only json: any JSON-serializable value Every PUT calls invalidate_cache() so the next handler accessor read sees the new value -- no container restart needed. POST /api/adapter-config/<adapter>/<key>/reset -- value_json = default_json, cache invalidated GET /api/adapter-meta -- {adapter: {display_name, include_in_llm_context, description}} PUT /api/adapter-meta/<adapter> partial-update body, fields: include_in_llm_context: bool, display_name: non-empty str Dashboard (dashboard-frontend/src/pages/AdapterConfig.tsx): - Per-adapter cards. Header row shows display_name, the include_in_llm_context toggle, and an expand chevron. Adapters with zero config keys (e.g. itd_511) still render so users can toggle their LLM-context inclusion. - Expanded body lists each key with a type-aware widget: bool -> checkbox, commit-on-change int/float -> number input, commit-on-blur (or Enter) str -> text input, commit-on-blur json -> textarea, commit-on-blur (JSON.parse with inline error) Each row shows the key name, type tag, description, "edited" badge when value != default, a per-key Reset button, and a save badge (spinner, check, error tooltip, or a small amber dot for unsaved local changes). - Auto-save semantics: every blur/change/reset triggers PUT immediately; no explicit Save button needed. Reset is one-click per key. Wiring: - meshai/dashboard/server.py registers the new router with prefix /api. - dashboard-frontend/src/App.tsx adds the /adapter-config route. - dashboard-frontend/src/components/Layout.tsx adds the left-nav entry (Sliders icon, label "Adapter Config", after Reference). - Vite build produces a fresh meshai/dashboard/static bundle. The Dockerfile copies meshai/ so the new bundle ships with the container image at next rebuild. Tests (tests/test_adapter_config_api.py, 30 cases): - GET grouped, per-adapter, single key - GET per-adapter returns [] for adapters with zero keys (itd_511) - PUT updates value, GET shows new value, accessor returns new value (proves cache invalidation propagates to the in-process accessor) - PUT type validation per (int, float, str, bool, json) incl. edge cases: int rejects str + fractional float + bool but accepts whole-number float; float accepts int + float, rejects bool; bool rejects int; str rejects other types; json accepts list / dict / None - PUT 404 on unknown key, 400 on missing value field - POST reset restores default + invalidates cache - GET /api/adapter-meta: include_in_llm_context defaults match registry (central / geocoder false, rest true) - PUT meta partial update: only provided fields change - PUT meta rejects non-bool include_in_llm_context, empty display_name, unknown adapter Test count: 731 -> 761 (+30 API cases, 0 regressions). Refs audit doc v0.6-phase1-audit.md Section A keystone + finding #4.
2026-06-05 18:50:30 +00:00
"""v0.6-3c API tests for adapter_config + adapter_meta routes.
Uses FastAPI TestClient against a tmp DB seeded by the conftest autouse
fixture. Covers: GET (list, per-adapter, single), PUT (incl. type
validation), POST reset, GET/PUT meta, cache invalidation propagation
to the accessor.
"""
from __future__ import annotations
import json
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from meshai.adapter_config import adapter_config, invalidate_cache
from meshai.dashboard.api.adapter_config_routes import router
@pytest.fixture
def client():
app = FastAPI()
app.include_router(router, prefix="/api")
return TestClient(app)
# ============================================================================
# GET /api/adapter-config (grouped)
# ============================================================================
feat(v0.6-phase3): reminder system + schema split + NWS dedup relaxation Third broadcast type Active: clock-driven re-broadcasts of still-live events at human-scale cadences. WFIGS fires 8h, itd_511 work zones daily 8 AM Mountain, SWPC G-storms 8h. NWS is NOT a clock reminder -- instead the per-CAP-id dedup is relaxed to allow re-broadcast if >3h since last. Schema split first_broadcast_at + last_broadcast_at on all reminder- eligible tables. Wire prefix logic: New (first sight), Update (WFIGS material change), Active (clock reminder). All cadences, channels, day- of-week patterns, timezones, and termination conditions GUI-editable from day one via the existing adapter_config editor. Termination: tombstone OR containment_100 OR end_date_passed (no max-count). Quiet hours not respected -- ripped out in Phase 2. Schema (v11.sql): - ALTER TABLE fires|nws_alerts|traffic_events|quake_events|swpc_events| gauge_readings ADD COLUMN first_broadcast_at REAL - Backfill: UPDATE ... SET first_broadcast_at = last_broadcast_at WHERE last_broadcast_at IS NOT NULL - ALTER TABLE adapter_meta ADD COLUMN reminder_enabled INTEGER NOT NULL DEFAULT 0 - UPDATE adapter_meta SET reminder_enabled=1 WHERE adapter IN ('wfigs', 'swpc') -- itd_511_work_zone is a new meta row seeded with reminder_enabled=1 - SCHEMA_VERSION 10 -> 11 Handler commit-callbacks (wfigs/nws/quake/swpc/incident): - UPDATE ... SET last_broadcast_at=?, first_broadcast_at=COALESCE( first_broadcast_at, ?) -- first_broadcast_at stamped once, never overwritten NWS handler (meshai/central/nws_handler.py): - _render() gains a prefix kwarg - After-first-broadcast branch: when (now - last_broadcast_at) >= adapter_config.nws.duplicate_allowed_after_seconds (default 10800 = 3h), allow the re-broadcast with prefix=Active. Under the window, suppress as before. The commit callback continues to update last_broadcast_at. ReminderScheduler (meshai/notifications/reminders/__init__.py): - Async loop, ticks every 60s - Each tick: SELECT adapter FROM adapter_meta WHERE reminder_enabled=1 - Per adapter, load reminders_<adapter> config from adapter_config (cadence_kind, cadence_value, channels, terminate_when, dow_mask, timezone) - Interval cadence: rows where last_broadcast_at <= now - cadence_value - Clock cadence: localizes now to configured tz, finds slots that just passed in the last tick window, gated by dow_mask - Termination conditions checked per adapter: wfigs.containment_100 -> current_contained_pct >= 100 wfigs.last_event_age_24h -> last_event_at older than 24h swpc.end_date_passed -> payload_json end_time in past itd_511_work_zone.end_date_passed -> traffic_events.end_at in past - Active: prefix on every emitted wire; dispatcher.dispatch_scheduled_ broadcast() honors cold-start grace, bypasses toggle path - On success, last_broadcast_at = now; first_broadcast_at preserved Launched from notifications/pipeline/__init__.py:start_pipeline() alongside BandConditionsScheduler. adapter_config registry (+15 new keys, 43 -> 58): - reminders_wfigs.cadence_kind/cadence_value/channels/terminate_when - reminders_swpc.cadence_kind/cadence_value/channels/terminate_when - reminders_itd_511_work_zone.cadence_kind/cadence_value/channels/ dow_mask/timezone/terminate_when - nws.duplicate_allowed_after_seconds adapter_meta (+4 rows, 15 -> 19): - reminders_wfigs, reminders_swpc, reminders_itd_511_work_zone (pseudo-adapters carrying the reminder config) - itd_511_work_zone (reminder target row; reminder_enabled=1) - reminder_enabled flag added to wfigs/swpc (existing rows updated by v11.sql) and to itd_511_work_zone seed. Tests (tests/test_reminders.py, 10 cases): - wfigs reminder fires past 8h cadence, stamps last_broadcast_at, preserves first_broadcast_at - reminder skipped within cadence - reminder skipped when containment_100, last_event_age_24h - swpc reminder fires (interval) - work_zone clock reminder fires at 08:00 Mountain on enabled DOW - work_zone reminder skipped when end_date_passed - work_zone reminder skipped outside slot window - reminder_enabled=0 suppresses all reminders for that adapter tests/test_nws_dedup_relaxation.py (5 cases): - First sighting renders without Active: prefix - Re-broadcast within 3h suppressed - Re-broadcast after 3h allowed with Active: prefix - adapter_config.nws.duplicate_allowed_after_seconds override takes effect (1h window verified) - First sighting stamps first_broadcast_at=committed_at, last_broadcast_at=committed_at; 4h later broadcast stamps last_broadcast_at only, first_broadcast_at preserved Test count: 829 -> 844 (+15 new, 0 regressions). Foundation tests updated for new counts (REGISTRY=58, ADAPTER_META=19, schema=v11).
2026-06-05 21:11:32 +00:00
def test_list_returns_all_58_keys(client):
feat(v0.6-3c): adapter_config REST API + dashboard editor Closes the audit-doc Section A keystone (the GUI editor). Together with v0.6-3a foundation, v0.6-3a.1 trim, and v0.6-3b handler wiring, every Rule-17 CONFIG knob from the audit is now editable in the dashboard without a container restart. API (meshai/dashboard/api/adapter_config_routes.py): GET /api/adapter-config -- {adapter: [{key, value, default, type, description}]} GET /api/adapter-config/<adapter> -- one adapter list GET /api/adapter-config/<adapter>/<key> -- single row PUT /api/adapter-config/<adapter>/<key> body {value} -- typed validation int: int or whole-number float; rejects bool, fractional float, str float: int or float; rejects bool str: str only bool: bool only json: any JSON-serializable value Every PUT calls invalidate_cache() so the next handler accessor read sees the new value -- no container restart needed. POST /api/adapter-config/<adapter>/<key>/reset -- value_json = default_json, cache invalidated GET /api/adapter-meta -- {adapter: {display_name, include_in_llm_context, description}} PUT /api/adapter-meta/<adapter> partial-update body, fields: include_in_llm_context: bool, display_name: non-empty str Dashboard (dashboard-frontend/src/pages/AdapterConfig.tsx): - Per-adapter cards. Header row shows display_name, the include_in_llm_context toggle, and an expand chevron. Adapters with zero config keys (e.g. itd_511) still render so users can toggle their LLM-context inclusion. - Expanded body lists each key with a type-aware widget: bool -> checkbox, commit-on-change int/float -> number input, commit-on-blur (or Enter) str -> text input, commit-on-blur json -> textarea, commit-on-blur (JSON.parse with inline error) Each row shows the key name, type tag, description, "edited" badge when value != default, a per-key Reset button, and a save badge (spinner, check, error tooltip, or a small amber dot for unsaved local changes). - Auto-save semantics: every blur/change/reset triggers PUT immediately; no explicit Save button needed. Reset is one-click per key. Wiring: - meshai/dashboard/server.py registers the new router with prefix /api. - dashboard-frontend/src/App.tsx adds the /adapter-config route. - dashboard-frontend/src/components/Layout.tsx adds the left-nav entry (Sliders icon, label "Adapter Config", after Reference). - Vite build produces a fresh meshai/dashboard/static bundle. The Dockerfile copies meshai/ so the new bundle ships with the container image at next rebuild. Tests (tests/test_adapter_config_api.py, 30 cases): - GET grouped, per-adapter, single key - GET per-adapter returns [] for adapters with zero keys (itd_511) - PUT updates value, GET shows new value, accessor returns new value (proves cache invalidation propagates to the in-process accessor) - PUT type validation per (int, float, str, bool, json) incl. edge cases: int rejects str + fractional float + bool but accepts whole-number float; float accepts int + float, rejects bool; bool rejects int; str rejects other types; json accepts list / dict / None - PUT 404 on unknown key, 400 on missing value field - POST reset restores default + invalidates cache - GET /api/adapter-meta: include_in_llm_context defaults match registry (central / geocoder false, rest true) - PUT meta partial update: only provided fields change - PUT meta rejects non-bool include_in_llm_context, empty display_name, unknown adapter Test count: 731 -> 761 (+30 API cases, 0 regressions). Refs audit doc v0.6-phase1-audit.md Section A keystone + finding #4.
2026-06-05 18:50:30 +00:00
r = client.get("/api/adapter-config")
assert r.status_code == 200
body = r.json()
# 14 adapters with at least one key (itd_511 has zero -- not in the
# grouped dict because the SQL only returns rows that exist).
total = sum(len(v) for v in body.values())
feat(v0.6-phase3): reminder system + schema split + NWS dedup relaxation Third broadcast type Active: clock-driven re-broadcasts of still-live events at human-scale cadences. WFIGS fires 8h, itd_511 work zones daily 8 AM Mountain, SWPC G-storms 8h. NWS is NOT a clock reminder -- instead the per-CAP-id dedup is relaxed to allow re-broadcast if >3h since last. Schema split first_broadcast_at + last_broadcast_at on all reminder- eligible tables. Wire prefix logic: New (first sight), Update (WFIGS material change), Active (clock reminder). All cadences, channels, day- of-week patterns, timezones, and termination conditions GUI-editable from day one via the existing adapter_config editor. Termination: tombstone OR containment_100 OR end_date_passed (no max-count). Quiet hours not respected -- ripped out in Phase 2. Schema (v11.sql): - ALTER TABLE fires|nws_alerts|traffic_events|quake_events|swpc_events| gauge_readings ADD COLUMN first_broadcast_at REAL - Backfill: UPDATE ... SET first_broadcast_at = last_broadcast_at WHERE last_broadcast_at IS NOT NULL - ALTER TABLE adapter_meta ADD COLUMN reminder_enabled INTEGER NOT NULL DEFAULT 0 - UPDATE adapter_meta SET reminder_enabled=1 WHERE adapter IN ('wfigs', 'swpc') -- itd_511_work_zone is a new meta row seeded with reminder_enabled=1 - SCHEMA_VERSION 10 -> 11 Handler commit-callbacks (wfigs/nws/quake/swpc/incident): - UPDATE ... SET last_broadcast_at=?, first_broadcast_at=COALESCE( first_broadcast_at, ?) -- first_broadcast_at stamped once, never overwritten NWS handler (meshai/central/nws_handler.py): - _render() gains a prefix kwarg - After-first-broadcast branch: when (now - last_broadcast_at) >= adapter_config.nws.duplicate_allowed_after_seconds (default 10800 = 3h), allow the re-broadcast with prefix=Active. Under the window, suppress as before. The commit callback continues to update last_broadcast_at. ReminderScheduler (meshai/notifications/reminders/__init__.py): - Async loop, ticks every 60s - Each tick: SELECT adapter FROM adapter_meta WHERE reminder_enabled=1 - Per adapter, load reminders_<adapter> config from adapter_config (cadence_kind, cadence_value, channels, terminate_when, dow_mask, timezone) - Interval cadence: rows where last_broadcast_at <= now - cadence_value - Clock cadence: localizes now to configured tz, finds slots that just passed in the last tick window, gated by dow_mask - Termination conditions checked per adapter: wfigs.containment_100 -> current_contained_pct >= 100 wfigs.last_event_age_24h -> last_event_at older than 24h swpc.end_date_passed -> payload_json end_time in past itd_511_work_zone.end_date_passed -> traffic_events.end_at in past - Active: prefix on every emitted wire; dispatcher.dispatch_scheduled_ broadcast() honors cold-start grace, bypasses toggle path - On success, last_broadcast_at = now; first_broadcast_at preserved Launched from notifications/pipeline/__init__.py:start_pipeline() alongside BandConditionsScheduler. adapter_config registry (+15 new keys, 43 -> 58): - reminders_wfigs.cadence_kind/cadence_value/channels/terminate_when - reminders_swpc.cadence_kind/cadence_value/channels/terminate_when - reminders_itd_511_work_zone.cadence_kind/cadence_value/channels/ dow_mask/timezone/terminate_when - nws.duplicate_allowed_after_seconds adapter_meta (+4 rows, 15 -> 19): - reminders_wfigs, reminders_swpc, reminders_itd_511_work_zone (pseudo-adapters carrying the reminder config) - itd_511_work_zone (reminder target row; reminder_enabled=1) - reminder_enabled flag added to wfigs/swpc (existing rows updated by v11.sql) and to itd_511_work_zone seed. Tests (tests/test_reminders.py, 10 cases): - wfigs reminder fires past 8h cadence, stamps last_broadcast_at, preserves first_broadcast_at - reminder skipped within cadence - reminder skipped when containment_100, last_event_age_24h - swpc reminder fires (interval) - work_zone clock reminder fires at 08:00 Mountain on enabled DOW - work_zone reminder skipped when end_date_passed - work_zone reminder skipped outside slot window - reminder_enabled=0 suppresses all reminders for that adapter tests/test_nws_dedup_relaxation.py (5 cases): - First sighting renders without Active: prefix - Re-broadcast within 3h suppressed - Re-broadcast after 3h allowed with Active: prefix - adapter_config.nws.duplicate_allowed_after_seconds override takes effect (1h window verified) - First sighting stamps first_broadcast_at=committed_at, last_broadcast_at=committed_at; 4h later broadcast stamps last_broadcast_at only, first_broadcast_at preserved Test count: 829 -> 844 (+15 new, 0 regressions). Foundation tests updated for new counts (REGISTRY=58, ADAPTER_META=19, schema=v11).
2026-06-05 21:11:32 +00:00
assert total == 58
feat(v0.6-3c): adapter_config REST API + dashboard editor Closes the audit-doc Section A keystone (the GUI editor). Together with v0.6-3a foundation, v0.6-3a.1 trim, and v0.6-3b handler wiring, every Rule-17 CONFIG knob from the audit is now editable in the dashboard without a container restart. API (meshai/dashboard/api/adapter_config_routes.py): GET /api/adapter-config -- {adapter: [{key, value, default, type, description}]} GET /api/adapter-config/<adapter> -- one adapter list GET /api/adapter-config/<adapter>/<key> -- single row PUT /api/adapter-config/<adapter>/<key> body {value} -- typed validation int: int or whole-number float; rejects bool, fractional float, str float: int or float; rejects bool str: str only bool: bool only json: any JSON-serializable value Every PUT calls invalidate_cache() so the next handler accessor read sees the new value -- no container restart needed. POST /api/adapter-config/<adapter>/<key>/reset -- value_json = default_json, cache invalidated GET /api/adapter-meta -- {adapter: {display_name, include_in_llm_context, description}} PUT /api/adapter-meta/<adapter> partial-update body, fields: include_in_llm_context: bool, display_name: non-empty str Dashboard (dashboard-frontend/src/pages/AdapterConfig.tsx): - Per-adapter cards. Header row shows display_name, the include_in_llm_context toggle, and an expand chevron. Adapters with zero config keys (e.g. itd_511) still render so users can toggle their LLM-context inclusion. - Expanded body lists each key with a type-aware widget: bool -> checkbox, commit-on-change int/float -> number input, commit-on-blur (or Enter) str -> text input, commit-on-blur json -> textarea, commit-on-blur (JSON.parse with inline error) Each row shows the key name, type tag, description, "edited" badge when value != default, a per-key Reset button, and a save badge (spinner, check, error tooltip, or a small amber dot for unsaved local changes). - Auto-save semantics: every blur/change/reset triggers PUT immediately; no explicit Save button needed. Reset is one-click per key. Wiring: - meshai/dashboard/server.py registers the new router with prefix /api. - dashboard-frontend/src/App.tsx adds the /adapter-config route. - dashboard-frontend/src/components/Layout.tsx adds the left-nav entry (Sliders icon, label "Adapter Config", after Reference). - Vite build produces a fresh meshai/dashboard/static bundle. The Dockerfile copies meshai/ so the new bundle ships with the container image at next rebuild. Tests (tests/test_adapter_config_api.py, 30 cases): - GET grouped, per-adapter, single key - GET per-adapter returns [] for adapters with zero keys (itd_511) - PUT updates value, GET shows new value, accessor returns new value (proves cache invalidation propagates to the in-process accessor) - PUT type validation per (int, float, str, bool, json) incl. edge cases: int rejects str + fractional float + bool but accepts whole-number float; float accepts int + float, rejects bool; bool rejects int; str rejects other types; json accepts list / dict / None - PUT 404 on unknown key, 400 on missing value field - POST reset restores default + invalidates cache - GET /api/adapter-meta: include_in_llm_context defaults match registry (central / geocoder false, rest true) - PUT meta partial update: only provided fields change - PUT meta rejects non-bool include_in_llm_context, empty display_name, unknown adapter Test count: 731 -> 761 (+30 API cases, 0 regressions). Refs audit doc v0.6-phase1-audit.md Section A keystone + finding #4.
2026-06-05 18:50:30 +00:00
def test_list_grouped_by_adapter(client):
r = client.get("/api/adapter-config")
body = r.json()
assert "wfigs" in body
keys = {row["key"] for row in body["wfigs"]}
assert keys == {"cooldown_seconds", "anchor_max_mi",
"broadcast_on_acres", "broadcast_on_contained"}
def test_list_includes_type_value_default_description(client):
r = client.get("/api/adapter-config")
body = r.json()
row = next(row for row in body["wfigs"] if row["key"] == "cooldown_seconds")
assert row["value"] == 28800
assert row["default"] == 28800
assert row["type"] == "int"
assert row["description"]
# ============================================================================
# GET /api/adapter-config/{adapter}
# ============================================================================
def test_per_adapter_list(client):
r = client.get("/api/adapter-config/usgs_quake")
assert r.status_code == 200
body = r.json()
assert isinstance(body, list)
keys = {row["key"] for row in body}
assert keys == {
"regional_centroid", "regional_radius_mi",
"broadcast_pager_alerts", "global_mag_floor",
"regional_mag_floor", "escalate_mag_floor",
}
def test_per_adapter_empty_for_itd_511(client):
"""itd_511 has zero config keys post-3a.1; returns empty list, not 404."""
r = client.get("/api/adapter-config/itd_511")
assert r.status_code == 200
assert r.json() == []
# ============================================================================
# GET /api/adapter-config/{adapter}/{key}
# ============================================================================
def test_get_single_key(client):
r = client.get("/api/adapter-config/usgs_quake/global_mag_floor")
assert r.status_code == 200
body = r.json()
assert body["value"] == 3.0
assert body["default"] == 3.0
assert body["type"] == "float"
def test_get_unknown_key_404(client):
r = client.get("/api/adapter-config/wfigs/no_such_key")
assert r.status_code == 404
# ============================================================================
# PUT /api/adapter-config/{adapter}/{key}
# ============================================================================
def test_put_updates_value(client):
r = client.put(
"/api/adapter-config/usgs_quake/global_mag_floor",
json={"value": 2.8},
)
assert r.status_code == 200
assert r.json()["value"] == 2.8
# GET reflects the new value.
g = client.get("/api/adapter-config/usgs_quake/global_mag_floor")
assert g.json()["value"] == 2.8
def test_put_invalidates_accessor_cache(client):
"""The handler-side accessor reads the new value WITHOUT a restart."""
# Prime the cache with the default.
assert adapter_config.usgs_quake.global_mag_floor == 3.0
# Mutate via API.
client.put(
"/api/adapter-config/usgs_quake/global_mag_floor",
json={"value": 2.5},
)
# Accessor returns the new value -- cache invalidation worked.
assert adapter_config.usgs_quake.global_mag_floor == 2.5
def test_put_int_validation(client):
"""int field rejects string body."""
r = client.put(
"/api/adapter-config/wfigs/cooldown_seconds",
json={"value": "two hours"},
)
assert r.status_code == 400
def test_put_int_rejects_float_with_fraction(client):
r = client.put(
"/api/adapter-config/wfigs/cooldown_seconds",
json={"value": 3600.5},
)
assert r.status_code == 400
def test_put_int_accepts_float_with_integer_value(client):
r = client.put(
"/api/adapter-config/wfigs/cooldown_seconds",
json={"value": 3600.0},
)
assert r.status_code == 200
assert r.json()["value"] == 3600
def test_put_float_accepts_int(client):
r = client.put(
"/api/adapter-config/usgs_quake/global_mag_floor",
json={"value": 4},
)
assert r.status_code == 200
assert r.json()["value"] == 4.0
def test_put_bool_rejects_int(client):
r = client.put(
"/api/adapter-config/wfigs/broadcast_on_acres",
json={"value": 1},
)
assert r.status_code == 400
def test_put_str_validation(client):
r = client.put(
"/api/adapter-config/geocoder/photon_url",
json={"value": 42},
)
assert r.status_code == 400
def test_put_json_accepts_list(client):
r = client.put(
"/api/adapter-config/nws/broadcast_severities",
json={"value": ["Extreme"]},
)
assert r.status_code == 200
assert r.json()["value"] == ["Extreme"]
def test_put_json_accepts_dict(client):
r = client.put(
"/api/adapter-config/central/severity_thresholds",
json={"value": {"routine_max": 0, "priority_max": 1, "immediate_min": 2}},
)
assert r.status_code == 200
def test_put_json_accepts_none(client):
r = client.put(
"/api/adapter-config/firms/bbox",
json={"value": None},
)
assert r.status_code == 200
assert r.json()["value"] is None
def test_put_unknown_key_404(client):
r = client.put(
"/api/adapter-config/wfigs/no_such_key",
json={"value": 1},
)
assert r.status_code == 404
def test_put_missing_value_field(client):
r = client.put(
"/api/adapter-config/wfigs/cooldown_seconds",
json={},
)
assert r.status_code == 400
# ============================================================================
# POST /api/adapter-config/{adapter}/{key}/reset
# ============================================================================
def test_reset_restores_default(client):
# Mutate.
client.put(
"/api/adapter-config/usgs_quake/global_mag_floor",
json={"value": 2.8},
)
assert client.get("/api/adapter-config/usgs_quake/global_mag_floor").json()["value"] == 2.8
# Reset.
r = client.post("/api/adapter-config/usgs_quake/global_mag_floor/reset")
assert r.status_code == 200
assert r.json()["value"] == 3.0
# Accessor too.
invalidate_cache()
assert adapter_config.usgs_quake.global_mag_floor == 3.0
def test_reset_invalidates_cache(client):
"""Reset must invalidate the accessor cache the same as PUT."""
client.put(
"/api/adapter-config/usgs_quake/global_mag_floor",
json={"value": 2.8},
)
# Prime cache with the post-PUT value.
assert adapter_config.usgs_quake.global_mag_floor == 2.8
client.post("/api/adapter-config/usgs_quake/global_mag_floor/reset")
# Cache cleared -- next read returns the default.
assert adapter_config.usgs_quake.global_mag_floor == 3.0
def test_reset_unknown_key_404(client):
r = client.post("/api/adapter-config/wfigs/no_such_key/reset")
assert r.status_code == 404
# ============================================================================
# GET /api/adapter-meta
# ============================================================================
def test_list_meta(client):
r = client.get("/api/adapter-meta")
assert r.status_code == 200
body = r.json()
assert "wfigs" in body
assert body["wfigs"]["include_in_llm_context"] is True
# central / geocoder default to False
assert body["central"]["include_in_llm_context"] is False
assert body["geocoder"]["include_in_llm_context"] is False
# ============================================================================
# PUT /api/adapter-meta/{adapter}
# ============================================================================
def test_put_meta_toggles_llm_context(client):
r = client.put(
"/api/adapter-meta/itd_511",
json={"include_in_llm_context": False},
)
assert r.status_code == 200
assert r.json()["include_in_llm_context"] is False
# GET reflects the change.
g = client.get("/api/adapter-meta").json()
assert g["itd_511"]["include_in_llm_context"] is False
def test_put_meta_updates_display_name(client):
r = client.put(
"/api/adapter-meta/wfigs",
json={"display_name": "Active wildfires (WFIGS)"},
)
assert r.status_code == 200
assert r.json()["display_name"] == "Active wildfires (WFIGS)"
def test_put_meta_partial_update(client):
"""Only the fields in the body change; others survive."""
original = client.get("/api/adapter-meta").json()["wfigs"]
r = client.put(
"/api/adapter-meta/wfigs",
json={"include_in_llm_context": False},
)
assert r.status_code == 200
body = r.json()
assert body["display_name"] == original["display_name"] # unchanged
assert body["include_in_llm_context"] is False
def test_put_meta_rejects_bad_bool(client):
r = client.put(
"/api/adapter-meta/wfigs",
json={"include_in_llm_context": "yes"},
)
assert r.status_code == 400
def test_put_meta_rejects_empty_display_name(client):
r = client.put(
"/api/adapter-meta/wfigs",
json={"display_name": " "},
)
assert r.status_code == 400
def test_put_meta_unknown_adapter_404(client):
r = client.put(
"/api/adapter-meta/nonexistent_adapter",
json={"include_in_llm_context": False},
)
assert r.status_code == 404