"""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_fields", return_value=("A valid backlog item title", "This is a sufficiently long description fetched from Plane API.")) def test_status_start_fetches_description( mock_fields, mock_seq, mock_branch, mock_docs, mock_enqueue ): """BUG 1: empty description in payload -> start_pipeline pulls it from the Plane API (single fetch_issue_fields GET) -> QG-0 passes -> task created + analyst enqueued (NOT blocked).""" resp = _to_in_progress_no_desc("bug1") assert resp.status_code == 200 # name + description were pulled from the API in one call mock_fields.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_fields", return_value=("", "")) def test_status_start_empty_api_still_blocks( mock_fields, 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_fields.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_fields", return_value=("Popup enduro trails feature", "A sufficiently long description for QG-0 to pass cleanly.")) def test_collision_reassigns_in_start_pipeline( mock_fields, 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"