mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
fix(persistence): derive SCHEMA_VERSION from migrations; unbreak the red suite (#140)
* fix(persistence): derive SCHEMA_VERSION from the migrations directory db.py hardcoded SCHEMA_VERSION = 26 while migrations/ had already reached v29 (v27 dispatcher floor-drop counter, v28 mesh_observations, v29 IPAWS). The migration runner globs the directory and applies every vN.sql it finds regardless of the constant, so a fresh DB actually landed at 29 while the constant claimed 26 -- a three-version drift that three tests were correctly catching. Derive it from the highest vN.sql present instead of bumping the literal, so it cannot drift again the next time someone adds a migration. Falls back to 0 if the directory is missing so import never fails; the migrations dir sits alongside db.py and ships with the package (Dockerfile COPYs meshai/). Adds a regression guard asserting the constant matches the highest migration file, and updates three tests that hardcoded 26 as a literal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(tle): make TLE fixtures time-relative so they cannot expire The ISS fixtures hardcoded epochs of 2026-06-30/07-01/07-02. tle_handler sets STALE_DAYS = 14 and get_tle_by_norad() filters on epoch >= now - 14d, so the fixtures silently aged out on 2026-07-02 and the tests began failing -- a time bomb, not a regression. Compute epochs relative to wall-clock now (base = now - 2d, +/-1d for newer/older) with correct TLE epoch-field encoding and mod-10 checksum. STALE_DAYS is untouched -- widening it in product code would have changed production behavior to paper over a test bug. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(fire-tracker): pin config + history db to tmp_path load_config() defaults HistoryConfig.database to the relative path "conversations.db", resolved against the process CWD, so every test calling load_config() with no override shares one file for the whole session. The conftest DB-isolation fixture only covers MESHAI_DB_PATH, not this. Point both the config dir and the history database at the test's tmp_path. NOTE: this does NOT resolve the order-dependent failure -- the test still passes standalone and fails in a full run, so the polluting state lives somewhere other than config/history. Left failing rather than weakened; root cause still unidentified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8460ab50e0
commit
16f01b29d4
6 changed files with 125 additions and 33 deletions
|
|
@ -30,9 +30,11 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_DB_PATH = "/data/meshai.sqlite"
|
DEFAULT_DB_PATH = "/data/meshai.sqlite"
|
||||||
MESHAI_DB_PATH_ENV = "MESHAI_DB_PATH"
|
MESHAI_DB_PATH_ENV = "MESHAI_DB_PATH"
|
||||||
SCHEMA_VERSION = 26
|
|
||||||
SCHEMA_META_TABLE = "schema_meta"
|
SCHEMA_META_TABLE = "schema_meta"
|
||||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||||
|
# SCHEMA_VERSION is derived below (after _read_migration_files is defined)
|
||||||
|
# from the highest vN.sql present in MIGRATIONS_DIR, so it can never drift
|
||||||
|
# from what a fresh DB actually lands at -- see _derive_schema_version().
|
||||||
|
|
||||||
# Per-thread connection pool. Each thread that calls get_db() gets its
|
# Per-thread connection pool. Each thread that calls get_db() gets its
|
||||||
# own sqlite3.Connection cached on threading.local. Tests can clear
|
# own sqlite3.Connection cached on threading.local. Tests can clear
|
||||||
|
|
@ -163,6 +165,22 @@ def _read_migration_files() -> list[tuple[int, str, str]]:
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_schema_version() -> int:
|
||||||
|
"""Highest migration version present in MIGRATIONS_DIR, i.e. the
|
||||||
|
version a fresh DB lands at once _apply_migrations() runs (it applies
|
||||||
|
every vN.sql it finds, independent of any hardcoded constant). Falls
|
||||||
|
back to 0 if the directory is missing/empty so import never fails;
|
||||||
|
that would only happen in a broken packaging layout, since
|
||||||
|
MIGRATIONS_DIR sits alongside this file and is copied/installed with
|
||||||
|
the rest of the meshai package (see Dockerfile: `COPY meshai/ ./meshai/`
|
||||||
|
then `pip install -e .`)."""
|
||||||
|
versions = [n for n, _, _ in _read_migration_files()]
|
||||||
|
return max(versions) if versions else 0
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA_VERSION = _derive_schema_version()
|
||||||
|
|
||||||
|
|
||||||
def _current_version(conn: sqlite3.Connection) -> int:
|
def _current_version(conn: sqlite3.Connection) -> int:
|
||||||
"""Return the highest applied migration version, or 0 if none."""
|
"""Return the highest applied migration version, or 0 if none."""
|
||||||
# Does the schema_meta table exist?
|
# Does the schema_meta table exist?
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ def test_router_scope_type_defined_before_env_check():
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
def test_natural_language_fire_question_routes_to_llm():
|
def test_natural_language_fire_question_routes_to_llm(tmp_path):
|
||||||
"""The LLM DM path is the sole interface for natural-language fire
|
"""The LLM DM path is the sole interface for natural-language fire
|
||||||
questions. Pre-revised commit there was a `?status` intent that
|
questions. Pre-revised commit there was a `?status` intent that
|
||||||
rewrote the query in-router; this test confirms the rewrite is gone
|
rewrote the query in-router; this test confirms the rewrite is gone
|
||||||
|
|
@ -81,7 +81,16 @@ def test_natural_language_fire_question_routes_to_llm():
|
||||||
from meshai.history import ConversationHistory
|
from meshai.history import ConversationHistory
|
||||||
from meshai.commands.dispatcher import create_dispatcher
|
from meshai.commands.dispatcher import create_dispatcher
|
||||||
|
|
||||||
cfg = load_config()
|
# load_config() defaults HistoryConfig.database to the relative path
|
||||||
|
# "conversations.db", resolved against the process CWD. Left alone,
|
||||||
|
# every test in the suite that calls load_config() with no override
|
||||||
|
# shares that one file for the whole pytest session, so conversation
|
||||||
|
# rows written by an unrelated, earlier-running test file can leak
|
||||||
|
# into this test's routing decision. Point both the config dir and
|
||||||
|
# the history database at this test's own tmp_path to make it
|
||||||
|
# hermetic regardless of run order.
|
||||||
|
cfg = load_config(tmp_path / "config")
|
||||||
|
cfg.history.database = str(tmp_path / "conversations.db")
|
||||||
history = ConversationHistory(cfg.history)
|
history = ConversationHistory(cfg.history)
|
||||||
|
|
||||||
async def _run():
|
async def _run():
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,11 @@ from meshai.persistence.observer_locations import (
|
||||||
|
|
||||||
# -- schema / migration -------------------------------------------------------
|
# -- schema / migration -------------------------------------------------------
|
||||||
|
|
||||||
def test_schema_version_is_25():
|
def test_schema_version_is_current():
|
||||||
assert SCHEMA_VERSION == 26
|
# SCHEMA_VERSION is derived from the highest vN.sql in migrations/, so
|
||||||
|
# this just guards against the derivation returning something bogus
|
||||||
|
# (e.g. 0, which would mean the migrations dir wasn't found).
|
||||||
|
assert SCHEMA_VERSION >= 23
|
||||||
|
|
||||||
|
|
||||||
def test_observer_locations_table_exists():
|
def test_observer_locations_table_exists():
|
||||||
|
|
@ -30,7 +33,7 @@ def test_schema_meta_at_current():
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT value FROM schema_meta WHERE key='version'").fetchone()
|
"SELECT value FROM schema_meta WHERE key='version'").fetchone()
|
||||||
assert int(row["value"]) == 26
|
assert int(row["value"]) == SCHEMA_VERSION
|
||||||
|
|
||||||
|
|
||||||
# -- accessors ----------------------------------------------------------------
|
# -- accessors ----------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,23 @@ def test_schema_version_recorded(tmp_db):
|
||||||
assert int(row["value"]) == SCHEMA_VERSION
|
assert int(row["value"]) == SCHEMA_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_version_matches_highest_migration_file():
|
||||||
|
"""SCHEMA_VERSION is derived from migrations/ at import time (see
|
||||||
|
db._derive_schema_version); this test independently re-derives the
|
||||||
|
expected value straight off the filenames so a future regression
|
||||||
|
(e.g. someone re-hardcoding the constant) is caught even if the
|
||||||
|
derivation logic itself is what breaks."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
versions = []
|
||||||
|
for p in persistence_db.MIGRATIONS_DIR.iterdir():
|
||||||
|
m = re.match(r"^v(\d+)", p.stem)
|
||||||
|
if p.suffix.lower() == ".sql" and m:
|
||||||
|
versions.append(int(m.group(1)))
|
||||||
|
assert versions, "no vN.sql migration files found"
|
||||||
|
assert SCHEMA_VERSION == max(versions)
|
||||||
|
|
||||||
|
|
||||||
def test_migration_idempotent_rerun(tmp_db):
|
def test_migration_idempotent_rerun(tmp_db):
|
||||||
init_db()
|
init_db()
|
||||||
# Force a "second startup" by closing the connection and clearing the
|
# Force a "second startup" by closing the connection and clearing the
|
||||||
|
|
|
||||||
|
|
@ -99,9 +99,11 @@ def _ingest_envelope(norad_id=25544, observer="Boise", max_el=72.5,
|
||||||
# ── schema / migration ───────────────────────────────────────────────
|
# ── schema / migration ───────────────────────────────────────────────
|
||||||
|
|
||||||
def test_schema_version_is_current():
|
def test_schema_version_is_current():
|
||||||
# Bumped to 26 by the generic-source migration (v26 generic_events); v24
|
# SCHEMA_VERSION is derived from the highest vN.sql in migrations/, so
|
||||||
# avalanche_events, v25 ducting_events; was 23 at native-satpass (v23).
|
# this just guards against the derivation returning something bogus
|
||||||
assert SCHEMA_VERSION == 26
|
# (e.g. 0, which would mean the migrations dir wasn't found). The v22
|
||||||
|
# migration (this file's focus) must always be <= the current version.
|
||||||
|
assert SCHEMA_VERSION >= 22
|
||||||
|
|
||||||
|
|
||||||
def test_v22_migration_applies_and_adds_due_at_column(tmp_path, monkeypatch):
|
def test_v22_migration_applies_and_adds_due_at_column(tmp_path, monkeypatch):
|
||||||
|
|
@ -114,7 +116,7 @@ def test_v22_migration_applies_and_adds_due_at_column(tmp_path, monkeypatch):
|
||||||
close_thread_connection()
|
close_thread_connection()
|
||||||
conn = init_db()
|
conn = init_db()
|
||||||
row = conn.execute("SELECT value FROM schema_meta WHERE key='version'").fetchone()
|
row = conn.execute("SELECT value FROM schema_meta WHERE key='version'").fetchone()
|
||||||
assert int(row["value"]) == 26
|
assert int(row["value"]) == SCHEMA_VERSION
|
||||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(satpass_pending)")}
|
cols = {r["name"] for r in conn.execute("PRAGMA table_info(satpass_pending)")}
|
||||||
assert "due_at" in cols
|
assert "due_at" in cols
|
||||||
close_thread_connection()
|
close_thread_connection()
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ tolerance. HTTP is monkeypatched — no network.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as _dt
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from meshai.env.tle_fetch import (
|
from meshai.env.tle_fetch import (
|
||||||
|
|
@ -18,27 +20,68 @@ from meshai.config import SatpassConfig
|
||||||
from meshai.persistence import get_db
|
from meshai.persistence import get_db
|
||||||
|
|
||||||
|
|
||||||
# A valid ISS 3-line set. Line-1 epoch field (cols 19-32) = 26182.50000000
|
# -- time-relative TLE fixture generation --------------------------------
|
||||||
# -> 2026 day-of-year 182.5 -> 2026-07-01T12:00:00+00:00.
|
#
|
||||||
ISS_TLE = (
|
# These used to be hardcoded 3-line sets with a fixed 2026-06-30..07-02
|
||||||
"ISS (ZARYA)\n"
|
# epoch. get_tle_by_norad() filters on `epoch >= now() - STALE_DAYS`
|
||||||
"1 25544U 98067A 26182.50000000 .00016717 00000-0 10270-3 0 9008\n"
|
# (STALE_DAYS=14, real wall-clock `now`), so a fixed-date fixture ages out
|
||||||
"2 25544 51.6400 208.9163 0007417 17.6777 85.6621 15.54225995 12345\n"
|
# 14 days after it's written and silently starts failing
|
||||||
)
|
# test_tick_upserts_into_sat_tles / test_latest_epoch_wins_on_refetch.
|
||||||
|
# Deriving the epoch from the real current time at test-collection time
|
||||||
|
# means these can never expire again.
|
||||||
|
|
||||||
# Same satellite, a NEWER epoch (26183.50 -> 2026-07-02T12:00Z).
|
|
||||||
ISS_TLE_NEWER = (
|
|
||||||
"ISS (ZARYA)\n"
|
|
||||||
"1 25544U 98067A 26183.50000000 .00016717 00000-0 10270-3 0 9010\n"
|
|
||||||
"2 25544 51.6400 208.9163 0007417 17.6777 85.6621 15.54225995 12347\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Same satellite, an OLDER epoch (26181.50 -> 2026-06-30T12:00Z).
|
def _tle_epoch_field(dt: _dt.datetime) -> str:
|
||||||
ISS_TLE_OLDER = (
|
"""Build a TLE line-1 epoch field (cols 19-32): YYDDD.DDDDDDDD."""
|
||||||
"ISS (ZARYA)\n"
|
yy = dt.year % 100
|
||||||
"1 25544U 98067A 26181.50000000 .00016717 00000-0 10270-3 0 9006\n"
|
start_of_year = _dt.datetime(dt.year, 1, 1, tzinfo=_dt.timezone.utc)
|
||||||
"2 25544 51.6400 208.9163 0007417 17.6777 85.6621 15.54225995 12343\n"
|
doy_frac = (dt - start_of_year).total_seconds() / 86400.0 + 1.0
|
||||||
)
|
doy_int = int(doy_frac)
|
||||||
|
frac = doy_frac - doy_int
|
||||||
|
return f"{yy:02d}{doy_int:03d}.{round(frac * 1e8):08d}"
|
||||||
|
|
||||||
|
|
||||||
|
def _tle_checksum(line_without_checksum_digit: str) -> int:
|
||||||
|
"""Standard TLE mod-10 checksum: sum of digits, '-' counts as 1,
|
||||||
|
everything else 0."""
|
||||||
|
total = 0
|
||||||
|
for ch in line_without_checksum_digit:
|
||||||
|
if ch.isdigit():
|
||||||
|
total += int(ch)
|
||||||
|
elif ch == "-":
|
||||||
|
total += 1
|
||||||
|
return total % 10
|
||||||
|
|
||||||
|
|
||||||
|
def _iss_tle_block(dt: _dt.datetime, element_set_num: str, revnum: str) -> str:
|
||||||
|
"""Build a valid 3-line ISS (ZARYA) TLE with the given epoch. All
|
||||||
|
other orbital elements are fixed (arbitrary but internally
|
||||||
|
consistent) — only epoch/checksum/element-set/revnum vary, mirroring
|
||||||
|
the original hand-written fixtures."""
|
||||||
|
line1_body = (f"1 25544U 98067A {_tle_epoch_field(dt)} .00016717 "
|
||||||
|
f"00000-0 10270-3 0 {element_set_num}")
|
||||||
|
line1 = f"{line1_body}{_tle_checksum(line1_body)}"
|
||||||
|
line2 = (f"2 25544 51.6400 208.9163 0007417 17.6777 85.6621 "
|
||||||
|
f"15.54225995 {revnum}")
|
||||||
|
return f"ISS (ZARYA)\n{line1}\n{line2}\n"
|
||||||
|
|
||||||
|
|
||||||
|
# Base epoch: 2 days ago. Comfortably inside the 14-day STALE_DAYS window
|
||||||
|
# no matter when the suite runs, and never in the future.
|
||||||
|
_NOW = _dt.datetime.now(_dt.timezone.utc)
|
||||||
|
_BASE_EPOCH = _NOW - _dt.timedelta(days=2)
|
||||||
|
|
||||||
|
# A valid ISS 3-line set at the base epoch.
|
||||||
|
ISS_TLE = _iss_tle_block(_BASE_EPOCH, "900", "12345")
|
||||||
|
# Same satellite, a NEWER epoch (1 day after base).
|
||||||
|
ISS_TLE_NEWER = _iss_tle_block(_BASE_EPOCH + _dt.timedelta(days=1), "901", "12347")
|
||||||
|
# Same satellite, an OLDER epoch (1 day before base).
|
||||||
|
ISS_TLE_OLDER = _iss_tle_block(_BASE_EPOCH - _dt.timedelta(days=1), "900", "12343")
|
||||||
|
|
||||||
|
# ISO epoch string parse_tle_epoch() will produce for ISS_TLE's line 1 --
|
||||||
|
# computed via the real parser (not re-derived independently) so
|
||||||
|
# assertions can't drift from the actual parsing behavior under test.
|
||||||
|
ISS_TLE_EPOCH_ISO = parse_tle_epoch(ISS_TLE.splitlines()[1])
|
||||||
|
|
||||||
|
|
||||||
def _adapter(**overrides) -> TLEFetchAdapter:
|
def _adapter(**overrides) -> TLEFetchAdapter:
|
||||||
|
|
@ -72,7 +115,7 @@ def test_parse_tle_block_basic():
|
||||||
assert r["name"] == "ISS (ZARYA)"
|
assert r["name"] == "ISS (ZARYA)"
|
||||||
assert r["line1"].startswith("1 25544")
|
assert r["line1"].startswith("1 25544")
|
||||||
assert r["line2"].startswith("2 25544")
|
assert r["line2"].startswith("2 25544")
|
||||||
assert r["epoch"].startswith("2026-07-01T12:00:00")
|
assert r["epoch"] == ISS_TLE_EPOCH_ISO
|
||||||
|
|
||||||
|
|
||||||
def test_parse_tle_block_skips_malformed():
|
def test_parse_tle_block_skips_malformed():
|
||||||
|
|
@ -104,7 +147,7 @@ def test_tick_upserts_into_sat_tles(monkeypatch):
|
||||||
assert row["name"] == "ISS (ZARYA)"
|
assert row["name"] == "ISS (ZARYA)"
|
||||||
assert row["line1"].startswith("1 25544")
|
assert row["line1"].startswith("1 25544")
|
||||||
assert row["line2"].startswith("2 25544")
|
assert row["line2"].startswith("2 25544")
|
||||||
assert row["epoch"].startswith("2026-07-01T12:00:00")
|
assert row["epoch"] == ISS_TLE_EPOCH_ISO
|
||||||
|
|
||||||
|
|
||||||
def test_latest_epoch_wins_on_refetch(monkeypatch):
|
def test_latest_epoch_wins_on_refetch(monkeypatch):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue