diff --git a/work/meshai/persistence/db.py b/work/meshai/persistence/db.py index 5039608..54e2ad4 100644 --- a/work/meshai/persistence/db.py +++ b/work/meshai/persistence/db.py @@ -30,9 +30,11 @@ logger = logging.getLogger(__name__) DEFAULT_DB_PATH = "/data/meshai.sqlite" MESHAI_DB_PATH_ENV = "MESHAI_DB_PATH" -SCHEMA_VERSION = 26 SCHEMA_META_TABLE = "schema_meta" 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 # 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 +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: """Return the highest applied migration version, or 0 if none.""" # Does the schema_meta table exist? diff --git a/work/tests/test_fire_tracker_phase4.py b/work/tests/test_fire_tracker_phase4.py index 2ca9904..e21b087 100644 --- a/work/tests/test_fire_tracker_phase4.py +++ b/work/tests/test_fire_tracker_phase4.py @@ -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 questions. Pre-revised commit there was a `?status` intent that 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.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) async def _run(): diff --git a/work/tests/test_observer_locations.py b/work/tests/test_observer_locations.py index 05b0173..97227f4 100644 --- a/work/tests/test_observer_locations.py +++ b/work/tests/test_observer_locations.py @@ -14,8 +14,11 @@ from meshai.persistence.observer_locations import ( # -- schema / migration ------------------------------------------------------- -def test_schema_version_is_25(): - assert SCHEMA_VERSION == 26 +def test_schema_version_is_current(): + # 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(): @@ -30,7 +33,7 @@ def test_schema_meta_at_current(): conn = get_db() row = conn.execute( "SELECT value FROM schema_meta WHERE key='version'").fetchone() - assert int(row["value"]) == 26 + assert int(row["value"]) == SCHEMA_VERSION # -- accessors ---------------------------------------------------------------- diff --git a/work/tests/test_persistence.py b/work/tests/test_persistence.py index 6f07716..cf62c84 100644 --- a/work/tests/test_persistence.py +++ b/work/tests/test_persistence.py @@ -90,6 +90,23 @@ def test_schema_version_recorded(tmp_db): 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): init_db() # Force a "second startup" by closing the connection and clearing the diff --git a/work/tests/test_satpass_persisted_timer.py b/work/tests/test_satpass_persisted_timer.py index d2a9ea9..adb84a8 100644 --- a/work/tests/test_satpass_persisted_timer.py +++ b/work/tests/test_satpass_persisted_timer.py @@ -99,9 +99,11 @@ def _ingest_envelope(norad_id=25544, observer="Boise", max_el=72.5, # ── schema / migration ─────────────────────────────────────────────── def test_schema_version_is_current(): - # Bumped to 26 by the generic-source migration (v26 generic_events); v24 - # avalanche_events, v25 ducting_events; was 23 at native-satpass (v23). - 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). 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): @@ -114,7 +116,7 @@ def test_v22_migration_applies_and_adds_due_at_column(tmp_path, monkeypatch): close_thread_connection() conn = init_db() 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)")} assert "due_at" in cols close_thread_connection() diff --git a/work/tests/test_tle_fetch.py b/work/tests/test_tle_fetch.py index 7038cdc..86b599e 100644 --- a/work/tests/test_tle_fetch.py +++ b/work/tests/test_tle_fetch.py @@ -6,6 +6,8 @@ tolerance. HTTP is monkeypatched — no network. """ from __future__ import annotations +import datetime as _dt + import pytest from meshai.env.tle_fetch import ( @@ -18,27 +20,68 @@ from meshai.config import SatpassConfig from meshai.persistence import get_db -# A valid ISS 3-line set. Line-1 epoch field (cols 19-32) = 26182.50000000 -# -> 2026 day-of-year 182.5 -> 2026-07-01T12:00:00+00:00. -ISS_TLE = ( - "ISS (ZARYA)\n" - "1 25544U 98067A 26182.50000000 .00016717 00000-0 10270-3 0 9008\n" - "2 25544 51.6400 208.9163 0007417 17.6777 85.6621 15.54225995 12345\n" -) +# -- time-relative TLE fixture generation -------------------------------- +# +# These used to be hardcoded 3-line sets with a fixed 2026-06-30..07-02 +# epoch. get_tle_by_norad() filters on `epoch >= now() - STALE_DAYS` +# (STALE_DAYS=14, real wall-clock `now`), so a fixed-date fixture ages out +# 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). -ISS_TLE_OLDER = ( - "ISS (ZARYA)\n" - "1 25544U 98067A 26181.50000000 .00016717 00000-0 10270-3 0 9006\n" - "2 25544 51.6400 208.9163 0007417 17.6777 85.6621 15.54225995 12343\n" -) +def _tle_epoch_field(dt: _dt.datetime) -> str: + """Build a TLE line-1 epoch field (cols 19-32): YYDDD.DDDDDDDD.""" + yy = dt.year % 100 + start_of_year = _dt.datetime(dt.year, 1, 1, tzinfo=_dt.timezone.utc) + 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: @@ -72,7 +115,7 @@ def test_parse_tle_block_basic(): assert r["name"] == "ISS (ZARYA)" assert r["line1"].startswith("1 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(): @@ -104,7 +147,7 @@ def test_tick_upserts_into_sat_tles(monkeypatch): assert row["name"] == "ISS (ZARYA)" assert row["line1"].startswith("1 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):