Files
orchestrator/tests/test_reconciler_plane.py
claude-bot 0dfddf93f0 feat(plane): осмысленная статусная модель Plane (слой B — индикация)
Приводит статусы доски Plane к смыслу стадий конвейера, сохраняя
инвариант «статус — индикация, а не управление». Меняется только слой B
(отображение: src/plane_sync.py + точки выставления статуса в
stage_engine.py/webhooks/plane.py/reconciler.py); слой A — машина стадий
src/stages.py::STAGE_TRANSITIONS — остаётся байт-в-байт неизменным (AC-21).

- 6 новых логических ключей статуса (to_analyse, analysis, code_review,
  awaiting_deploy, deploying, monitoring) + сеттеры и диспетчер
  set_issue_stage_state.
- Project-relative alias-fallback (BR-12): новый ключ деградирует на
  базовый UUID того же проекта → нулевая регрессия для enduro-trails.
- Самодеплой (ORCH-036) индицирует фазы: Awaiting Deploy / Deploying;
  terminal-sync для self-hosting → Monitoring after Deploy, для прочих →
  терминальный Done.
- Post-deploy монитор (ORCH-021): HEALTHY → Done, DEGRADED → Blocked
  (только индикация; self-hosting ALERT_ONLY, прод не трогается, BR-5).
- Reconciler: триггер старта/резюма на To Analyse; Guard 2 учитывает
  новые активные ожидания без расширения skip-set на алиасах.
- never-raise контракт сеттеров и резолвера состояний сохранён.
- Раскатка — созданием статусов в Plane оператором, без kill-switch.

Инварианты не менялись: STAGE_TRANSITIONS, QG_CHECKS (12 чеков),
check_deploy_status, exit-код-контракт хука, merge-gate, схема БД.

ADR: docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md
Тесты: test_plane_status_model, test_plane_to_analyse_resume,
test_plane_status_failclosed + TC в существующих наборах. 774 passed.

Refs: ORCH-066

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 22:02:45 +00:00

344 lines
12 KiB
Python

"""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,
# ORCH-066: To Analyse is the F-2 start/resume trigger; absent in this
# project -> aliases in_progress (real get_project_states fallback).
"to_analyse": _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()
# ---------------------------------------------------------------------------
# ORCH-066 TC-20 (AC-19): F-2 polls the DISTINCT To Analyse status and routes it
# to handle_status_start (a lost start/resume webhook is recovered).
# ---------------------------------------------------------------------------
def test_tc20_distinct_to_analyse_polled_and_routed(monkeypatch):
_TO_ANALYSE = "uuid-to-analyse" # distinct from in_progress
monkeypatch.setattr(
reconciler_mod, "get_project_states",
lambda pid: {
"in_progress": _IN_PROGRESS,
"to_analyse": _TO_ANALYSE, # dedicated status created
"approved": _APPROVED,
"rejected": _REJECTED,
},
)
monkeypatch.setattr(
reconciler_mod.projects, "PROJECTS",
[SimpleNamespace(plane_project_id="proj-1", repo="enduro-trails",
work_item_prefix="ET")],
)
start, verdict = _patch_handlers(monkeypatch)
polled = {}
def fake_list(pid, states):
polled["states"] = list(states)
return [{"id": "iss-ta", "state": {"id": _TO_ANALYSE}, "updated_at": _OLD_TS,
"name": "Lost start"}]
monkeypatch.setattr(reconciler_mod, "list_issues_by_state", fake_list)
Reconciler().reconcile_plane_once()
# The To Analyse UUID is in the polled set and routed to start (not verdict).
assert _TO_ANALYSE in polled["states"]
assert start.call_count == 1
assert start.call_args.args[0]["id"] == "iss-ta"
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, "to_analyse": _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}