From c69e11348b71abf0bf84a4ca11c1aa7f59c2f376 Mon Sep 17 00:00:00 2001 From: Dev Agent Date: Wed, 3 Jun 2026 21:12:59 +0300 Subject: [PATCH] test(pipeline): cover status-start description fetch and work_item_id uniqueness - test_status_start_fetches_description: empty payload description -> pulled from Plane API (mocked) -> QG-0 passes, analyst enqueued. - test_status_start_empty_api_still_blocks: empty API -> honest QG-0 fail. - test_work_item_id_uniqueness: ET-006 taken -> next free id, per-repo isolation. - test_collision_reassigns_in_start_pipeline: end-to-end collision reassignment. - test_worktree_per_task: two tasks never share a worktree path. --- tests/test_pipeline_start_bugs.py | 210 ++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 tests/test_pipeline_start_bugs.py diff --git a/tests/test_pipeline_start_bugs.py b/tests/test_pipeline_start_bugs.py new file mode 100644 index 0000000..c3cb02e --- /dev/null +++ b/tests/test_pipeline_start_bugs.py @@ -0,0 +1,210 @@ +"""Tests for the two pipeline-start bugs surfaced by the ET-006 live run. + +BUG 1: issue.updated (status -> In Progress) ships a payload WITHOUT the + description, so start_pipeline must pull it from the Plane issue API + before QG-0 runs (otherwise QG-0 wrongly blocks the issue). + +BUG 2a: M-6 derives work_item_id from the Plane sequence_id, which can collide. + ensure_unique_work_item_id() must hand out the next FREE id instead of + reusing one that is already in the tasks table. + +BUG 2b: two tasks with an (artificially) identical work_item_id must not share a + branch/worktree. + +launcher / Gitea / Plane network are mocked. Real FastAPI endpoint via +TestClient for the BUG 1 end-to-end path. +""" + +import os +import tempfile + +_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_pipeline_bugs.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ.setdefault("ORCH_PLANE_WEBHOOK_SECRET", "") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +import pytest # noqa: E402 +from unittest.mock import patch, AsyncMock # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +from src.main import app # noqa: E402 +from src.db import init_db, get_db, ensure_unique_work_item_id # noqa: E402 +from src import projects as P # noqa: E402 +from src.projects import reload_projects # noqa: E402 +from src.git_worktree import get_worktree_path # noqa: E402 + +ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c" +IN_PROGRESS = "b873d9eb-993c-48cd-97ac-99a9b1623967" +BACKLOG = "113b24f6-cce8-4be9-9a22-a359b9cf0122" + +client = TestClient(app) + + +@pytest.fixture(autouse=True) +def setup(monkeypatch): + monkeypatch.setattr(P.settings, "db_path", _test_db) + import src.db as _db + monkeypatch.setattr(_db.settings, "db_path", _test_db) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + monkeypatch.setattr("src.webhooks.plane.verify_plane_signature", lambda body, sig: True) + registry_json = ( + f'[{{"plane_project_id": "{ENDURO_PLANE_ID}", "repo": "enduro-trails",' + f' "work_item_prefix": "ET", "name": "enduro-trails"}}]' + ) + monkeypatch.setattr(P.settings, "projects_json", registry_json) + reload_projects() + yield + reload_projects() + if os.path.exists(_test_db): + os.unlink(_test_db) + + +def _insert_task(work_item_id, branch, plane_id="x"): + conn = get_db() + conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, plane_issue_id) " + "VALUES (?, ?, ?, ?, ?, ?)", + (plane_id, work_item_id, "enduro-trails", branch, "analysis", plane_id), + ) + conn.commit() + conn.close() + + +def _count(plane_id): + conn = get_db() + n = conn.execute("SELECT COUNT(*) FROM tasks WHERE plane_id=?", (plane_id,)).fetchone()[0] + conn.close() + return n + + +def _task(plane_id): + conn = get_db() + row = conn.execute("SELECT * FROM tasks WHERE plane_id=?", (plane_id,)).fetchone() + conn.close() + return row + + +# --------------------------------------------------------------------------- # +# BUG 1 +# --------------------------------------------------------------------------- # +def _to_in_progress_no_desc(plane_id="bug1"): + """issue.updated payload WITHOUT description (only changed fields).""" + return client.post("/webhook/plane", json={ + "event": "issue", "action": "updated", + "data": { + "id": plane_id, "name": "A valid backlog item title", + # NO description / description_stripped here, exactly like Plane sends + # on a status change. + "project": ENDURO_PLANE_ID, + "state": {"id": IN_PROGRESS, "name": "In Progress", "group": "started"}, + }, + "activity": {"field": "state", "new_value": IN_PROGRESS, "old_value": BACKLOG}, + }) + + +@patch("src.webhooks.plane.enqueue_job", return_value=1) +@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock) +@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock) +@patch("src.plane_sync.fetch_issue_sequence_id", return_value=42) +@patch("src.plane_sync.fetch_issue_description", + return_value="This is a sufficiently long description fetched from Plane API.") +def test_status_start_fetches_description( + mock_desc, mock_seq, mock_branch, mock_docs, mock_enqueue +): + """BUG 1: empty description in payload -> start_pipeline pulls it from the + Plane API -> QG-0 passes -> task created + analyst enqueued (NOT blocked).""" + resp = _to_in_progress_no_desc("bug1") + assert resp.status_code == 200 + # description was pulled from the API + mock_desc.assert_called_once() + # QG-0 passed -> task created and analyst launched (NOT set_issue_blocked) + assert _count("bug1") == 1 + assert _task("bug1")["stage"] == "analysis" + mock_enqueue.assert_called_once() + assert mock_enqueue.call_args.args[0] == "analyst" + + +@patch("src.webhooks.plane.enqueue_job", return_value=1) +@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock) +@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock) +@patch("src.plane_sync.fetch_issue_sequence_id", return_value=42) +@patch("src.plane_sync.fetch_issue_description", return_value="") +def test_status_start_empty_api_still_blocks( + mock_desc, mock_seq, mock_branch, mock_docs, mock_enqueue +): + """BUG 1 negative path: if the API also returns empty, QG-0 legitimately + fails -> NO task is created (truly empty ticket).""" + resp = _to_in_progress_no_desc("bug1-empty") + assert resp.status_code == 200 + mock_desc.assert_called_once() + assert _count("bug1-empty") == 0 + mock_enqueue.assert_not_called() + + +# --------------------------------------------------------------------------- # +# BUG 2a +# --------------------------------------------------------------------------- # +def test_work_item_id_uniqueness(): + """BUG 2a: if ET-006 is already in tasks, the guard returns the next free + id (ET-007), not ET-006 again.""" + _insert_task("ET-006", "feature/ET-006-gpx-upload", plane_id="old") + assert ensure_unique_work_item_id("ET-006", "enduro-trails") == "ET-007" + + # ET-006 AND ET-007 taken -> next free is ET-008. + _insert_task("ET-007", "feature/ET-007-something", plane_id="old2") + assert ensure_unique_work_item_id("ET-006", "enduro-trails") == "ET-008" + + # A free id is returned unchanged. + assert ensure_unique_work_item_id("ET-099", "enduro-trails") == "ET-099" + + # Per-repo isolation: a different repo with the same id is not a collision. + assert ensure_unique_work_item_id("ET-006", "other-repo") == "ET-006" + + +@patch("src.webhooks.plane.enqueue_job", return_value=1) +@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock) +@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock) +@patch("src.plane_sync.fetch_issue_sequence_id", return_value=6) +@patch("src.plane_sync.fetch_issue_description", + return_value="A sufficiently long description for QG-0 to pass cleanly.") +def test_collision_reassigns_in_start_pipeline( + mock_desc, mock_seq, mock_branch, mock_docs, mock_enqueue +): + """BUG 2a end-to-end: ET-006 already exists -> a new In Progress issue whose + Plane sequence_id is also 6 must NOT reuse ET-006.""" + _insert_task("ET-006", "feature/ET-006-gpx-upload", plane_id="task8") + resp = client.post("/webhook/plane", json={ + "event": "issue", "action": "updated", + "data": { + "id": "task25", "name": "Popup enduro trails feature", + "description_stripped": "A sufficiently long description for QG-0.", + "project": ENDURO_PLANE_ID, + "state": {"id": IN_PROGRESS, "name": "In Progress", "group": "started"}, + }, + "activity": {"field": "state", "new_value": IN_PROGRESS, "old_value": BACKLOG}, + }) + assert resp.status_code == 200 + new_id = _task("task25")["work_item_id"] + assert new_id != "ET-006" + assert new_id == "ET-007" + + +# --------------------------------------------------------------------------- # +# BUG 2b +# --------------------------------------------------------------------------- # +def test_worktree_per_task(): + """BUG 2b: two tasks must not resolve to the same worktree path. With the + uniqueness guard the branches differ, so the worktree paths differ too.""" + _insert_task("ET-006", "feature/ET-006-gpx-upload", plane_id="task8") + # The second task gets a unique id via the guard... + new_id = ensure_unique_work_item_id("ET-006", "enduro-trails") + assert new_id == "ET-007" + branch_a = "feature/ET-006-gpx-upload" + branch_b = f"feature/{new_id}-popup-enduro-trails" + + wt_a = get_worktree_path("enduro-trails", branch_a) + wt_b = get_worktree_path("enduro-trails", branch_b) + assert wt_a != wt_b, "two tasks must not share a worktree path"