From c1f35a2047b7a27bd624b3060ad6de0d932ace88 Mon Sep 17 00:00:00 2001 From: Dev Agent Date: Tue, 2 Jun 2026 22:30:51 +0300 Subject: [PATCH] test(projects,webhook): cover registry resolvers + project filter ORCH-6: test_projects.py covers resolvers and ORCH_PROJECTS_JSON parsing (valid/malformed/fallback). test_plane_webhook.py covers the webhook project filter via TestClient (unknown->ignored, orchestrator->orchestrator repo, enduro->enduro-trails, independent ORCH/ET prefixes); launcher mocked. test_webhooks.py: register proj-1 so existing ET fixtures pass. --- tests/test_plane_webhook.py | 180 ++++++++++++++++++++++++++++++++++++ tests/test_projects.py | 177 +++++++++++++++++++++++++++++++++++ tests/test_webhooks.py | 6 ++ 3 files changed, 363 insertions(+) create mode 100644 tests/test_plane_webhook.py create mode 100644 tests/test_projects.py diff --git a/tests/test_plane_webhook.py b/tests/test_plane_webhook.py new file mode 100644 index 0000000..c213376 --- /dev/null +++ b/tests/test_plane_webhook.py @@ -0,0 +1,180 @@ +"""ORCH-6: Plane webhook project-filter + repo-resolution tests. + +Verifies the core of the 2026-06-02 incident fix: + * webhook from an UNKNOWN Plane project -> {"status": "ignored"} and no task + * webhook from the orchestrator project -> task created with repo=orchestrator + * webhook from the enduro project -> task created with repo=enduro-trails + +launcher.launch is mocked so no real agents are spawned. Gitea branch/doc +creation is mocked (network). FastAPI TestClient drives the real endpoint. + +This module configures its own registry via monkeypatch + reload_projects so it +is independent of ORCH_PROJECTS_JSON set by other test modules. +""" + +import os +import tempfile + +import pytest + +# Test DB / disable signature checks (same convention as test_webhooks.py). +_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_plane.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ.setdefault("ORCH_PLANE_WEBHOOK_SECRET", "") +os.environ.setdefault("ORCH_GITEA_WEBHOOK_SECRET", "") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +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 # noqa: E402 +from src import projects as P # noqa: E402 +from src.projects import reload_projects # noqa: E402 + +ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a" +ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c" +UNKNOWN_PLANE_ID = "deadbeef-0000-0000-0000-000000000000" + +client = TestClient(app) + + +@pytest.fixture(autouse=True) +def setup(monkeypatch): + """Fresh DB + a known two-project registry for each test.""" + # settings.db_path is resolved once at import; force it to our isolated DB so + # this suite is independent of whichever test module imported config first. + 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() + + # The webhook signature secret may be baked into the runtime env; this suite + # focuses on the project filter, so bypass signature verification. + 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"}},' + f' {{"plane_project_id": "{ORCH_PLANE_ID}", "repo": "orchestrator",' + f' "work_item_prefix": "ORCH", "name": "orchestrator"}}]' + ) + monkeypatch.setattr(P.settings, "projects_json", registry_json) + reload_projects() + + yield + + reload_projects() # restore from env + if os.path.exists(_test_db): + os.unlink(_test_db) + + +def _post_created(plane_project_id, plane_id="wi-1", name="A valid work item title"): + return client.post( + "/webhook/plane", + json={ + "event": "work_item.created", + "data": { + "id": plane_id, + "name": name, + "description_stripped": "This is a sufficiently long description.", + "project": plane_project_id, + }, + }, + ) + + +# --------------------------------------------------------------------------- +# Filter: unknown project is ignored, no side effects +# --------------------------------------------------------------------------- + +@patch("src.webhooks.plane.launcher") +@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock) +@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock) +def test_unknown_project_ignored(mock_branch, mock_docs, mock_launcher): + resp = _post_created(UNKNOWN_PLANE_ID, plane_id="ignore-me") + assert resp.status_code == 200 + assert resp.json()["status"] == "ignored" + assert resp.json().get("reason") == "unknown project" + + # No task, no branch, no agent. + conn = get_db() + task = conn.execute("SELECT * FROM tasks WHERE plane_id='ignore-me'").fetchone() + conn.close() + assert task is None + mock_branch.assert_not_called() + mock_launcher.launch.assert_not_called() + + +# --------------------------------------------------------------------------- +# orchestrator project -> repo=orchestrator, prefix ORCH +# --------------------------------------------------------------------------- + +@patch("src.webhooks.plane.launcher") +@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock) +@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock) +def test_orchestrator_project_routes_to_orchestrator_repo(mock_branch, mock_docs, mock_launcher): + mock_launcher.launch.return_value = 1 + resp = _post_created(ORCH_PLANE_ID, plane_id="orch-1") + assert resp.status_code == 200 + assert resp.json()["status"] == "accepted" + + conn = get_db() + task = conn.execute("SELECT * FROM tasks WHERE plane_id='orch-1'").fetchone() + conn.close() + assert task is not None + assert task["repo"] == "orchestrator" + assert task["work_item_id"].startswith("ORCH-") + assert task["stage"] == "analysis" + # Branch created against the orchestrator repo. + args = mock_branch.call_args.args + assert args[0] == "orchestrator" + + +# --------------------------------------------------------------------------- +# enduro project -> repo=enduro-trails, prefix ET +# --------------------------------------------------------------------------- + +@patch("src.webhooks.plane.launcher") +@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock) +@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock) +def test_enduro_project_routes_to_enduro_repo(mock_branch, mock_docs, mock_launcher): + mock_launcher.launch.return_value = 1 + resp = _post_created(ENDURO_PLANE_ID, plane_id="et-1") + assert resp.status_code == 200 + assert resp.json()["status"] == "accepted" + + conn = get_db() + task = conn.execute("SELECT * FROM tasks WHERE plane_id='et-1'").fetchone() + conn.close() + assert task is not None + assert task["repo"] == "enduro-trails" + assert task["work_item_id"].startswith("ET-") + args = mock_branch.call_args.args + assert args[0] == "enduro-trails" + + +# --------------------------------------------------------------------------- +# prefixes are independent per repo (ORCH-001 vs ET-001 in parallel) +# --------------------------------------------------------------------------- + +@patch("src.webhooks.plane.launcher") +@patch("src.webhooks.plane._create_initial_docs", new_callable=AsyncMock) +@patch("src.webhooks.plane._create_gitea_branch", new_callable=AsyncMock) +def test_prefixes_independent_per_project(mock_branch, mock_docs, mock_launcher): + mock_launcher.launch.return_value = 1 + _post_created(ORCH_PLANE_ID, plane_id="o1", name="Orchestrator item one") + _post_created(ENDURO_PLANE_ID, plane_id="e1", name="Enduro item one") + _post_created(ORCH_PLANE_ID, plane_id="o2", name="Orchestrator item two") + + conn = get_db() + rows = {r["plane_id"]: r["work_item_id"] for r in + conn.execute("SELECT plane_id, work_item_id FROM tasks").fetchall()} + conn.close() + assert rows["o1"] == "ORCH-001" + assert rows["o2"] == "ORCH-002" + assert rows["e1"] == "ET-001" diff --git a/tests/test_projects.py b/tests/test_projects.py new file mode 100644 index 0000000..4b6b6ce --- /dev/null +++ b/tests/test_projects.py @@ -0,0 +1,177 @@ +"""ORCH-6: tests for the project registry (src/projects.py). + +Covers resolvers (by plane_id, by repo, unknown -> None, known ids) against the +built-in default registry, plus ORCH_PROJECTS_JSON parsing (valid + malformed +-> default fallback). + +The pure parser ``_parse_projects_json`` is tested directly so we don't mutate +the module-global registry. Resolver tests run against the default registry; if +another test (e.g. test_webhooks) set ORCH_PROJECTS_JSON in the env, we restore +the default via monkeypatch + reload_projects to keep this file order-independent. +""" + +import pytest + +from src import projects as P +from src.projects import ( + ProjectConfig, + get_project_by_plane_id, + get_project_by_repo, + known_plane_project_ids, + reload_projects, + _parse_projects_json, + _DEFAULT_PROJECTS, +) + +# Known ids from the default registry / task spec. +ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c" +ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a" + + +@pytest.fixture +def default_registry(monkeypatch): + """Force the default (built-in) registry regardless of ORCH_PROJECTS_JSON + that other test modules may have set in the process env.""" + monkeypatch.setattr(P.settings, "projects_json", "") + reload_projects() + yield + # Restore from current settings (whatever env says) after the test. + reload_projects() + + +# --------------------------------------------------------------------------- +# Resolvers +# --------------------------------------------------------------------------- + +def test_get_project_by_plane_id_orchestrator(default_registry): + proj = get_project_by_plane_id(ORCH_PLANE_ID) + assert proj is not None + assert proj.repo == "orchestrator" + assert proj.work_item_prefix == "ORCH" + assert proj.plane_project_id == ORCH_PLANE_ID + + +def test_get_project_by_plane_id_enduro(default_registry): + proj = get_project_by_plane_id(ENDURO_PLANE_ID) + assert proj is not None + assert proj.repo == "enduro-trails" + assert proj.work_item_prefix == "ET" + + +def test_get_project_by_plane_id_unknown_returns_none(default_registry): + assert get_project_by_plane_id("00000000-0000-0000-0000-000000000000") is None + + +def test_get_project_by_plane_id_empty_returns_none(default_registry): + assert get_project_by_plane_id("") is None + assert get_project_by_plane_id(None) is None + + +def test_get_project_by_repo(default_registry): + assert get_project_by_repo("enduro-trails").work_item_prefix == "ET" + assert get_project_by_repo("orchestrator").work_item_prefix == "ORCH" + + +def test_get_project_by_repo_unknown_returns_none(default_registry): + assert get_project_by_repo("does-not-exist") is None + assert get_project_by_repo("") is None + assert get_project_by_repo(None) is None + + +def test_known_plane_project_ids(default_registry): + ids = known_plane_project_ids() + assert isinstance(ids, set) + assert ENDURO_PLANE_ID in ids + assert ORCH_PLANE_ID in ids + assert len(ids) == len(_DEFAULT_PROJECTS) + + +# --------------------------------------------------------------------------- +# ORCH_PROJECTS_JSON parsing (pure function, no global mutation) +# --------------------------------------------------------------------------- + +def test_parse_empty_returns_none(): + assert _parse_projects_json("") is None + assert _parse_projects_json(" ") is None + assert _parse_projects_json(None) is None + + +def test_parse_valid_json(): + raw = ( + '[{"plane_project_id": "p-1", "repo": "repo-a", ' + '"work_item_prefix": "AAA", "name": "Alpha"}]' + ) + parsed = _parse_projects_json(raw) + assert parsed is not None + assert len(parsed) == 1 + assert isinstance(parsed[0], ProjectConfig) + assert parsed[0].plane_project_id == "p-1" + assert parsed[0].repo == "repo-a" + assert parsed[0].work_item_prefix == "AAA" + assert parsed[0].name == "Alpha" + + +def test_parse_valid_json_multiple(): + raw = ( + '[{"plane_project_id": "p-1", "repo": "repo-a", "work_item_prefix": "A"},' + ' {"plane_project_id": "p-2", "repo": "repo-b", "work_item_prefix": "B"}]' + ) + parsed = _parse_projects_json(raw) + assert len(parsed) == 2 + # name defaults to repo when omitted + assert parsed[0].name == "repo-a" + assert parsed[1].repo == "repo-b" + + +def test_parse_malformed_json_returns_none(): + assert _parse_projects_json("{not valid json") is None + assert _parse_projects_json("[}") is None + + +def test_parse_not_an_array_returns_none(): + # A JSON object (not array) is invalid -> fallback. + assert _parse_projects_json('{"plane_project_id": "p-1"}') is None + + +def test_parse_skips_bad_entries_keeps_good(): + raw = ( + '[{"repo": "missing-id"},' # missing required key -> skipped + ' {"plane_project_id": "p-2", "repo": "repo-b", "work_item_prefix": "B"}]' + ) + parsed = _parse_projects_json(raw) + assert parsed is not None + assert len(parsed) == 1 + assert parsed[0].plane_project_id == "p-2" + + +def test_parse_all_bad_entries_returns_none(): + # No valid entries -> None (fallback to default). + assert _parse_projects_json('[{"repo": "no-id"}, "not-an-object"]') is None + + +def test_reload_from_custom_json(monkeypatch): + """End-to-end: set settings.projects_json, reload, resolvers reflect it.""" + custom = ( + '[{"plane_project_id": "custom-uuid", "repo": "custom-repo", ' + '"work_item_prefix": "CUS", "name": "Custom"}]' + ) + monkeypatch.setattr(P.settings, "projects_json", custom) + reload_projects() + try: + assert get_project_by_plane_id("custom-uuid").repo == "custom-repo" + assert get_project_by_repo("custom-repo").work_item_prefix == "CUS" + assert known_plane_project_ids() == {"custom-uuid"} + # The built-in defaults must NOT be present when JSON overrides. + assert get_project_by_plane_id(ENDURO_PLANE_ID) is None + finally: + reload_projects() + + +def test_reload_invalid_json_falls_back_to_default(monkeypatch): + monkeypatch.setattr(P.settings, "projects_json", "{garbage") + reload_projects() + try: + assert get_project_by_plane_id(ENDURO_PLANE_ID) is not None + assert get_project_by_plane_id(ORCH_PLANE_ID) is not None + finally: + reload_projects() diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index 0c93649..074b9ae 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -14,6 +14,12 @@ os.environ["ORCH_GITEA_TOKEN"] = "test-token" os.environ["ORCH_PLANE_API_TOKEN"] = "test-token" os.environ["ORCH_GITEA_OWNER"] = "admin" os.environ["ORCH_DEFAULT_REPO"] = "enduro-trails" +# ORCH-6: register the test project so the project filter lets these fixtures +# through. proj-1 maps to enduro-trails/ET, preserving the ET-001/ET-002 asserts. +os.environ["ORCH_PROJECTS_JSON"] = ( + '[{"plane_project_id": "proj-1", "repo": "enduro-trails", ' + '"work_item_prefix": "ET", "name": "enduro-trails"}]' +) from fastapi.testclient import TestClient from src.main import app