fix(tests): per-project Plane states in webhook tests + close CI hole (ORCH-39) #35

Merged
admin merged 1 commits from fix/ORCH-39-webhook-tests into main 2026-06-05 17:36:40 +03:00
5 changed files with 87 additions and 9 deletions

View File

@@ -12,11 +12,17 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install dependencies - name: Install dependencies
run: | run: |
set -euo pipefail
python3 -m pip install --user --upgrade pip python3 -m pip install --user --upgrade pip
python3 -m pip install --user -r requirements.txt python3 -m pip install --user -r requirements.txt
- name: Test - name: Test
env: env:
PYTHONPATH: ${{ github.workspace }} PYTHONPATH: ${{ github.workspace }}
run: | 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" export PATH="$HOME/.local/bin:$PATH"
python3 -m pytest tests/ -q python3 -m pytest tests/ -q -p no:cacheprovider --strict-markers

13
pytest.ini Normal file
View File

@@ -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

View File

@@ -3,3 +3,4 @@ uvicorn[standard]==0.30.0
pydantic-settings==2.5.0 pydantic-settings==2.5.0
httpx==0.27.0 httpx==0.27.0
pytest==8.3.3 pytest==8.3.3
pytest-asyncio==0.23.8

View File

@@ -34,6 +34,27 @@ import src.plane_sync as plane_sync # noqa: E402
ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a" ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c" 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) client = TestClient(app)
@@ -48,6 +69,10 @@ def setup(monkeypatch):
monkeypatch.setattr("src.webhooks.plane.verify_plane_signature", lambda body, sig: True) 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 = ( registry_json = (
f'[{{"plane_project_id": "{ENDURO_PLANE_ID}", "repo": "enduro-trails",' f'[{{"plane_project_id": "{ENDURO_PLANE_ID}", "repo": "enduro-trails",'
f' "work_item_prefix": "ET", "name": "enduro-trails"}},' f' "work_item_prefix": "ET", "name": "enduro-trails"}},'
@@ -60,6 +85,7 @@ def setup(monkeypatch):
yield yield
reload_projects() reload_projects()
plane_sync.reload_project_states()
if os.path.exists(_test_db): if os.path.exists(_test_db):
os.unlink(_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. # 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"): 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( return client.post(
"/webhook/plane", "/webhook/plane",
json={ json={
@@ -117,7 +142,7 @@ def _post(plane_id, plane_project_id=ORCH_PLANE_ID, name="A valid work item titl
"name": name, "name": name,
"description_stripped": "This is a sufficiently long description.", "description_stripped": "This is a sufficiently long description.",
"project": plane_project_id, "project": plane_project_id,
"state": {"id": _IN_PROGRESS, "name": "In Progress", "group": "started"}, "state": {"id": in_progress, "name": "In Progress", "group": "started"},
}, },
}, },
) )

View File

@@ -33,11 +33,36 @@ from src.main import app # noqa: E402
from src.db import init_db, get_db # noqa: E402 from src.db import init_db, get_db # noqa: E402
from src import projects as P # noqa: E402 from src import projects as P # noqa: E402
from src.projects import reload_projects # 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" ORCH_PLANE_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a"
ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c" ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
UNKNOWN_PLANE_ID = "deadbeef-0000-0000-0000-000000000000" 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) client = TestClient(app)
@@ -57,6 +82,13 @@ def setup(monkeypatch):
# focuses on the project filter, so bypass signature verification. # focuses on the project filter, so bypass signature verification.
monkeypatch.setattr("src.webhooks.plane.verify_plane_signature", lambda body, sig: True) 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 = ( registry_json = (
f'[{{"plane_project_id": "{ENDURO_PLANE_ID}", "repo": "enduro-trails",' f'[{{"plane_project_id": "{ENDURO_PLANE_ID}", "repo": "enduro-trails",'
f' "work_item_prefix": "ET", "name": "enduro-trails"}},' f' "work_item_prefix": "ET", "name": "enduro-trails"}},'
@@ -69,6 +101,7 @@ def setup(monkeypatch):
yield yield
reload_projects() # restore from env reload_projects() # restore from env
plane_sync.reload_project_states()
if os.path.exists(_test_db): if os.path.exists(_test_db):
os.unlink(_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 # 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 # creation). _post_created drives that status-change event so these ORCH-6
# routing tests still exercise task creation through the new trigger. # 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"): 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( return client.post(
"/webhook/plane", "/webhook/plane",
json={ json={
@@ -90,7 +123,7 @@ def _post_created(plane_project_id, plane_id="wi-1", name="A valid work item tit
"name": name, "name": name,
"description_stripped": "This is a sufficiently long description.", "description_stripped": "This is a sufficiently long description.",
"project": plane_project_id, "project": plane_project_id,
"state": {"id": _IN_PROGRESS, "name": "In Progress", "group": "started"}, "state": {"id": in_progress, "name": "In Progress", "group": "started"},
}, },
}, },
) )