""" test_cache_invalidation.py — verify post-erase key-reuse correctness. The device's event-key counter resets to 0x01110000 after every memory erase, so a bare-key dedup (the old behaviour) silently treats a freshly-recorded event 0 as if it were the previously-downloaded one. These tests exercise the (key, timestamp)-based eviction logic in: - bridges/ach_server.py (state-file migration + force flag) - sfm/server.py (_LiveCache.set_events / set_waveform) Run: python tests/test_cache_invalidation.py """ from __future__ import annotations import json import os import sys import tempfile from pathlib import Path try: import pytest except ImportError: pytest = None # type: ignore sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # ── ACH state migration ─────────────────────────────────────────────────────── def test_ach_state_legacy_migration(tmp_path: Path): """ Legacy v1 state with a `downloaded_keys` list is migrated on _load_state to the v2 `downloaded_events` dict. All legacy keys come back with empty timestamps so the (key, ts) compare in get_events() always falls through to a fresh download. """ from bridges.ach_server import _load_state state_path = tmp_path / "ach_state.json" legacy = { "BE11529": { "downloaded_keys": ["01110000", "0111245a"], "max_downloaded_key": "0111245a", "last_seen": "2026-04-11T01:04:36", "serial": "BE11529", "peer": "63.43.212.232:51920", }, } state_path.write_text(json.dumps(legacy)) migrated = _load_state(state_path) unit = migrated["BE11529"] assert "downloaded_keys" not in unit assert unit["downloaded_events"] == { "01110000": "", "0111245a": "", } # max_downloaded_key is preserved verbatim assert unit["max_downloaded_key"] == "0111245a" def test_ach_state_v2_passes_through(tmp_path: Path): """A v2 state file is returned verbatim — no migration touches it.""" from bridges.ach_server import _load_state state_path = tmp_path / "ach_state.json" v2 = { "BE11529": { "downloaded_events": { "01110000": "2026-04-15T14:23:45", "0111245a": "2026-04-16T09:01:12", }, "max_downloaded_key": "0111245a", "serial": "BE11529", }, } state_path.write_text(json.dumps(v2)) loaded = _load_state(state_path) assert loaded["BE11529"]["downloaded_events"] == v2["BE11529"]["downloaded_events"] def test_ach_state_missing_returns_empty(tmp_path: Path): """Nonexistent state path → empty dict (not an error).""" from bridges.ach_server import _load_state assert _load_state(tmp_path / "absent.json") == {} # ── _LiveCache eviction ─────────────────────────────────────────────────────── def _ev(index: int, key: str, ts: str) -> dict: return {"index": index, "waveform_key": key, "timestamp": ts} def test_live_cache_set_events_no_eviction_when_keys_match(): """No flush when incoming events match the cached (key, ts) at each index.""" from sfm.live_cache import LiveCache as _LiveCache c = _LiveCache() conn = "tcp:1.2.3.4:12345" c.set_events(conn, 2, [_ev(0, "01110000", "2026-04-15T14:23:45"), _ev(1, "0111245a", "2026-04-16T09:01:12")]) c.set_waveform(conn, 0, _ev(0, "01110000", "2026-04-15T14:23:45")) # Same events again — must not flush. c.set_events(conn, 2, [_ev(0, "01110000", "2026-04-15T14:23:45"), _ev(1, "0111245a", "2026-04-16T09:01:12")]) assert c._events[conn][0] == 2 assert (conn, 0) in c._waveforms def test_live_cache_set_events_flushes_on_post_erase_collision(): """ Index 0 keeps the same key (01110000 reuses) but the timestamp differs → device was erased + re-recorded → flush all events + waveforms for the device. """ from sfm.live_cache import LiveCache as _LiveCache c = _LiveCache() conn = "tcp:1.2.3.4:12345" # First "session": index 0 key=01110000 ts=2026-04-15. c.set_events(conn, 1, [_ev(0, "01110000", "2026-04-15T14:23:45")]) c.set_waveform(conn, 0, _ev(0, "01110000", "2026-04-15T14:23:45")) assert (conn, 0) in c._waveforms # Second "session" after erase: index 0 still key=01110000 but new ts. c.set_events(conn, 1, [_ev(0, "01110000", "2026-05-06T12:34:56")]) # Stale waveform for index 0 must have been flushed by the eviction path # before the new event was inserted. The new events list IS in cache but # the cached waveform from the prior session is gone. assert (conn, 0) not in c._waveforms assert c._events[conn][1][0]["timestamp"] == "2026-05-06T12:34:56" def test_live_cache_set_waveform_flushes_on_mismatch(): """set_waveform alone should also evict when (key, ts) differs.""" from sfm.live_cache import LiveCache as _LiveCache c = _LiveCache() conn = "tcp:1.2.3.4:12345" c.set_waveform(conn, 0, _ev(0, "01110000", "2026-04-15T14:23:45")) c.set_waveform(conn, 1, _ev(1, "0111245a", "2026-04-16T09:01:12")) # Index 0 swap: same key, new timestamp. c.set_waveform(conn, 0, _ev(0, "01110000", "2026-05-06T12:34:56")) # Index 1's stale waveform must be flushed — keeping it would mix eras. assert (conn, 1) not in c._waveforms # The newly-inserted index 0 entry is what's there. assert c._waveforms[(conn, 0)]["timestamp"] == "2026-05-06T12:34:56" def test_live_cache_partial_signature_does_not_flush(): """ If incoming event lacks waveform_key OR timestamp, we cannot prove a mismatch — eviction must NOT trigger. Avoids spurious flushes from legacy / partial event shapes. """ from sfm.live_cache import LiveCache as _LiveCache c = _LiveCache() conn = "tcp:1.2.3.4:12345" c.set_waveform(conn, 0, _ev(0, "01110000", "2026-04-15T14:23:45")) # Incoming entry missing the timestamp — cannot prove a mismatch. c.set_waveform(conn, 0, {"index": 0, "waveform_key": "01110000"}) # Cache should contain the new entry; the implementation overwrites # the index-0 row but does NOT flush other indices. Since there are no # other indices in this test, just check the entry exists. assert (conn, 0) in c._waveforms if __name__ == "__main__": if pytest is not None: pytest.main([__file__, "-v"]) else: import inspect import traceback as _tb passed = failed = 0 for _name, _fn in sorted(globals().items()): if not _name.startswith("test_") or not callable(_fn): continue try: _sig = inspect.signature(_fn) if "tmp_path" in _sig.parameters: with tempfile.TemporaryDirectory() as _td: _fn(Path(_td)) else: _fn() print(f"PASS {_name}") passed += 1 except Exception: print(f"FAIL {_name}") _tb.print_exc() failed += 1 print(f"\n{passed} passed, {failed} failed") sys.exit(0 if failed == 0 else 1)