"""ORCH-10: per-project Plane state resolution tests. Verifies: 1. get_project_states(ET_PROJECT_ID) -> enduro-trails UUIDs (backward compat). 2. get_project_states(ORCH_PROJECT_ID) -> orchestrator UUIDs. 3. get_project_states falls back to _DEFAULT_STATES when the Plane API fails. 4. _STATES_CACHE is populated after a successful call and reload_project_states evicts it (per-project and full flush). 5. stage_to_state() resolves per-project UUIDs for both projects. 6. Webhook handle_issue_updated recognises In Progress for BOTH projects (ORCH-10 critical path: e331bfb3 for ORCH, b873d9eb for ET -> pipeline start). 7. Webhook handle_issue_updated recognises Approved/Rejected per project. """ import os import sys import tempfile from unittest.mock import patch, MagicMock, AsyncMock import pytest # --------------------------------------------------------------------------- # Minimal env so src/config.py can import without a real .env file. # --------------------------------------------------------------------------- os.environ.setdefault("ORCH_PLANE_API_URL", "http://plane.local") os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") os.environ.setdefault("ORCH_PLANE_WORKSPACE_SLUG", "test-ws") os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") os.environ.setdefault("ORCH_PLANE_WEBHOOK_SECRET", "") os.environ.setdefault("ORCH_GITEA_WEBHOOK_SECRET", "") _test_db = os.path.join(tempfile.gettempdir(), "test_orch10_states.db") os.environ["ORCH_DB_PATH"] = _test_db # --------------------------------------------------------------------------- # Known UUIDs from the ТЗ (source of truth). # --------------------------------------------------------------------------- ET_PROJECT_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c" ORCH_PROJECT_ID = "8da6aa25-a60e-44d6-a1e2-d8ae59aa7d6a" ET_STATES = { "backlog": "113b24f6-cce8-4be9-9a22-a359b9cf0122", "todo": "2c7d3df3-9eb9-419b-92b7-d7d560bcdd10", "in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967", "architecture": "3020bbb7-6122-4663-930c-0315ba8dfa3d", "development": "9920609b-f140-4e46-ab95-89acda8412c8", "review": "ba0d802c-5218-41d4-ab43-978b0ea123ed", "testing": "7855d807-b1bf-42ef-8dae-6cde0df92d02", "approved": "a519a341-dada-4a91-8910-7604f82b79c5", "rejected": "ba958f3c-5db5-461d-8f82-89425e413b97", "done": "381a2833-3c4e-4be5-bd0f-be84cb946ad8", "cancelled": "b1cae7f9-961d-4889-a179-f3acea697d17", "needs_input": "babf08a3-ff4d-41f3-a821-5491aa29a8ac", "in_review": "38fb1f64-aa1e-48a3-92e0-0b109679046b", "blocked": "6c4543f9-ac47-4ef7-ae0f-070020dc9920", } ORCH_STATES = { "backlog": "2d5d42ff-e94d-4209-a664-8020c28c2a95", "todo": "b5d3f512-4870-460f-bf6b-4ea560f00a6f", "in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", "architecture": "795cc32f-5f5a-4244-be7b-9acffc92c7c0", "development": "f5ed4705-5029-470d-89a9-54c3f0d211ee", "review": "2026f3d9-0f43-4054-ab5f-3f9bae3308b8", "testing": "81c5cd78-2993-4f2c-9e8c-2f52db3e5623", "approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff", "rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3", "done": "3738cd3c-7610-4907-ba5e-26b9a248d9c0", "cancelled": "59d1d210-8e3a-4a83-930a-cbc5dbf6ad85", "needs_input": "99978b3f-72fe-46e3-8b9b-25ba02899fa0", "in_review": "c52e99b9-31ae-4b31-be3f-9773eea7a747", "blocked": "505f01a6-a12f-4121-aaa7-9c5dd009acc4", } def _make_states_response(states_dict: dict) -> dict: """Build a fake Plane GET /states/ response.""" name_map = {v: k for k, v in { "backlog": "Backlog", "todo": "Todo", "in_progress": "In Progress", "architecture": "Architecture", "development": "Development", "review": "Review", "testing": "Testing", "approved": "Approved", "rejected": "Rejected", "done": "Done", "cancelled": "Cancelled", "needs_input": "Needs Input", "in_review": "In Review", "blocked": "Blocked", }.items()} logical_to_plane = { "backlog": "Backlog", "todo": "Todo", "in_progress": "In Progress", "architecture": "Architecture", "development": "Development", "review": "Review", "testing": "Testing", "approved": "Approved", "rejected": "Rejected", "done": "Done", "cancelled": "Cancelled", "needs_input": "Needs Input", "in_review": "In Review", "blocked": "Blocked", } results = [ {"id": uid, "name": logical_to_plane[key]} for key, uid in states_dict.items() if key in logical_to_plane ] return {"results": results} # --------------------------------------------------------------------------- # Helpers to build fake httpx responses. # --------------------------------------------------------------------------- def _fake_response(data: dict, status: int = 200): m = MagicMock() m.status_code = status m.json.return_value = data if status >= 400: from httpx import HTTPStatusError, Request, Response m.raise_for_status.side_effect = HTTPStatusError( "error", request=MagicMock(), response=MagicMock() ) else: m.raise_for_status.return_value = None return m # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) def reset_states_cache(): """Ensure the states cache is empty before each test.""" import src.plane_sync as ps ps.reload_project_states() yield ps.reload_project_states() # --------------------------------------------------------------------------- # 1 & 2. get_project_states returns correct UUIDs per project # --------------------------------------------------------------------------- def test_get_project_states_enduro(): """ET project -> enduro-trails UUIDs.""" import src.plane_sync as ps with patch("src.plane_sync.httpx.get") as mock_get: mock_get.return_value = _fake_response(_make_states_response(ET_STATES)) states = ps.get_project_states(ET_PROJECT_ID) for key, expected_uuid in ET_STATES.items(): assert states[key] == expected_uuid, ( f"ET state '{key}': expected {expected_uuid}, got {states.get(key)}" ) def test_get_project_states_orchestrator(): """ORCH project -> orchestrator UUIDs.""" import src.plane_sync as ps with patch("src.plane_sync.httpx.get") as mock_get: mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES)) states = ps.get_project_states(ORCH_PROJECT_ID) for key, expected_uuid in ORCH_STATES.items(): assert states[key] == expected_uuid, ( f"ORCH state '{key}': expected {expected_uuid}, got {states.get(key)}" ) def test_get_project_states_et_in_progress_uuid(): """ET in_progress == b873d9eb (exact UUID from ТЗ).""" import src.plane_sync as ps with patch("src.plane_sync.httpx.get") as mock_get: mock_get.return_value = _fake_response(_make_states_response(ET_STATES)) states = ps.get_project_states(ET_PROJECT_ID) assert states["in_progress"] == "b873d9eb-993c-48cd-97ac-99a9b1623967" def test_get_project_states_orch_in_progress_uuid(): """ORCH in_progress == e331bfb3 (exact UUID from ТЗ) — the ORCH-10 blocker.""" import src.plane_sync as ps with patch("src.plane_sync.httpx.get") as mock_get: mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES)) states = ps.get_project_states(ORCH_PROJECT_ID) assert states["in_progress"] == "e331bfb3-e17e-4699-ba48-4abb89c21b7b" # --------------------------------------------------------------------------- # 3. Fallback to _DEFAULT_STATES when API fails # --------------------------------------------------------------------------- def test_get_project_states_api_error_fallback(): """Network failure -> returns _DEFAULT_STATES (ET values).""" import src.plane_sync as ps with patch("src.plane_sync.httpx.get", side_effect=Exception("network error")): states = ps.get_project_states(ORCH_PROJECT_ID) # Should return _DEFAULT_STATES (ET values) as fallback. assert states is ps._DEFAULT_STATES def test_get_project_states_non_200_fallback(): """Non-2xx response -> returns _DEFAULT_STATES.""" import src.plane_sync as ps with patch("src.plane_sync.httpx.get") as mock_get: mock_get.return_value = _fake_response({}, status=500) states = ps.get_project_states(ORCH_PROJECT_ID) assert states is ps._DEFAULT_STATES def test_get_project_states_empty_response_fallback(): """Empty results list -> returns _DEFAULT_STATES.""" import src.plane_sync as ps with patch("src.plane_sync.httpx.get") as mock_get: mock_get.return_value = _fake_response({"results": []}) states = ps.get_project_states(ORCH_PROJECT_ID) assert states is ps._DEFAULT_STATES def test_get_project_states_none_project_id_fallback(): """None project_id -> _DEFAULT_STATES immediately (no API call).""" import src.plane_sync as ps with patch("src.plane_sync.httpx.get") as mock_get: states = ps.get_project_states(None) mock_get.assert_not_called() assert states is ps._DEFAULT_STATES # --------------------------------------------------------------------------- # 4. Caching & reload_project_states # --------------------------------------------------------------------------- def test_get_project_states_caches_result(): """Second call returns cached result without hitting the API again.""" import src.plane_sync as ps with patch("src.plane_sync.httpx.get") as mock_get: mock_get.return_value = _fake_response(_make_states_response(ET_STATES)) _ = ps.get_project_states(ET_PROJECT_ID) _ = ps.get_project_states(ET_PROJECT_ID) assert mock_get.call_count == 1 def test_reload_project_states_per_project(): """reload_project_states(project_id) evicts only that project.""" import src.plane_sync as ps with patch("src.plane_sync.httpx.get") as mock_get: mock_get.return_value = _fake_response(_make_states_response(ET_STATES)) ps.get_project_states(ET_PROJECT_ID) assert ET_PROJECT_ID in ps._STATES_CACHE ps.reload_project_states(ET_PROJECT_ID) assert ET_PROJECT_ID not in ps._STATES_CACHE def test_reload_project_states_full_flush(): """reload_project_states() with no args clears entire cache.""" import src.plane_sync as ps with patch("src.plane_sync.httpx.get") as mock_get: mock_get.return_value = _fake_response(_make_states_response(ET_STATES)) ps.get_project_states(ET_PROJECT_ID) ps.reload_project_states() assert ps._STATES_CACHE == {} # --------------------------------------------------------------------------- # 5. stage_to_state() resolves per-project # --------------------------------------------------------------------------- def test_stage_to_state_et_analysis(): """ET analysis -> in_progress UUID b873d9eb.""" import src.plane_sync as ps with patch("src.plane_sync.httpx.get") as mock_get: mock_get.return_value = _fake_response(_make_states_response(ET_STATES)) uid = ps.stage_to_state("analysis", ET_PROJECT_ID) assert uid == "b873d9eb-993c-48cd-97ac-99a9b1623967" def test_stage_to_state_orch_analysis(): """ORCH analysis -> in_progress UUID e331bfb3.""" import src.plane_sync as ps with patch("src.plane_sync.httpx.get") as mock_get: mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES)) uid = ps.stage_to_state("analysis", ORCH_PROJECT_ID) assert uid == "e331bfb3-e17e-4699-ba48-4abb89c21b7b" def test_stage_to_state_unknown_stage(): """Unknown stage -> None.""" import src.plane_sync as ps with patch("src.plane_sync.httpx.get") as mock_get: mock_get.return_value = _fake_response(_make_states_response(ET_STATES)) uid = ps.stage_to_state("nonexistent_stage", ET_PROJECT_ID) assert uid is None def test_stage_to_state_orch_done(): """ORCH done -> 3738cd3c.""" import src.plane_sync as ps with patch("src.plane_sync.httpx.get") as mock_get: mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES)) uid = ps.stage_to_state("done", ORCH_PROJECT_ID) assert uid == "3738cd3c-7610-4907-ba5e-26b9a248d9c0" # --------------------------------------------------------------------------- # 6 & 7. Webhook handle_issue_updated — ORCH-10 critical path # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_webhook_in_progress_et_starts_pipeline(): """ET In Progress (b873d9eb) -> handle_status_start called.""" from src.webhooks.plane import handle_issue_updated import src.plane_sync as ps et_states_resp = _make_states_response(ET_STATES) with patch("src.plane_sync.httpx.get") as mock_httpx, \ patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \ patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict: mock_httpx.return_value = _fake_response(et_states_resp) data = { "id": "et-issue-uuid", "state": {"id": "b873d9eb-993c-48cd-97ac-99a9b1623967", "name": "In Progress"}, } await handle_issue_updated(data, ET_PROJECT_ID) mock_start.assert_called_once() mock_verdict.assert_not_called() @pytest.mark.asyncio async def test_webhook_in_progress_orch_starts_pipeline(): """ORCH In Progress (e331bfb3) -> handle_status_start called. This is the ORCH-10 blocker: previously the webhook compared against the hardcoded ET UUID (b873d9eb) and the ORCH UUID (e331bfb3) was silently ignored — the pipeline never started for ORCH tasks. """ from src.webhooks.plane import handle_issue_updated import src.plane_sync as ps orch_states_resp = _make_states_response(ORCH_STATES) with patch("src.plane_sync.httpx.get") as mock_httpx, \ patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \ patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict: mock_httpx.return_value = _fake_response(orch_states_resp) data = { "id": "orch-issue-uuid", "state": {"id": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", "name": "In Progress"}, } await handle_issue_updated(data, ORCH_PROJECT_ID) mock_start.assert_called_once() mock_verdict.assert_not_called() @pytest.mark.asyncio async def test_webhook_approved_orch(): """ORCH Approved (63f2c8fe) -> handle_verdict(approved=True).""" from src.webhooks.plane import handle_issue_updated orch_states_resp = _make_states_response(ORCH_STATES) with patch("src.plane_sync.httpx.get") as mock_httpx, \ patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \ patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict: mock_httpx.return_value = _fake_response(orch_states_resp) data = { "id": "orch-issue-uuid", "state": {"id": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff", "name": "Approved"}, } await handle_issue_updated(data, ORCH_PROJECT_ID) mock_verdict.assert_called_once_with(data, ORCH_PROJECT_ID, approved=True) mock_start.assert_not_called() @pytest.mark.asyncio async def test_webhook_rejected_orch(): """ORCH Rejected (4c769e90) -> handle_verdict(approved=False).""" from src.webhooks.plane import handle_issue_updated orch_states_resp = _make_states_response(ORCH_STATES) with patch("src.plane_sync.httpx.get") as mock_httpx, \ patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \ patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict: mock_httpx.return_value = _fake_response(orch_states_resp) data = { "id": "orch-issue-uuid", "state": {"id": "4c769e90-bf80-4a52-b97a-e1c84904bfc3", "name": "Rejected"}, } await handle_issue_updated(data, ORCH_PROJECT_ID) mock_verdict.assert_called_once_with(data, ORCH_PROJECT_ID, approved=False) mock_start.assert_not_called() @pytest.mark.asyncio async def test_webhook_other_state_no_action(): """A non-trigger state (e.g. 'Needs Input') -> no pipeline action.""" from src.webhooks.plane import handle_issue_updated orch_states_resp = _make_states_response(ORCH_STATES) with patch("src.plane_sync.httpx.get") as mock_httpx, \ patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \ patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict: mock_httpx.return_value = _fake_response(orch_states_resp) data = { "id": "orch-issue-uuid", "state": {"id": "99978b3f-72fe-46e3-8b9b-25ba02899fa0", "name": "Needs Input"}, } await handle_issue_updated(data, ORCH_PROJECT_ID) mock_start.assert_not_called() mock_verdict.assert_not_called() @pytest.mark.asyncio async def test_webhook_et_in_progress_not_confused_with_orch(): """ET In Progress UUID does NOT trigger pipeline for ORCH project. This guards against the reverse confusion: if somehow an ET UUID was sent for an ORCH project event, it should NOT start the pipeline (wrong UUID). """ from src.webhooks.plane import handle_issue_updated orch_states_resp = _make_states_response(ORCH_STATES) with patch("src.plane_sync.httpx.get") as mock_httpx, \ patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \ patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict: mock_httpx.return_value = _fake_response(orch_states_resp) # Send ET's in_progress UUID for an ORCH project event. data = { "id": "orch-issue-uuid", "state": {"id": "b873d9eb-993c-48cd-97ac-99a9b1623967", "name": "In Progress"}, } await handle_issue_updated(data, ORCH_PROJECT_ID) # Since ORCH in_progress is e331bfb3, ET's b873d9eb should NOT trigger start. mock_start.assert_not_called() mock_verdict.assert_not_called() # --------------------------------------------------------------------------- # 8. _DEFAULT_STATES / PLANE_STATES alias preserved # --------------------------------------------------------------------------- def test_plane_states_alias_is_default_states(): """PLANE_STATES is still exported and equals _DEFAULT_STATES (backward compat).""" import src.plane_sync as ps assert ps.PLANE_STATES is ps._DEFAULT_STATES def test_default_states_et_values(): """_DEFAULT_STATES contains the original enduro-trails UUIDs.""" import src.plane_sync as ps for key, expected in ET_STATES.items(): assert ps._DEFAULT_STATES[key] == expected, ( f"_DEFAULT_STATES['{key}']: expected {expected}, got {ps._DEFAULT_STATES.get(key)}" ) # --------------------------------------------------------------------------- # ORCH-066 TC-19 (AC-18): resolve-by-name — when a project DEFINES one of the # new statuses, get_project_states must use its OWN UUID, not the default alias. # --------------------------------------------------------------------------- def test_orch066_tc19_name_resolution_beats_alias(): """A project that created 'Analysis' / 'Code-Review' / 'Awaiting Deploy' / 'Deploying' / 'Monitoring after Deploy' resolves each to its own project UUID (via _PLANE_NAME_TO_KEY), NOT the aliased base-key UUID.""" import src.plane_sync as ps new_uuids = { "Analysis": "11111111-0000-0000-0000-000000000001", "Code-Review": "11111111-0000-0000-0000-000000000002", "Awaiting Deploy": "11111111-0000-0000-0000-000000000003", "Deploying": "11111111-0000-0000-0000-000000000004", "Monitoring after Deploy": "11111111-0000-0000-0000-000000000005", "To Analyse": "11111111-0000-0000-0000-000000000006", } # Start from the full ORCH base set, then add the dedicated new statuses. results = _make_states_response(ORCH_STATES)["results"] results += [{"id": uid, "name": name} for name, uid in new_uuids.items()] with patch("src.plane_sync.httpx.get") as mock_get: mock_get.return_value = _fake_response({"results": results}) states = ps.get_project_states(ORCH_PROJECT_ID) # Each new key resolved to the project's OWN UUID, not the base-key alias. assert states["analysis"] == new_uuids["Analysis"] assert states["code_review"] == new_uuids["Code-Review"] assert states["awaiting_deploy"] == new_uuids["Awaiting Deploy"] assert states["deploying"] == new_uuids["Deploying"] assert states["monitoring"] == new_uuids["Monitoring after Deploy"] assert states["to_analyse"] == new_uuids["To Analyse"] # Sanity: they are NOT the aliased base UUIDs. assert states["analysis"] != states["in_progress"] assert states["code_review"] != states["review"] assert states["awaiting_deploy"] != states["in_review"] def test_orch066_tc19_missing_new_status_aliases_to_project_base(): """BR-12: a project WITHOUT the new statuses degrades each new key to its OWN base UUID (not a foreign enduro UUID) — keeping the PATCH state valid.""" import src.plane_sync as ps with patch("src.plane_sync.httpx.get") as mock_get: mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES)) states = ps.get_project_states(ORCH_PROJECT_ID) # No dedicated new statuses -> alias to THIS project's base UUIDs. assert states["analysis"] == ORCH_STATES["in_progress"] assert states["to_analyse"] == ORCH_STATES["in_progress"] assert states["code_review"] == ORCH_STATES["review"] assert states["awaiting_deploy"] == ORCH_STATES["in_review"] assert states["deploying"] == ORCH_STATES["in_progress"] assert states["monitoring"] == ORCH_STATES["done"]