"""ORCH-053: tests for the Plane-side reconciler (F-2) + sha-resolve helpers. F-2 polls the Plane API per project (``list_issues_by_state``) and REPLAYS a missed In Progress / Approved / Rejected transition through the EXISTING ``webhooks.plane.handle_status_start`` / ``handle_verdict`` handlers — it never duplicates pipeline logic. These tests mock those handlers (AsyncMock) and the Plane API helpers, and verify the dispatch / idempotency / multi-project rules. TC-15 is the AC-4 anti-dup integration test for ``create_task_atomic`` against a real isolated sqlite DB under concurrency. TC-16 exercises ``plane_sync.list_issues_by_state`` directly (pagination + the never-raise contract). """ import os import tempfile import threading from types import SimpleNamespace import pytest _test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_reconciler_plane.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 AsyncMock, MagicMock # noqa: E402 import src.db as _db # noqa: E402 from src.db import init_db, get_db, enqueue_job, create_task_atomic # noqa: E402 from src import reconciler as reconciler_mod # noqa: E402 from src import plane_sync # noqa: E402 from src.reconciler import Reconciler # noqa: E402 _IN_PROGRESS = "uuid-in-progress" _APPROVED = "uuid-approved" _REJECTED = "uuid-rejected" _OLD_TS = "2020-01-01T00:00:00Z" # well past any grace @pytest.fixture(autouse=True) def fresh_db(monkeypatch): monkeypatch.setattr(_db.settings, "db_path", _test_db) if os.path.exists(_test_db): os.unlink(_test_db) init_db() yield @pytest.fixture def single_project(monkeypatch): """Restrict F-2 to a single fake project and stub its state resolution.""" proj = SimpleNamespace( plane_project_id="proj-1", repo="enduro-trails", work_item_prefix="ET", ) monkeypatch.setattr(reconciler_mod.projects, "PROJECTS", [proj]) monkeypatch.setattr( reconciler_mod, "get_project_states", lambda pid: { "in_progress": _IN_PROGRESS, "approved": _APPROVED, "rejected": _REJECTED, }, ) return proj def _make_task(plane_id, stage="review", repo="enduro-trails", branch="feature/ET-001-x", wi="ET-001"): conn = get_db() cur = conn.execute( "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, plane_issue_id) " "VALUES (?, ?, ?, ?, ?, ?)", (plane_id, wi, repo, branch, stage, plane_id), ) tid = cur.lastrowid conn.commit() conn.close() return tid def _patch_handlers(monkeypatch): start = AsyncMock() verdict = AsyncMock() monkeypatch.setattr(reconciler_mod, "handle_status_start", start) monkeypatch.setattr(reconciler_mod, "handle_verdict", verdict) return start, verdict def _patch_issues(monkeypatch, issues): monkeypatch.setattr( reconciler_mod, "list_issues_by_state", lambda pid, states: list(issues) ) # --------------------------------------------------------------------------- # TC-11: In Progress without a task -> handle_status_start once. # --------------------------------------------------------------------------- def test_tc11_in_progress_without_task_starts_pipeline(monkeypatch, single_project): start, verdict = _patch_handlers(monkeypatch) _patch_issues(monkeypatch, [ {"id": "iss-1", "state": {"id": _IN_PROGRESS}, "updated_at": _OLD_TS, "name": "Some issue"}, ]) Reconciler().reconcile_plane_once() assert start.call_count == 1 issue_data, project_id = start.call_args.args assert issue_data["id"] == "iss-1" assert issue_data["state"]["id"] == _IN_PROGRESS assert project_id == "proj-1" verdict.assert_not_called() # --------------------------------------------------------------------------- # TC-12: Approved with an existing task, no active job -> handle_verdict(True). # --------------------------------------------------------------------------- def test_tc12_approved_replays_verdict(monkeypatch, single_project): start, verdict = _patch_handlers(monkeypatch) _make_task("iss-2", stage="review") _patch_issues(monkeypatch, [ {"id": "iss-2", "state": {"id": _APPROVED}, "updated_at": _OLD_TS}, ]) Reconciler().reconcile_plane_once() assert verdict.call_count == 1 assert verdict.call_args.kwargs.get("approved") is True start.assert_not_called() # --------------------------------------------------------------------------- # TC-13: Rejected with an existing task -> handle_verdict(False). # --------------------------------------------------------------------------- def test_tc13_rejected_replays_verdict(monkeypatch, single_project): start, verdict = _patch_handlers(monkeypatch) _make_task("iss-3", stage="review") _patch_issues(monkeypatch, [ {"id": "iss-3", "state": {"id": _REJECTED}, "updated_at": _OLD_TS}, ]) Reconciler().reconcile_plane_once() assert verdict.call_count == 1 assert verdict.call_args.kwargs.get("approved") is False start.assert_not_called() # --------------------------------------------------------------------------- # TC-14: idempotency — an active job means a live webhook is in flight -> skip. # --------------------------------------------------------------------------- def test_tc14_active_job_skips(monkeypatch, single_project): start, verdict = _patch_handlers(monkeypatch) tid = _make_task("iss-4", stage="review") enqueue_job("reviewer", "enduro-trails", task_id=tid) # active _patch_issues(monkeypatch, [ {"id": "iss-4", "state": {"id": _APPROVED}, "updated_at": _OLD_TS}, ]) Reconciler().reconcile_plane_once() start.assert_not_called() verdict.assert_not_called() # --------------------------------------------------------------------------- # TC-14b: within-grace issue is left alone (lost, not merely delayed). # --------------------------------------------------------------------------- def test_tc14b_within_grace_skipped(monkeypatch, single_project): from datetime import datetime, timezone start, verdict = _patch_handlers(monkeypatch) _make_task("iss-5", stage="review") fresh_ts = datetime.now(timezone.utc).isoformat() _patch_issues(monkeypatch, [ {"id": "iss-5", "state": {"id": _APPROVED}, "updated_at": fresh_ts}, ]) Reconciler().reconcile_plane_once() start.assert_not_called() verdict.assert_not_called() # --------------------------------------------------------------------------- # TC-15 (AC-4): atomic anti-dup — concurrent create_task_atomic for one # plane_id yields exactly ONE row and ONE created=True. # --------------------------------------------------------------------------- def test_tc15_create_task_atomic_no_duplicate(): results = [] barrier = threading.Barrier(8) def worker(): barrier.wait() # maximise the race row, created = create_task_atomic( "plane-dup", "ET-099", "enduro-trails", "feature/ET-099-x", "analysis", "Dup race", ) results.append((row["id"], created)) threads = [threading.Thread(target=worker) for _ in range(8)] for t in threads: t.start() for t in threads: t.join() created_flags = [c for _, c in results] assert created_flags.count(True) == 1 # exactly one winner assert created_flags.count(False) == 7 # the rest see the existing row conn = get_db() n = conn.execute( "SELECT COUNT(*) FROM tasks WHERE plane_id = 'plane-dup'" ).fetchone()[0] conn.close() assert n == 1 # only one task row ever created # All callers see the same row id (the single task). assert len({rid for rid, _ in results}) == 1 # --------------------------------------------------------------------------- # TC-16: list_issues_by_state — never-raise on API error, filter+paginate on OK. # --------------------------------------------------------------------------- def test_tc16_list_issues_never_raises_on_error(monkeypatch): def boom(*a, **k): raise RuntimeError("plane down") monkeypatch.setattr(plane_sync.httpx, "get", boom) out = plane_sync.list_issues_by_state("proj-1", [_APPROVED]) assert out == [] def test_tc16_list_issues_paginates_and_filters(monkeypatch): page1 = { "results": [ {"id": "a", "state": {"id": _APPROVED}}, {"id": "b", "state": {"id": "other"}}, ], "next_page_results": True, "next_cursor": "cur2", } page2 = { "results": [ {"id": "c", "state": _APPROVED}, # bare-uuid state shape {"id": "d", "state": {"id": _REJECTED}}, ], "next_page_results": False, "next_cursor": None, } pages = iter([page1, page2]) def fake_get(url, headers=None, params=None, timeout=None): resp = MagicMock() resp.json.return_value = next(pages) resp.raise_for_status.return_value = None return resp monkeypatch.setattr(plane_sync.httpx, "get", fake_get) out = plane_sync.list_issues_by_state("proj-1", [_APPROVED, _REJECTED]) ids = {i["id"] for i in out} assert ids == {"a", "c", "d"} # 'b' filtered out (state 'other') # --------------------------------------------------------------------------- # TC-17: F-2 polls EVERY registry project and resolves states per-project. # --------------------------------------------------------------------------- def test_tc17_polls_all_projects_resolves_states_per_project(monkeypatch): _patch_handlers(monkeypatch) from src import projects as projects_mod projects_mod.reload_projects() expected_ids = {p.plane_project_id for p in projects_mod.PROJECTS} assert len(expected_ids) >= 2 # enduro + orchestrator in the default registry states_calls = [] issues_calls = [] def fake_states(pid): states_calls.append(pid) return {"in_progress": _IN_PROGRESS, "approved": _APPROVED, "rejected": _REJECTED} def fake_issues(pid, states): issues_calls.append((pid, tuple(states))) return [] monkeypatch.setattr(reconciler_mod, "get_project_states", fake_states) monkeypatch.setattr(reconciler_mod, "list_issues_by_state", fake_issues) Reconciler().reconcile_plane_once() assert set(states_calls) == expected_ids assert {pid for pid, _ in issues_calls} == expected_ids # state uuids are resolved per-project (not hardcoded): each call carries them. for _pid, states in issues_calls: assert set(states) == {_IN_PROGRESS, _APPROVED, _REJECTED}