From f375be249fd685c9fcc988207574c33cded95200 Mon Sep 17 00:00:00 2001 From: Slava Date: Fri, 5 Jun 2026 17:36:40 +0300 Subject: [PATCH] fix(tests): per-project Plane states in webhook tests + close CI hole (ORCH-39) (#35) --- .gitea/workflows/ci.yml | 8 +++++++- pytest.ini | 13 ++++++++++++ requirements.txt | 1 + tests/test_m6_sequence.py | 33 +++++++++++++++++++++++++---- tests/test_plane_webhook.py | 41 +++++++++++++++++++++++++++++++++---- 5 files changed, 87 insertions(+), 9 deletions(-) create mode 100644 pytest.ini diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 2283a95..4169518 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -12,11 +12,17 @@ jobs: - uses: actions/checkout@v4 - name: Install dependencies run: | + set -euo pipefail python3 -m pip install --user --upgrade pip python3 -m pip install --user -r requirements.txt - name: Test env: PYTHONPATH: ${{ github.workspace }} run: | + # ORCH-39: fail the job on ANY failure. Run the WHOLE suite from the + # repo root. --strict-markers + pytest-asyncio (asyncio_mode=auto, see + # pytest.ini) make async tests actually run instead of silently + # skipping (the hole that hid red tests behind a green CI). + set -euo pipefail export PATH="$HOME/.local/bin:$PATH" - python3 -m pytest tests/ -q + python3 -m pytest tests/ -q -p no:cacheprovider --strict-markers diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..770f25a --- /dev/null +++ b/pytest.ini @@ -0,0 +1,13 @@ +[pytest] +# ORCH-39: make the async webhook/state tests (test_orch10_states.py) actually +# run in every environment. Without pytest-asyncio + asyncio_mode=auto these +# @pytest.mark.asyncio tests were silently SKIPPED, so a broken async path +# could pass CI. asyncio_mode=auto runs `async def test_*` natively. +asyncio_mode = auto + +# Fail loudly on unknown markers so a typo'd @pytest.mark.* can't silently +# disable a test. +markers = + asyncio: mark a coroutine test to be run by pytest-asyncio. + +testpaths = tests diff --git a/requirements.txt b/requirements.txt index 4025b1c..55490e7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ uvicorn[standard]==0.30.0 pydantic-settings==2.5.0 httpx==0.27.0 pytest==8.3.3 +pytest-asyncio==0.23.8 diff --git a/tests/test_m6_sequence.py b/tests/test_m6_sequence.py index 81720f0..733a267 100644 --- a/tests/test_m6_sequence.py +++ b/tests/test_m6_sequence.py @@ -34,6 +34,27 @@ import src.plane_sync as plane_sync # noqa: E402 ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a" ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c" +# ORCH-39: after ORCH-10 the webhook resolves Plane state UUIDs per-project via +# get_project_states(project_id). Mock it deterministically (no network) and +# send each request with the UUID that matches its own project. +_PROJECT_STATES = { + ENDURO_PLANE_ID: { + "in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967", + "approved": "a519a341-dada-4a91-8910-7604f82b79c5", + "rejected": "ba958f3c-5db5-461d-8f82-89425e413b97", + }, + ORCH_PLANE_ID: { + "in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", + "approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff", + "rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3", + }, +} + + +def _fake_get_project_states(project_id): + return _PROJECT_STATES.get(project_id, _PROJECT_STATES[ENDURO_PLANE_ID]) + + client = TestClient(app) @@ -48,6 +69,10 @@ def setup(monkeypatch): monkeypatch.setattr("src.webhooks.plane.verify_plane_signature", lambda body, sig: True) + # ORCH-39: deterministic per-project Plane states, clean cache per test. + plane_sync.reload_project_states() + monkeypatch.setattr(plane_sync, "get_project_states", _fake_get_project_states) + registry_json = ( f'[{{"plane_project_id": "{ENDURO_PLANE_ID}", "repo": "enduro-trails",' f' "work_item_prefix": "ET", "name": "enduro-trails"}},' @@ -60,6 +85,7 @@ def setup(monkeypatch): yield reload_projects() + plane_sync.reload_project_states() if os.path.exists(_test_db): os.unlink(_test_db) @@ -103,10 +129,9 @@ def test_fetch_sequence_id_missing_field_returns_none(): # --------------------------------------------------------------------------- # Feature 1: pipeline starts on a status change to In Progress, not on creation. -_IN_PROGRESS = "b873d9eb-993c-48cd-97ac-99a9b1623967" - - +# ORCH-39: in_progress UUID is project-specific; derive it from the project. def _post(plane_id, plane_project_id=ORCH_PLANE_ID, name="A valid work item title"): + in_progress = _fake_get_project_states(plane_project_id)["in_progress"] return client.post( "/webhook/plane", json={ @@ -117,7 +142,7 @@ def _post(plane_id, plane_project_id=ORCH_PLANE_ID, name="A valid work item titl "name": name, "description_stripped": "This is a sufficiently long description.", "project": plane_project_id, - "state": {"id": _IN_PROGRESS, "name": "In Progress", "group": "started"}, + "state": {"id": in_progress, "name": "In Progress", "group": "started"}, }, }, ) diff --git a/tests/test_plane_webhook.py b/tests/test_plane_webhook.py index baf7887..961001f 100644 --- a/tests/test_plane_webhook.py +++ b/tests/test_plane_webhook.py @@ -33,11 +33,36 @@ 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 +import src.plane_sync as plane_sync # 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" +# ORCH-39: after ORCH-10 the webhook resolves Plane state UUIDs per-project via +# get_project_states(project_id). Hardcoding the enduro in_progress UUID for an +# ORCH-project payload no longer matches, so the pipeline never starts. We mock +# get_project_states with a deterministic per-project map (no network) and send +# each request with the UUID that matches its own project. +_PROJECT_STATES = { + ENDURO_PLANE_ID: { + "in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967", + "approved": "a519a341-dada-4a91-8910-7604f82b79c5", + "rejected": "ba958f3c-5db5-461d-8f82-89425e413b97", + }, + ORCH_PLANE_ID: { + "in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", + "approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff", + "rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3", + }, +} + + +def _fake_get_project_states(project_id): + """Deterministic per-project state map; mirrors get_project_states' fallback + for unknown projects so the webhook still behaves sensibly.""" + return _PROJECT_STATES.get(project_id, _PROJECT_STATES[ENDURO_PLANE_ID]) + client = TestClient(app) @@ -57,6 +82,13 @@ def setup(monkeypatch): # focuses on the project filter, so bypass signature verification. monkeypatch.setattr("src.webhooks.plane.verify_plane_signature", lambda body, sig: True) + # ORCH-39: resolve Plane states deterministically per-project (no network) + # and start from a clean per-project cache so suites don't leak into each + # other. plane.py imports get_project_states locally from ..plane_sync, so + # patch it at the src.plane_sync source. + plane_sync.reload_project_states() + monkeypatch.setattr(plane_sync, "get_project_states", _fake_get_project_states) + registry_json = ( f'[{{"plane_project_id": "{ENDURO_PLANE_ID}", "repo": "enduro-trails",' f' "work_item_prefix": "ET", "name": "enduro-trails"}},' @@ -69,6 +101,7 @@ def setup(monkeypatch): yield reload_projects() # restore from env + plane_sync.reload_project_states() if os.path.exists(_test_db): os.unlink(_test_db) @@ -76,10 +109,10 @@ def setup(monkeypatch): # Feature 1: the pipeline now starts on a status change to In Progress (not on # creation). _post_created drives that status-change event so these ORCH-6 # routing tests still exercise task creation through the new trigger. -_IN_PROGRESS = "b873d9eb-993c-48cd-97ac-99a9b1623967" - - +# ORCH-39: the in_progress UUID is now project-specific, so derive it from the +# project being posted to (matches get_project_states resolution above). def _post_created(plane_project_id, plane_id="wi-1", name="A valid work item title"): + in_progress = _fake_get_project_states(plane_project_id)["in_progress"] return client.post( "/webhook/plane", json={ @@ -90,7 +123,7 @@ def _post_created(plane_project_id, plane_id="wi-1", name="A valid work item tit "name": name, "description_stripped": "This is a sufficiently long description.", "project": plane_project_id, - "state": {"id": _IN_PROGRESS, "name": "In Progress", "group": "started"}, + "state": {"id": in_progress, "name": "In Progress", "group": "started"}, }, }, )