mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
* 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>
246 lines
9.6 KiB
Python
246 lines
9.6 KiB
Python
"""Tests for the satpass persisted-timer reboot-recovery fix.
|
|
|
|
Pending satellite-pass consolidations used to be scheduled only as in-memory
|
|
asyncio TimerHandles, so a restart orphaned any satpass_pending rows: the row
|
|
survived but its timer did not, and it was never consolidated/broadcast.
|
|
|
|
The fix persists a durable `due_at` on each pending row and adds a startup
|
|
sweep (`CentralConsumer._sweep_pending_satpass`) that reconstructs a timer for
|
|
every pending consolidated_id off its persisted due_at, reusing the existing
|
|
`_satpass_consolidation_fire` emit path.
|
|
|
|
These tests cover:
|
|
- a PAST-due orphan is recovered (its timer fires -> consolidation invoked)
|
|
- a FUTURE-due row is scheduled, NOT fired immediately
|
|
- `due_at` is persisted on the normal ingest path
|
|
- SCHEMA_VERSION == 22 and the v22 migration applies cleanly on a fresh DB
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import time
|
|
import types
|
|
|
|
import pytest
|
|
|
|
from meshai.persistence import get_db, init_db, SCHEMA_VERSION
|
|
from meshai.adapter_config import invalidate_cache
|
|
|
|
|
|
# ── helpers ───────────────────────────────────────────────────────────
|
|
|
|
def _enable_satpass_db(norad_ids=(25544,), dry_run=True):
|
|
"""Enable satpass and set opt-in norad_ids in the test DB."""
|
|
conn = get_db()
|
|
conn.execute("UPDATE adapter_config SET value_json='true' "
|
|
"WHERE adapter='satpass' AND key='enabled'")
|
|
conn.execute("UPDATE adapter_config SET value_json=? "
|
|
"WHERE adapter='satpass' AND key='dry_run'",
|
|
(json.dumps(bool(dry_run)),))
|
|
conn.execute("UPDATE adapter_config SET value_json=? "
|
|
"WHERE adapter='satpass' AND key='norad_ids'",
|
|
(json.dumps(list(norad_ids)),))
|
|
invalidate_cache()
|
|
|
|
|
|
def _insert_pending(consolidated_id, *, due_at, observer="Boise",
|
|
norad_id=25544, received_at=None):
|
|
"""Write a single satpass_pending row with an explicit due_at."""
|
|
conn = get_db()
|
|
now = int(time.time()) if received_at is None else received_at
|
|
aos = now + 600
|
|
los = aos + 360
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO satpass_pending("
|
|
"consolidated_id, observer, sat_name, norad_id, max_elevation, "
|
|
"aos_at, los_at, aos_compass, los_compass, peak_compass, received_at, "
|
|
"due_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
(consolidated_id, observer, "ISS", norad_id, 72.5,
|
|
aos, los, "SW", "NE", "S", now, due_at))
|
|
|
|
|
|
def _make_consumer(bus=None):
|
|
"""Construct a CentralConsumer with minimal fakes (no NATS needed)."""
|
|
from meshai.central.consumer import CentralConsumer
|
|
env = types.SimpleNamespace(central=None)
|
|
return CentralConsumer(env, bus)
|
|
|
|
|
|
def _run(coro):
|
|
return asyncio.new_event_loop().run_until_complete(coro)
|
|
|
|
|
|
def _ingest_envelope(norad_id=25544, observer="Boise", max_el=72.5,
|
|
aos="2026-06-12T03:32:00Z", los="2026-06-12T03:38:00Z"):
|
|
return {
|
|
"specversion": "1.0",
|
|
"type": "central.sat.pass",
|
|
"source": "central",
|
|
"id": f"pass-{norad_id}-{aos}",
|
|
"data": {
|
|
"adapter": "n2yo_visualpasses",
|
|
"category": "pass.n2yo_visualpasses",
|
|
"data": {
|
|
"norad_id": norad_id,
|
|
"satellite_name": "ISS",
|
|
"observer_name": observer,
|
|
"max_elevation_deg": max_el,
|
|
"aos_time": aos,
|
|
"los_time": los,
|
|
"azimuth_at_peak_compass": "S",
|
|
"azimuth_at_aos_compass": "SW",
|
|
"azimuth_at_los_compass": "NE",
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
# ── schema / migration ───────────────────────────────────────────────
|
|
|
|
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). 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):
|
|
"""Fresh DB migrates cleanly and satpass_pending has due_at (v22 column)."""
|
|
from meshai.persistence import close_thread_connection
|
|
from meshai.persistence import db as persistence_db
|
|
db = str(tmp_path / "fresh-v22.sqlite")
|
|
monkeypatch.setenv("MESHAI_DB_PATH", db)
|
|
persistence_db._initialised.clear()
|
|
close_thread_connection()
|
|
conn = init_db()
|
|
row = conn.execute("SELECT value FROM schema_meta WHERE key='version'").fetchone()
|
|
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()
|
|
persistence_db._initialised.discard(db)
|
|
|
|
|
|
# ── due_at persisted on normal ingest ────────────────────────────────
|
|
|
|
def test_due_at_persisted_on_normal_ingest():
|
|
"""handle_satpass writes due_at = received_at + CONSOLIDATION_DELAY."""
|
|
from meshai.central.satpass_handler import (
|
|
handle_satpass, CONSOLIDATION_DELAY, _parse_iso_epoch)
|
|
_enable_satpass_db(norad_ids=[25544], dry_run=True)
|
|
for attr in ("_disabled_logged", "_no_norad_ids_logged"):
|
|
if hasattr(handle_satpass, attr):
|
|
delattr(handle_satpass, attr)
|
|
|
|
env = _ingest_envelope()
|
|
aos_epoch = _parse_iso_epoch("2026-06-12T03:32:00Z")
|
|
now = aos_epoch - 300 # inside horizon, before los
|
|
assert handle_satpass(env, "central.sat.pass.iss", now=now) is None
|
|
|
|
conn = get_db()
|
|
row = conn.execute(
|
|
"SELECT received_at, due_at FROM satpass_pending "
|
|
"WHERE norad_id=25544").fetchone()
|
|
assert row is not None, "ingest did not write a pending row"
|
|
assert row["due_at"] is not None
|
|
assert row["due_at"] == row["received_at"] + CONSOLIDATION_DELAY
|
|
assert row["due_at"] == now + CONSOLIDATION_DELAY
|
|
|
|
|
|
# ── startup sweep: past-due orphan is recovered ──────────────────────
|
|
|
|
def test_sweep_recovers_past_due_orphan(monkeypatch):
|
|
"""A pending row with due_at in the PAST fires consolidation via the sweep."""
|
|
_enable_satpass_db(norad_ids=[25544], dry_run=True)
|
|
now = int(time.time())
|
|
cid = "25544:ORPHAN"
|
|
_insert_pending(cid, due_at=now - 100, received_at=now - 105)
|
|
|
|
fired = []
|
|
import meshai.central.satpass_handler as sh
|
|
real = sh.consolidate_satpass_pending
|
|
|
|
def _spy(consolidated_id):
|
|
fired.append(consolidated_id)
|
|
return real(consolidated_id) # exercise the real path (dry-run -> None)
|
|
|
|
monkeypatch.setattr(sh, "consolidate_satpass_pending", _spy)
|
|
|
|
consumer = _make_consumer(bus=None)
|
|
|
|
async def _main():
|
|
consumer._sweep_pending_satpass(now=now)
|
|
# overdue orphan is armed at ~0.5s; give the loop time to fire it.
|
|
await asyncio.sleep(1.0)
|
|
|
|
_run(_main())
|
|
|
|
assert cid in fired, "sweep did not fire consolidation for the orphaned cid"
|
|
# Orphan recovered: consolidation (dry-run) drained its pending rows.
|
|
conn = get_db()
|
|
remaining = conn.execute(
|
|
"SELECT COUNT(*) AS n FROM satpass_pending WHERE consolidated_id=?",
|
|
(cid,)).fetchone()["n"]
|
|
assert remaining == 0
|
|
|
|
|
|
# ── startup sweep: future row scheduled, not fired now ───────────────
|
|
|
|
def test_sweep_schedules_future_row_without_firing(monkeypatch):
|
|
"""A pending row with due_at in the FUTURE is armed but does not fire yet."""
|
|
_enable_satpass_db(norad_ids=[25544], dry_run=True)
|
|
now = int(time.time())
|
|
cid = "25544:FUTURE"
|
|
_insert_pending(cid, due_at=now + 3600, received_at=now)
|
|
|
|
fired = []
|
|
import meshai.central.satpass_handler as sh
|
|
monkeypatch.setattr(sh, "consolidate_satpass_pending",
|
|
lambda c: fired.append(c))
|
|
|
|
consumer = _make_consumer(bus=None)
|
|
|
|
async def _main():
|
|
consumer._sweep_pending_satpass(now=now)
|
|
await asyncio.sleep(0.3)
|
|
|
|
_run(_main())
|
|
|
|
assert cid not in fired, "future row fired immediately"
|
|
assert cid in consumer._pending_satpass_timers, "future row was not armed"
|
|
# Pending row untouched (still awaiting its future fire).
|
|
conn = get_db()
|
|
remaining = conn.execute(
|
|
"SELECT COUNT(*) AS n FROM satpass_pending WHERE consolidated_id=?",
|
|
(cid,)).fetchone()["n"]
|
|
assert remaining == 1
|
|
|
|
|
|
# ── sweep does not double-schedule an already-armed cid ──────────────
|
|
|
|
def test_sweep_does_not_double_schedule(monkeypatch):
|
|
"""A cid already armed by the live path is skipped by the sweep."""
|
|
_enable_satpass_db(norad_ids=[25544], dry_run=True)
|
|
now = int(time.time())
|
|
cid = "25544:ARMED"
|
|
_insert_pending(cid, due_at=now - 10, received_at=now - 15)
|
|
|
|
consumer = _make_consumer(bus=None)
|
|
|
|
fired = []
|
|
import meshai.central.satpass_handler as sh
|
|
monkeypatch.setattr(sh, "consolidate_satpass_pending",
|
|
lambda c: fired.append(c))
|
|
|
|
async def _main():
|
|
sentinel = object()
|
|
consumer._pending_satpass_timers[cid] = sentinel # live path owns it
|
|
consumer._sweep_pending_satpass(now=now)
|
|
# The sweep must not have replaced the live handle.
|
|
assert consumer._pending_satpass_timers[cid] is sentinel
|
|
await asyncio.sleep(0.1)
|
|
|
|
_run(_main())
|
|
assert cid not in fired, "sweep double-scheduled an already-armed cid"
|