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.
This commit is contained in:
180
tests/test_plane_webhook.py
Normal file
180
tests/test_plane_webhook.py
Normal file
@@ -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"
|
||||
177
tests/test_projects.py
Normal file
177
tests/test_projects.py
Normal file
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user