"""ORCH-087 (BR-G1): tracker_messages ledger — no orphaned cards in bump mode. The scalar tasks.tracker_message_id only ever knew the LAST mid, so any lost reference (delete-fail+send-ok, race, restart) orphaned older cards forever. The additive tracker_messages ledger lets every bump delete ALL still-open mids, not just the last one. These tests model the dominant orphan generators (vopros 2 in ADR-001) with Telegram fully mocked (no network). Covers TC-01..TC-05 / AC-1.2, AC-1.3, AC-X.1. """ import os import tempfile os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") _test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_orphans.db") os.environ["ORCH_DB_PATH"] = _test_db import pytest # noqa: E402 import src.db as db_module # noqa: E402 from src.db import ( # noqa: E402 init_db, get_db, get_tracker_message_id, set_tracker_message_id, add_tracker_message, get_open_tracker_messages, mark_tracker_message_deleted, ) from src import notifications as N # noqa: E402 @pytest.fixture(autouse=True) def setup_db(monkeypatch): monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False) if os.path.exists(_test_db): os.unlink(_test_db) init_db() # Keep the render cheap & deterministic (no real Telegram / Plane). monkeypatch.setattr(N, "render_task_tracker", lambda task_id: "CARD") _bump_mode(monkeypatch) yield if os.path.exists(_test_db): os.unlink(_test_db) def _bump_mode(monkeypatch): monkeypatch.setattr(N._get_settings(), "tracker_mode", "bump", raising=False) def _mk_task(stage="development", wid="ORCH-087"): conn = get_db() cur = conn.execute( "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) " "VALUES (?, ?, ?, ?, ?, ?)", ("p1", wid, "orchestrator", "feature/ORCH-087-x", stage, "orphan test"), ) tid = cur.lastrowid conn.commit() conn.close() return tid # --------------------------------------------------------------------------- # # ledger helpers (direct DB contract) # --------------------------------------------------------------------------- # def test_ledger_add_get_mark(monkeypatch): """add -> open set; mark_deleted -> drops out; INSERT OR IGNORE idempotent.""" tid = _mk_task() add_tracker_message(tid, 10) add_tracker_message(tid, 11) add_tracker_message(tid, 10) # duplicate -> ignored, no resurrection assert get_open_tracker_messages(tid) == [10, 11] mark_tracker_message_deleted(tid, 10) assert get_open_tracker_messages(tid) == [11] # re-add of a deleted mid is ignored (PK exists) -> stays deleted. add_tracker_message(tid, 10) assert get_open_tracker_messages(tid) == [11] # --------------------------------------------------------------------------- # # TC-01: bump deletes ALL known open mids, not just the last # --------------------------------------------------------------------------- # def test_bump_deletes_all_open_mids(monkeypatch): """TC-01/AC-1.2: every still-open card is deleted on the next bump.""" tid = _mk_task() # Three orphans accumulated in the ledger from earlier desyncs. for m in (100, 101, 102): add_tracker_message(tid, m) set_tracker_message_id(tid, 102) # scalar only knows the last one deleted = [] monkeypatch.setattr(N, "delete_telegram", lambda mid: deleted.append(mid) or True) monkeypatch.setattr(N, "send_telegram", lambda text, disable_notification=False: 200) N.update_task_tracker(tid) assert sorted(deleted) == [100, 101, 102] # ALL open mids deleted # Old ones marked gone; only the new card is open. assert get_open_tracker_messages(tid) == [200] assert get_tracker_message_id(tid) == 200 # --------------------------------------------------------------------------- # # TC-02: send -> None keeps the ledger/pointer intact (BR-6 / R-3) # --------------------------------------------------------------------------- # def test_send_none_keeps_ledger_and_pointer(monkeypatch): """TC-02/AC-1.3: send fails -> no new mid recorded, pointer not wiped.""" tid = _mk_task() add_tracker_message(tid, 100) set_tracker_message_id(tid, 100) # delete fails transiently so 100 stays open (alive); send returns None. monkeypatch.setattr(N, "delete_telegram", lambda mid: False) sends = [] monkeypatch.setattr(N, "send_telegram", lambda text, disable_notification=False: sends.append(1) or None) N.update_task_tracker(tid) # must not raise assert len(sends) == 1 # exactly one attempt assert get_tracker_message_id(tid) == 100 # pointer preserved assert get_open_tracker_messages(tid) == [100] # 100 still tracked for retry # --------------------------------------------------------------------------- # # TC-03: delete False -> stays open; "already gone" -> dropped # --------------------------------------------------------------------------- # def test_delete_transient_stays_open_gone_dropped(monkeypatch): """TC-03: transient-delete mid retried next bump; gone mid excluded.""" tid = _mk_task() add_tracker_message(tid, 100) # will fail transiently -> stays add_tracker_message(tid, 101) # will be 'gone' (True) -> dropped def _del(mid): return mid != 100 # 100 -> False (transient), 101 -> True (gone) monkeypatch.setattr(N, "delete_telegram", _del) monkeypatch.setattr(N, "send_telegram", lambda text, disable_notification=False: 300) N.update_task_tracker(tid) # 100 still open (retry), 101 marked deleted, 300 new card open. assert set(get_open_tracker_messages(tid)) == {100, 300} assert get_tracker_message_id(tid) == 300 # --------------------------------------------------------------------------- # # TC-04: rapid repeats / race -> one live card, <=1 send per call # --------------------------------------------------------------------------- # def test_repeated_bumps_converge_to_one_card(monkeypatch): """TC-04/AC-X.1: repeated bumps self-heal to exactly one open card.""" tid = _mk_task() seq = iter([501, 502, 503, 504]) sends_per_call = [] def _send(text, disable_notification=False): sends_per_call.append(1) return next(seq) monkeypatch.setattr(N, "delete_telegram", lambda mid: True) monkeypatch.setattr(N, "send_telegram", _send) for _ in range(4): before = len(sends_per_call) N.update_task_tracker(tid) assert len(sends_per_call) - before == 1 # <=1 send per call # After the last bump only the newest card is open; all earlier deleted. assert get_open_tracker_messages(tid) == [504] assert get_tracker_message_id(tid) == 504 # --------------------------------------------------------------------------- # # TC-05: ledger survives a "restart" (read from DB) -> old cards cleaned # --------------------------------------------------------------------------- # def test_ledger_survives_restart(monkeypatch): """TC-05/AC-1.3: mids persisted in DB are cleaned on the next bump.""" tid = _mk_task() # Simulate a previous process that created two cards but lost the scalar to # one of them (orphan): both are in the ledger though. add_tracker_message(tid, 700) add_tracker_message(tid, 701) set_tracker_message_id(tid, 701) # scalar lost 700 deleted = [] monkeypatch.setattr(N, "delete_telegram", lambda mid: deleted.append(mid) or True) monkeypatch.setattr(N, "send_telegram", lambda text, disable_notification=False: 800) # "Fresh process" reads the ledger straight from the DB. N.update_task_tracker(tid) assert sorted(deleted) == [700, 701] # the orphan 700 is reaped too assert get_open_tracker_messages(tid) == [800] # --------------------------------------------------------------------------- # # never-raise on ledger/DB explosion # --------------------------------------------------------------------------- # def test_bump_never_raises_on_ledger_error(monkeypatch): """AC-X.2: a ledger read blowing up does not break the bump path.""" tid = _mk_task() monkeypatch.setattr(N, "get_open_tracker_messages", lambda task_id: (_ for _ in ()).throw(RuntimeError("db")), raising=False) # Even if the import-bound name is used, force the failure via db module too. monkeypatch.setattr(db_module, "get_open_tracker_messages", lambda task_id: (_ for _ in ()).throw(RuntimeError("db")), raising=False) sent = [] monkeypatch.setattr(N, "delete_telegram", lambda mid: True) monkeypatch.setattr(N, "send_telegram", lambda text, disable_notification=False: sent.append(1) or 900) # Must not raise; still sends the fresh card. N.update_task_tracker(tid) assert sent == [1]