"""ORCH-094 — terminal-window-aware deploy-status guard (FR-2 / FR-5). Covers (04-test-plan.yaml): TC-01 deploy-status for a DB stage=done task converges to Done: a set_issue_monitoring/awaiting/deploying attempt on a terminal task drives Done (or no-op if already Done), never an intermediate status. TC-02 idempotency: a repeated terminal-aware setter call on an already-Done task never PATCHes an intermediate status (no Done<->deploy pendulum). TC-03 a non-terminal task (stage=deploy) is NOT suppressed: the deploy setters proceed normally (regression AC-4). TC-04 kill-switch off -> 1:1 prior behaviour (guard inert); on -> converge. TC-05 never-raise: an undeterminable DB stage / DB error degrades safely (ALLOW, no flapp, no exception). TC-12 non-self repo: zero regression — the guard is inert (self-hosting only). """ import os import tempfile import pytest _test_db = os.path.join(tempfile.gettempdir(), "test_deploy_status_guard.db") os.environ["ORCH_DB_PATH"] = _test_db os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") from unittest.mock import MagicMock # noqa: E402 import src.db as _db # noqa: E402 from src.db import init_db, get_db # noqa: E402 from src import deploy_status_guard as guard # noqa: E402 from src import plane_sync # noqa: E402 from src import post_deploy # noqa: E402 from src import config as cfg # noqa: E402 @pytest.fixture(autouse=True) def fresh_db(monkeypatch, tmp_path): monkeypatch.setattr(_db.settings, "db_path", _test_db) if os.path.exists(_test_db): os.unlink(_test_db) init_db() # Guard ON, self-hosting only (empty CSV) by default. monkeypatch.setattr(cfg.settings, "deploy_status_guard_enabled", True, raising=False) monkeypatch.setattr(cfg.settings, "deploy_status_guard_repos", "", raising=False) # post-deploy sentinels live under a fresh tmp dir (window closed by default). monkeypatch.setattr(post_deploy.settings, "repos_dir", str(tmp_path)) monkeypatch.setattr(post_deploy.settings, "host_repos_dir", str(tmp_path)) yield def _make_task(stage, repo="orchestrator", wi="ORCH-061", branch="feature/ORCH-061-x"): conn = get_db() conn.execute( "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) " "VALUES (?, ?, ?, ?, ?)", (f"plane-{wi}", wi, repo, branch, stage), ) conn.commit() conn.close() @pytest.fixture def spy_setters(monkeypatch): """Spy the low-level PATCH primitive + the Done convergence target.""" direct = MagicMock() done = MagicMock() monkeypatch.setattr(plane_sync, "_set_issue_state_direct", direct) monkeypatch.setattr(plane_sync, "set_issue_done", done) # Keep status resolution offline-deterministic. monkeypatch.setattr(plane_sync, "_resolve_project_id", lambda w=None, p=None: "proj-1") monkeypatch.setattr( plane_sync, "get_project_states", lambda pid: {"awaiting_deploy": "S-aw", "deploying": "S-dep", "monitoring": "S-mon"}, ) return direct, done # --- TC-01 ------------------------------------------------------------------ def test_tc01_done_task_converges_to_done(spy_setters): direct, done = spy_setters _make_task("done") # Window is NOT active (no ARMED sentinel) -> Monitoring is spurious. for setter in ( plane_sync.set_issue_monitoring, plane_sync.set_issue_awaiting_deploy, plane_sync.set_issue_deploying, ): done.reset_mock() direct.reset_mock() setter("ORCH-061") # Converged to Done; no intermediate deploy-status PATCH. done.assert_called_once_with("ORCH-061") direct.assert_not_called() def test_tc01_decide_verdicts_for_done(): _make_task("done") # No window -> all three converge. assert guard.decide("ORCH-061", guard.MONITORING) == guard.CONVERGE_DONE assert guard.decide("ORCH-061", guard.AWAITING) == guard.CONVERGE_DONE assert guard.decide("ORCH-061", guard.DEPLOYING) == guard.CONVERGE_DONE def test_tc01_decide_allows_monitoring_in_active_window(tmp_path, monkeypatch): _make_task("done") # Arm the window: ARMED present, DONE absent. post_deploy.write_marker("orchestrator", "ORCH-061", post_deploy.ARMED, "armed") assert post_deploy.window_active("orchestrator", "ORCH-061") is True assert guard.decide("ORCH-061", guard.MONITORING) == guard.ALLOW # Awaiting/Deploying are ALWAYS spurious for a done task, even with a window. assert guard.decide("ORCH-061", guard.AWAITING) == guard.CONVERGE_DONE # Once the window closes (DONE present) Monitoring converges too. post_deploy.mark_done("orchestrator", "ORCH-061") assert post_deploy.window_active("orchestrator", "ORCH-061") is False assert guard.decide("ORCH-061", guard.MONITORING) == guard.CONVERGE_DONE # --- TC-02 ------------------------------------------------------------------ def test_tc02_idempotent_no_pendulum(spy_setters): direct, done = spy_setters _make_task("done") # Repeated calls keep converging to Done; the intermediate Monitoring PATCH # never fires, so there is no Done<->deploy-status pendulum. for _ in range(5): plane_sync.set_issue_monitoring("ORCH-061") assert direct.call_count == 0 assert done.call_count == 5 # idempotent PATCH-equivalent (same terminal state) # --- TC-03 ------------------------------------------------------------------ def test_tc03_non_terminal_not_suppressed(spy_setters): direct, done = spy_setters _make_task("deploy") # a really-deploying task plane_sync.set_issue_awaiting_deploy("ORCH-061") plane_sync.set_issue_deploying("ORCH-061") plane_sync.set_issue_monitoring("ORCH-061") # All three proceed to a real PATCH; nothing converges to Done. assert direct.call_count == 3 done.assert_not_called() assert guard.decide("ORCH-061", guard.MONITORING) == guard.ALLOW # --- TC-04 ------------------------------------------------------------------ def test_tc04_kill_switch(spy_setters, monkeypatch): direct, done = spy_setters _make_task("done") # OFF -> terminal-blind, the monitoring PATCH proceeds (1:1 pre-ORCH-094). monkeypatch.setattr(cfg.settings, "deploy_status_guard_enabled", False) plane_sync.set_issue_monitoring("ORCH-061") assert direct.call_count == 1 done.assert_not_called() # ON -> converge to Done. monkeypatch.setattr(cfg.settings, "deploy_status_guard_enabled", True) direct.reset_mock() done.reset_mock() plane_sync.set_issue_monitoring("ORCH-061") direct.assert_not_called() done.assert_called_once_with("ORCH-061") # --- TC-05 ------------------------------------------------------------------ def test_tc05_never_raise_on_db_error(spy_setters, monkeypatch): direct, done = spy_setters _make_task("done") def _boom(_wi): raise RuntimeError("db down") monkeypatch.setattr(_db, "get_task_by_work_item_id", _boom) # decide degrades to ALLOW (fail-safe), never raises. assert guard.decide("ORCH-061", guard.MONITORING) == guard.ALLOW # The setter proceeds with the normal PATCH (1:1), no convergence, no crash. plane_sync.set_issue_monitoring("ORCH-061") assert direct.call_count == 1 done.assert_not_called() def test_tc05_unknown_task_allows(spy_setters): direct, done = spy_setters # No task row at all -> ALLOW (foreign/unknown issue, not ours). assert guard.decide("ORCH-999", guard.MONITORING) == guard.ALLOW plane_sync.set_issue_monitoring("ORCH-999") assert direct.call_count == 1 done.assert_not_called() def test_tc05_cancelled_is_suppressed(spy_setters): direct, done = spy_setters _make_task("cancelled") assert guard.decide("ORCH-061", guard.MONITORING) == guard.SUPPRESS plane_sync.set_issue_monitoring("ORCH-061") # Suppressed: neither an intermediate PATCH nor a Done convergence. direct.assert_not_called() done.assert_not_called() # --- TC-12 ------------------------------------------------------------------ def test_tc12_non_self_repo_inert(spy_setters): direct, done = spy_setters # A non-self repo done task: the guard is inert (self-hosting only, empty CSV). _make_task("done", repo="enduro-trails", wi="ET-042", branch="feature/ET-042-x") assert guard.applies("enduro-trails") is False assert guard.decide("ET-042", guard.MONITORING) == guard.ALLOW plane_sync.set_issue_monitoring("ET-042") # Behaviour unchanged: the requested PATCH proceeds, no convergence. assert direct.call_count == 1 done.assert_not_called() def test_tc12_csv_scope_overrides_self_hosting(monkeypatch): _make_task("done", repo="enduro-trails", wi="ET-042", branch="feature/ET-042-x") # Explicit CSV scope brings a non-self repo in-scope. monkeypatch.setattr(cfg.settings, "deploy_status_guard_repos", "enduro-trails") assert guard.applies("enduro-trails") is True assert guard.applies("orchestrator") is False # not listed -> out of scope assert guard.decide("ET-042", guard.MONITORING) == guard.CONVERGE_DONE