mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-10 17:04:45 +02:00
feat(v0.6-3a): adapter_config foundation -- migration + defaults registry + typed accessor
Closes the foundation slice of audit doc Section A (Rule 17). Lands two
SQLite tables, the seed routine that populates them from a Python
defaults registry, and a typed accessor that handler code will read in
v0.6-3b. No handler changes in this commit -- ZERO behavior risk, every
existing test still passes (721 / 69 skipped / 0 failed).
v6.sql tables:
- adapter_config(adapter, key, value_json, default_json, type, description,
updated_at) PRIMARY KEY(adapter, key) -- JSON-encoded
values flow through a single column uniformly. CHECK
constraint on `type` closes the vocab (int/float/str/
bool/json).
- adapter_meta(adapter PK, display_name, include_in_llm_context,
description, updated_at) -- per-adapter metadata + the
user-scopable LLM-context toggle (Matt refinement #5).
meshai/adapter_config/ package:
- defaults.py: REGISTRY dict mapping (adapter, key) -> {default, type,
description}. Covers audit doc sections A.1-A.12: wfigs, nws,
usgs_quake, swpc, usgs_nwis, incident family (tomtom_incidents,
state_511_atis, itd_511, shared "incident"), central consumer,
dispatcher, band_conditions, geocoder, firms, pipeline (Inhibitor +
Grouper). ~85 keys total. ADAPTER_META covers 15 adapters with
display_name + include_in_llm_context defaulting to True. Per Matt
refinement #3, every default matches the current handler constant
EXACTLY -- first deploy behavior is unchanged.
- _accessor.py: AdapterConfig class with `adapter_config.<adapter>.<key>`
syntax. Read pipeline: in-memory cache hit -> SQL -> registry
fallback (with WARNING) -> AttributeError. Process-wide cache; PUT
via v0.6-3c REST API calls invalidate_cache() to drop the cache.
GIL-atomic dict reads on the fast path (handlers call this hot).
- __init__.py: seed_defaults(conn) -- INSERT OR IGNOREs one row per
registry entry. Idempotent, never overwrites user edits.
Wiring:
- meshai/persistence/db.py: SCHEMA_VERSION 5 -> 6, and init_db() now
calls seed_defaults() after migrations apply.
- meshai/main.py: _init_components() now calls init_db() FIRST (per
commit #1 lessons-learned: a startup-time migration is required
when handlers will rely on the new schema; lazy-on-first-handler
is fine for v4/v5 but not for v6 where handler reads start in
v0.6-3b).
- tests/conftest.py: autouse fixture now calls init_db() + clears
the accessor cache around each test, so every test gets the v6
seed AND a clean cache without per-test boilerplate.
Tests (tests/test_adapter_config_foundation.py, 24 cases):
- v6 tables exist + schema_meta at 6 + type-vocabulary CHECK enforced
- seed populates every REGISTRY + ADAPTER_META row, value_json ==
default_json on first seed, type matches
- seed is idempotent + does not overwrite user edits
- accessor returns correctly typed values for int/float/str/bool/
json list/json dict/json None
- cache hit: second read does not touch the DB (patched _load_from_db
raises, accessor still succeeds)
- invalidate_cache forces a re-read; mutated DB value wins
- registry fallback path triggers when a row is missing (with WARNING)
- unknown key raises AttributeError
- setattr blocked (writes go via the REST API in 3c)
- every default JSON round-trips cleanly; every type is in vocabulary
- ADAPTER_META covers every adapter in REGISTRY
Test count: 697 -> 721 (+24 new, 0 regressions).
v0.6-3b will wire handlers one at a time (wfigs, nws, quake, swpc, nwis,
incident, central, dispatcher, band_conditions, geocoder, firms). Per
the audit lock, defaults match exactly so each wiring step is a pure
refactor -- bisect-safe.
v0.6-3c lands the /api/adapter-config CRUD + the AdapterConfig.tsx
dashboard editor + cache invalidation on PUT.
Refs audit doc v0.6-phase1-audit.md Section A + finding #4.
This commit is contained in:
parent
c333a97344
commit
cb3c5aec7e
8 changed files with 1360 additions and 4 deletions
|
|
@ -21,11 +21,32 @@ from meshai.persistence import db as _persistence_db
|
|||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_meshai_db(tmp_path, monkeypatch):
|
||||
"""Point MESHAI_DB_PATH at a tmp file per test."""
|
||||
"""Point MESHAI_DB_PATH at a tmp file per test + run init_db so the
|
||||
v6 adapter_config seed lands before any test code runs.
|
||||
|
||||
v0.6-3a: init_db() applies pending migrations AND seeds
|
||||
adapter_config from the defaults registry. Tests that read via
|
||||
need the seed in place.
|
||||
"""
|
||||
p = str(tmp_path / "meshai-test-isolated.sqlite")
|
||||
monkeypatch.setenv("MESHAI_DB_PATH", p)
|
||||
_persistence_db._initialised.clear()
|
||||
close_thread_connection()
|
||||
# Clear the adapter_config accessor cache so the prior tests
|
||||
# in-memory cache does not leak into this test.
|
||||
try:
|
||||
from meshai.adapter_config import invalidate_cache
|
||||
invalidate_cache()
|
||||
except Exception:
|
||||
pass
|
||||
# Eager init: applies v1..v6 migrations + seeds adapter_config.
|
||||
from meshai.persistence import init_db
|
||||
init_db()
|
||||
yield p
|
||||
close_thread_connection()
|
||||
_persistence_db._initialised.discard(p)
|
||||
try:
|
||||
from meshai.adapter_config import invalidate_cache
|
||||
invalidate_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
|
|
|||
289
tests/test_adapter_config_foundation.py
Normal file
289
tests/test_adapter_config_foundation.py
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
"""v0.6-3a foundation tests: migration, seed, accessor.
|
||||
|
||||
No handler is wired to adapter_config in 3a (that lands in 3b). These
|
||||
tests verify the foundation in isolation:
|
||||
- v6 migration creates adapter_config + adapter_meta
|
||||
- seed_defaults seeds every REGISTRY + ADAPTER_META row
|
||||
- seed_defaults is idempotent (re-run is a no-op)
|
||||
- accessor returns typed values for int / float / str / bool / json
|
||||
- cache hit: second read does not re-hit the DB
|
||||
- invalidate_cache forces reload
|
||||
- registry fallback when the DB row is missing (defensive)
|
||||
- unknown key raises AttributeError
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from meshai.adapter_config import (
|
||||
adapter_config,
|
||||
invalidate_cache,
|
||||
seed_defaults,
|
||||
REGISTRY,
|
||||
ADAPTER_META,
|
||||
)
|
||||
from meshai.adapter_config import _accessor as accessor_mod
|
||||
from meshai.persistence import close_thread_connection, init_db
|
||||
from meshai.persistence import db as persistence_db
|
||||
|
||||
|
||||
# ---------- fixtures ------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
"""Forces a fresh DB AND clears the in-memory accessor cache.
|
||||
|
||||
The conftest autouse fixture also redirects MESHAI_DB_PATH, but
|
||||
explicit cleanup here makes the tests robust to ordering.
|
||||
"""
|
||||
p = str(tmp_path / "ac-test.sqlite")
|
||||
monkeypatch.setenv("MESHAI_DB_PATH", p)
|
||||
persistence_db._initialised.clear()
|
||||
close_thread_connection()
|
||||
invalidate_cache()
|
||||
conn = init_db()
|
||||
yield conn
|
||||
close_thread_connection()
|
||||
persistence_db._initialised.discard(p)
|
||||
invalidate_cache()
|
||||
|
||||
|
||||
# ---------- schema --------------------------------------------------------
|
||||
|
||||
|
||||
def test_v6_tables_exist(fresh_db):
|
||||
tables = {r["name"] for r in fresh_db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()}
|
||||
assert "adapter_config" in tables
|
||||
assert "adapter_meta" in tables
|
||||
|
||||
|
||||
def test_schema_meta_at_v6(fresh_db):
|
||||
v = fresh_db.execute(
|
||||
"SELECT value FROM schema_meta WHERE key='version'"
|
||||
).fetchone()["value"]
|
||||
assert int(v) == 6
|
||||
|
||||
|
||||
def test_adapter_config_type_check_constrains_vocabulary(fresh_db):
|
||||
"""The CHECK constraint on `type` rejects unknown labels."""
|
||||
with pytest.raises(Exception):
|
||||
fresh_db.execute(
|
||||
"INSERT INTO adapter_config(adapter, key, value_json, default_json, "
|
||||
"type, description, updated_at) VALUES (?,?,?,?,?,?,?)",
|
||||
("x", "y", "1", "1", "integer", "", 0.0), # 'integer' is not in vocab
|
||||
)
|
||||
|
||||
|
||||
# ---------- seed ----------------------------------------------------------
|
||||
|
||||
|
||||
def test_seed_populates_every_registry_row(fresh_db):
|
||||
"""Every (adapter, key) in REGISTRY lands in adapter_config."""
|
||||
rows = fresh_db.execute("SELECT adapter, key FROM adapter_config").fetchall()
|
||||
db_keys = {(r["adapter"], r["key"]) for r in rows}
|
||||
assert db_keys == set(REGISTRY.keys()), (
|
||||
f"DB missing {set(REGISTRY) - db_keys}, "
|
||||
f"extra in DB {db_keys - set(REGISTRY)}"
|
||||
)
|
||||
|
||||
|
||||
def test_seed_value_matches_registry_default(fresh_db):
|
||||
"""value_json == default_json on first seed; both = json.dumps(default)."""
|
||||
for (adapter, key), spec in REGISTRY.items():
|
||||
row = fresh_db.execute(
|
||||
"SELECT value_json, default_json, type FROM adapter_config "
|
||||
"WHERE adapter=? AND key=?",
|
||||
(adapter, key),
|
||||
).fetchone()
|
||||
expected = json.dumps(spec["default"])
|
||||
assert row["value_json"] == expected, f"{adapter}.{key} value drift"
|
||||
assert row["default_json"] == expected, f"{adapter}.{key} default drift"
|
||||
assert row["type"] == spec["type"]
|
||||
|
||||
|
||||
def test_seed_populates_every_adapter_meta_row(fresh_db):
|
||||
rows = fresh_db.execute("SELECT adapter, include_in_llm_context FROM adapter_meta").fetchall()
|
||||
db_adapters = {r["adapter"] for r in rows}
|
||||
assert db_adapters == set(ADAPTER_META.keys())
|
||||
for r in rows:
|
||||
spec = ADAPTER_META[r["adapter"]]
|
||||
expected = 1 if spec.get("include_in_llm_context", True) else 0
|
||||
assert r["include_in_llm_context"] == expected
|
||||
|
||||
|
||||
def test_seed_is_idempotent(fresh_db):
|
||||
"""Re-running seed_defaults inserts zero new rows."""
|
||||
a, b = seed_defaults(fresh_db)
|
||||
assert a == 0 and b == 0
|
||||
# Row counts unchanged.
|
||||
cfg = fresh_db.execute("SELECT COUNT(*) FROM adapter_config").fetchone()[0]
|
||||
meta = fresh_db.execute("SELECT COUNT(*) FROM adapter_meta").fetchone()[0]
|
||||
assert cfg == len(REGISTRY)
|
||||
assert meta == len(ADAPTER_META)
|
||||
|
||||
|
||||
def test_seed_does_not_overwrite_user_edits(fresh_db):
|
||||
"""A user-edited value_json survives a re-seed (INSERT OR IGNORE)."""
|
||||
fresh_db.execute(
|
||||
"UPDATE adapter_config SET value_json=? WHERE adapter=? AND key=?",
|
||||
("999", "wfigs", "cooldown_seconds"),
|
||||
)
|
||||
seed_defaults(fresh_db)
|
||||
row = fresh_db.execute(
|
||||
"SELECT value_json FROM adapter_config "
|
||||
"WHERE adapter='wfigs' AND key='cooldown_seconds'"
|
||||
).fetchone()
|
||||
assert row["value_json"] == "999", "seed must not overwrite user edits"
|
||||
|
||||
|
||||
# ---------- accessor -----------------------------------------------------
|
||||
|
||||
|
||||
def test_accessor_returns_int(fresh_db):
|
||||
invalidate_cache()
|
||||
v = adapter_config.wfigs.cooldown_seconds
|
||||
assert isinstance(v, int)
|
||||
assert v == 28800
|
||||
|
||||
|
||||
def test_accessor_returns_float(fresh_db):
|
||||
invalidate_cache()
|
||||
v = adapter_config.usgs_quake.global_mag_floor
|
||||
assert isinstance(v, float)
|
||||
assert v == 3.0
|
||||
|
||||
|
||||
def test_accessor_returns_str(fresh_db):
|
||||
invalidate_cache()
|
||||
v = adapter_config.geocoder.photon_url
|
||||
assert isinstance(v, str)
|
||||
assert v == "http://100.64.0.24:2322"
|
||||
|
||||
|
||||
def test_accessor_returns_bool(fresh_db):
|
||||
invalidate_cache()
|
||||
v = adapter_config.tomtom_incidents.drop_zero_magnitude
|
||||
assert isinstance(v, bool)
|
||||
assert v is True
|
||||
|
||||
|
||||
def test_accessor_returns_json_list(fresh_db):
|
||||
invalidate_cache()
|
||||
v = adapter_config.nws.broadcast_severities
|
||||
assert v == ["Extreme", "Severe"]
|
||||
|
||||
|
||||
def test_accessor_returns_json_dict(fresh_db):
|
||||
invalidate_cache()
|
||||
v = adapter_config.usgs_nwis.threshold_labels
|
||||
assert v["action"] == "action stage"
|
||||
|
||||
|
||||
def test_accessor_returns_json_none(fresh_db):
|
||||
"""A registry default of None survives the round-trip."""
|
||||
invalidate_cache()
|
||||
v = adapter_config.firms.bbox
|
||||
assert v is None
|
||||
|
||||
|
||||
# ---------- cache --------------------------------------------------------
|
||||
|
||||
|
||||
def test_cache_hits_second_read(fresh_db):
|
||||
"""Second read of the same key bypasses the DB."""
|
||||
invalidate_cache()
|
||||
# Prime the cache.
|
||||
_ = adapter_config.wfigs.cooldown_seconds
|
||||
# Patch _load_from_db to fail; if the cache works we never call it.
|
||||
with patch.object(accessor_mod, "_load_from_db",
|
||||
side_effect=AssertionError("cache miss -- went to DB")):
|
||||
v = adapter_config.wfigs.cooldown_seconds
|
||||
assert v == 28800
|
||||
|
||||
|
||||
def test_invalidate_forces_reload(fresh_db):
|
||||
"""After invalidate_cache the next read hits the DB and sees fresh values."""
|
||||
invalidate_cache()
|
||||
_ = adapter_config.wfigs.cooldown_seconds # prime cache
|
||||
|
||||
fresh_db.execute(
|
||||
"UPDATE adapter_config SET value_json=? WHERE adapter='wfigs' AND key='cooldown_seconds'",
|
||||
("3600",),
|
||||
)
|
||||
# Without invalidation, the cached 28800 is returned.
|
||||
assert adapter_config.wfigs.cooldown_seconds == 28800
|
||||
invalidate_cache()
|
||||
assert adapter_config.wfigs.cooldown_seconds == 3600
|
||||
|
||||
|
||||
# ---------- defensive fallback paths -------------------------------------
|
||||
|
||||
|
||||
def test_registry_fallback_when_db_row_missing(fresh_db, caplog):
|
||||
"""If the seed somehow missed a key, the accessor still returns the
|
||||
registry default (with a WARNING)."""
|
||||
invalidate_cache()
|
||||
# Delete one row to simulate the missing-seed case.
|
||||
fresh_db.execute(
|
||||
"DELETE FROM adapter_config WHERE adapter='wfigs' AND key='cooldown_seconds'"
|
||||
)
|
||||
import logging
|
||||
caplog.set_level(logging.WARNING, logger="meshai.adapter_config._accessor")
|
||||
v = adapter_config.wfigs.cooldown_seconds
|
||||
assert v == 28800 # from REGISTRY
|
||||
assert any("missing from DB" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_unknown_key_raises(fresh_db):
|
||||
invalidate_cache()
|
||||
with pytest.raises(AttributeError):
|
||||
_ = adapter_config.wfigs.no_such_key
|
||||
|
||||
|
||||
def test_unknown_adapter_section_still_works_until_key_lookup(fresh_db):
|
||||
"""Accessing adapter_config.<unknown> returns a section object;
|
||||
the AttributeError comes on the key dereference."""
|
||||
invalidate_cache()
|
||||
section = adapter_config.nonexistent_adapter
|
||||
assert section is not None # section object exists
|
||||
with pytest.raises(AttributeError):
|
||||
_ = section.any_key
|
||||
|
||||
|
||||
def test_setattr_blocked(fresh_db):
|
||||
"""Writes via the accessor are forbidden -- use the API."""
|
||||
invalidate_cache()
|
||||
with pytest.raises(AttributeError):
|
||||
adapter_config.wfigs.cooldown_seconds = 999
|
||||
|
||||
|
||||
# ---------- registry sanity ----------------------------------------------
|
||||
|
||||
|
||||
def test_every_registry_default_round_trips_through_json():
|
||||
"""Every default must json.dumps + json.loads to an equivalent value."""
|
||||
for (adapter, key), spec in REGISTRY.items():
|
||||
encoded = json.dumps(spec["default"])
|
||||
decoded = json.loads(encoded)
|
||||
assert decoded == spec["default"], f"{adapter}.{key}: JSON round-trip drift"
|
||||
|
||||
|
||||
def test_every_registry_type_is_in_vocabulary():
|
||||
valid = {"int", "float", "str", "bool", "json"}
|
||||
for (adapter, key), spec in REGISTRY.items():
|
||||
assert spec["type"] in valid, f"{adapter}.{key}: invalid type {spec['type']!r}"
|
||||
|
||||
|
||||
def test_adapter_meta_includes_every_registry_adapter():
|
||||
"""Every adapter referenced in REGISTRY should have a meta row so the
|
||||
GUI can render an include_in_llm_context toggle for it."""
|
||||
reg_adapters = {a for a, _ in REGISTRY}
|
||||
meta_adapters = set(ADAPTER_META)
|
||||
missing = reg_adapters - meta_adapters
|
||||
assert not missing, f"adapters in REGISTRY but missing ADAPTER_META: {missing}"
|
||||
Loading…
Add table
Add a link
Reference in a new issue