import json import subprocess import sys from pathlib import Path from uuid import uuid4 import httpx import pytest from scripts.run_operational_worker import CredentialProvider, WorkerSettings from scripts.sync_peripheral_work_orders import ( peripheral_order_id, should_project_work_order, sync_once, work_order_snapshot, ) from samples.peripheral.seed_identity import SEED_ACTOR_ID from app.domain.schemas.operational_task import ExternalFeedbackCommand @pytest.fixture def anyio_backend(): return "asyncio" def test_documented_sync_command_can_start_from_repository_root(): repository_root = Path(__file__).resolve().parents[2] result = subprocess.run( [sys.executable, str(repository_root / "scripts/sync_peripheral_work_orders.py"), "--help"], cwd=repository_root, capture_output=True, text=True, check=False, ) assert result.returncode == 0, result.stderr assert "--once" in result.stdout def test_unlinked_synthetic_checks_are_not_operational_tasks(): order_id = str(uuid4()) order = { "work_order_id": order_id, "origin": "synthetic", "created_by": str(SEED_ACTOR_ID), "title": "合成压力偏差复核", "work_order_type": "general", } assert not should_project_work_order(order, set()) assert should_project_work_order(order, {order_id}) assert should_project_work_order( {**order, "work_order_type": "water_quality_complaint"}, set() ) assert should_project_work_order( {**order, "title": "现场采样:二次供水出水及末端用户水样"}, set() ) is False assert should_project_work_order( {**order, "created_by": str(uuid4()), "title": "合成管材接驳预约"}, set() ) def test_peripheral_order_id_ignores_foreign_and_malformed_namespaces(): order_id = str(uuid4()) assert peripheral_order_id(f"peripheral_sample:{order_id}") == order_id assert peripheral_order_id("peripheral_sample:not-a-uuid") is None assert peripheral_order_id(f"other_system:{order_id}") is None @pytest.mark.anyio async def test_sync_creates_one_task_and_only_applies_new_external_revisions(): project_id, order_id, task_id = map(str, (uuid4(), uuid4(), uuid4())) tasks, writes = [], [] order = {"project_id": project_id, "work_order_id": order_id, "origin": "synthetic", "revision": 1, "title": "水表维修", "summary": "等待现场班组处理", "priority": "normal", "status": "open", "created_at": "2026-09-23T08:00:00Z", "updated_at": "2026-09-23T08:00:00Z", "scheduled_at": None, "work_order_type": "general"} def handler(request): if request.url.host == "sample.test": return httpx.Response(200, json={"items": [order], "next_cursor": None}) if request.method == "GET": return httpx.Response(200, json={"items": tasks, "next_offset": None}) body = json.loads(request.content) writes.append((request.url.path, body)) if request.url.path.endswith("external-feedback"): tasks[0]["source"]["external_revision"] = body["source_revision"] tasks[0]["status"] = body["status"] return httpx.Response(200, json={"task": tasks[0]}) task = {**body, "task_id": task_id} tasks.append(task) return httpx.Response(201, json=task) settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY") async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: credentials = CredentialProvider(settings) assert await sync_once(client, settings, "http://sample.test", credentials) == 1 assert await sync_once(client, settings, "http://sample.test", credentials) == 0 order.update(revision=2, status="awaiting_reply", updated_at="2026-09-23T09:00:00Z", title="水表维修复核", assignee="测试班组", location="测试位置", scheduled_at="2026-09-24T08:00:00Z") assert await sync_once(client, settings, "http://sample.test", credentials) == 1 assert len(tasks) == 1 assert len(writes) == 3 assert writes[-1][1]["status"] == "awaiting_feedback" assert writes[-1][1]["source_revision"] == 2 snapshot = writes[-1][1]["work_order_snapshot"] assert snapshot["status"] == "awaiting_reply" assert snapshot["title"] == "水表维修复核" assert snapshot["assignee"] == "测试班组" assert snapshot["location"] == "测试位置" assert snapshot["scheduled_at"] == "2026-09-24T08:00:00Z" assert snapshot["feedback_requirements"] == [] assert snapshot["activity"] == [] # Exercise the real API schema, including the prefixed external identity. accepted = ExternalFeedbackCommand.model_validate(writes[-1][1]) assert str(accepted.work_order_snapshot.work_order_id) == order_id @pytest.mark.anyio async def test_sync_skips_unlinked_synthetic_check_before_writing(): project_id, order_id = str(uuid4()), str(uuid4()) order = { "project_id": project_id, "work_order_id": order_id, "origin": "synthetic", "created_by": str(SEED_ACTOR_ID), "revision": 1, "title": "合成通信核查", "summary": "仅用于外围样板联调", "priority": "normal", "status": "open", "created_at": "2026-09-23T08:00:00Z", "updated_at": "2026-09-23T08:00:00Z", "scheduled_at": None, "work_order_type": "general", } def handler(request): assert request.method == "GET" if request.url.host == "sample.test": return httpx.Response(200, json={"items": [order], "next_cursor": None}) return httpx.Response(200, json={"items": [], "next_offset": None}) settings = WorkerSettings( "http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY" ) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: synchronized = await sync_once( client, settings, "http://sample.test", CredentialProvider(settings), ) assert synchronized == 0 @pytest.mark.anyio async def test_sync_withdraws_only_deleted_peripheral_projection(): project_id, deleted_id, deleted_task_id, other_task_id = map( str, (uuid4(), uuid4(), uuid4(), uuid4()) ) deleted_source = f"peripheral_sample:{deleted_id}" tasks = [ {"task_id": deleted_task_id, "task_type": "external_work_order", "source": {"external_id": deleted_source, "external_revision": 1}}, {"task_id": other_task_id, "task_type": "external_work_order", "source": {"external_id": f"other_system:{uuid4()}", "external_revision": 1}}, ] withdrawals = [] def handler(request): if request.url.host == "sample.test": if request.url.path.endswith("/work-orders"): return httpx.Response(200, json={"items": [], "next_cursor": None}) assert request.url.path.endswith(f"/work-orders/{deleted_id}") return httpx.Response(404, json={"code": "WORK_ORDER_NOT_FOUND"}) if request.method == "GET": return httpx.Response(200, json={"items": tasks, "next_offset": None}) assert request.url.path.endswith(f"/{deleted_task_id}/withdraw-external-source") withdrawals.append(json.loads(request.content)) tasks.pop(0) return httpx.Response(200, json={}) settings = WorkerSettings( "http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY" ) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: credentials = CredentialProvider(settings) assert await sync_once(client, settings, "http://sample.test", credentials) == 1 assert await sync_once(client, settings, "http://sample.test", credentials) == 0 assert len(withdrawals) == 1 assert withdrawals[0]["source_event_id"] == deleted_source @pytest.mark.anyio @pytest.mark.parametrize("lookup_status", [200, 503]) async def test_sync_does_not_withdraw_when_missing_order_is_not_confirmed_deleted(lookup_status): project_id, order_id, task_id = map(str, (uuid4(), uuid4(), uuid4())) source_id = f"peripheral_sample:{order_id}" writes = [] def handler(request): if request.url.host == "sample.test": if request.url.path.endswith("/work-orders"): return httpx.Response(200, json={"items": [], "next_cursor": None}) return httpx.Response(lookup_status, json={"work_order_id": order_id}) if request.method == "GET": return httpx.Response(200, json={"items": [{ "task_id": task_id, "task_type": "external_work_order", "source": {"external_id": source_id, "external_revision": 1}, }], "next_offset": None}) writes.append(request.url.path) return httpx.Response(200, json={}) settings = WorkerSettings( "http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY" ) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: if lookup_status == 503: with pytest.raises(httpx.HTTPStatusError): await sync_once(client, settings, "http://sample.test", CredentialProvider(settings)) else: assert await sync_once( client, settings, "http://sample.test", CredentialProvider(settings) ) == 0 assert writes == [] @pytest.mark.anyio async def test_sample_sync_rejects_cross_project_data_before_writing(): def handler(request): assert request.method == "GET" return httpx.Response(200, json={"items": ([{"project_id": str(uuid4()), "origin": "synthetic"}] if request.url.host == "sample.test" else []), "next_offset": None}) settings = WorkerSettings("http://server.test", "http://agent.test", str(uuid4()), bearer="TEST_ONLY") async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: with pytest.raises(ValueError, match="scope"): await sync_once(client, settings, "http://sample.test", CredentialProvider(settings)) @pytest.mark.anyio @pytest.mark.parametrize("targeted", [False, True]) @pytest.mark.parametrize("order_revision", [1, 2]) @pytest.mark.parametrize( ("investigation_status", "expected_feedback_count", "routes_to_investigation"), [ ("waiting_human", 2, True), ("queued", 2, True), ("completed", 2, True), ("cancelled", 1, False), ], ) async def test_created_work_order_receipt_preserves_projection_and_routes_active_feedback( investigation_status, expected_feedback_count, routes_to_investigation, targeted, order_revision, ): project_id, order_id, investigation_id, projection_id = map(str, (uuid4(), uuid4(), uuid4(), uuid4())) received = [] def handler(request): if request.url.host == "sample.test": order = { "project_id": project_id, "work_order_id": order_id, "origin": "synthetic", "created_by": str(uuid4()), "revision": order_revision, "summary": "采样任务", "status": "completed" if order_revision > 1 else "open", "updated_at": "2026-09-23T09:00:00Z", "title": "合成采样工单", "priority": "normal", "work_order_type": "general", "created_at": "2026-09-23T08:00:00Z", "scheduled_at": None, "activity": ([{"sequence": 2, "occurred_at": "2026-09-23T09:00:00Z", "actor": "现场人员", "kind": "feedback", "title": "现场反馈", "detail": "复测余氯0.2mg/L"}] if order_revision > 1 else []), } if targeted: assert request.url.path.endswith(f"/work-orders/{order_id}") return httpx.Response(200, json=order) return httpx.Response(200, json={"items": [order], "next_cursor": None}) if request.method == "POST": assert request.url.path.endswith("external-feedback"), "Linked child orders must not start another investigation" received.append(request.url.path) payload = json.loads(request.content) assert payload["source_event_id"] == f"peripheral_sample:{order_id}" assert ("work_order_snapshot" in payload) == request.url.path.endswith(f"{projection_id}/external-feedback") if order_revision > 1: assert "复测余氯0.2mg/L" in payload["evidence"]["summary"] return httpx.Response(200, json={}) if request.url.path.endswith(investigation_id): return httpx.Response(200, json={"actions": [{"kind": "create_work_order", "receipt": { "adapter": "peripheral_sample", "receipt_id": order_id, }}]}) if targeted: assert request.url.params["peripheral_work_order_id"] == order_id return httpx.Response(200, json={"items": [ {"task_id": investigation_id, "task_type": "event_investigation", "status": investigation_status}, {"task_id": projection_id, "task_type": "external_work_order", "source": { "external_id": f"peripheral_sample:{order_id}", "external_revision": 1 if order_revision > 1 else None, }}, ], "next_offset": None}) settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY") async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: assert await sync_once( client, settings, "http://sample.test", CredentialProvider(settings), **({"work_order_ids": {order_id}} if targeted else {}), ) == (expected_feedback_count if order_revision > 1 else 1) assert any(investigation_id in path for path in received) is (routes_to_investigation and order_revision > 1) assert any(projection_id in path for path in received) assert received[-1].endswith(f"{projection_id}/external-feedback") @pytest.mark.anyio @pytest.mark.parametrize("event_kind", ["water_quality_complaint", "burst_report"]) async def test_external_events_start_investigations_without_browser(event_kind): project_id, order_id = str(uuid4()), str(uuid4()) created = [] def handler(request): if request.method == "GET" and request.url.host == "server.test": return httpx.Response(200, json={"items": [], "next_offset": None}) if request.url.host == "sample.test": return httpx.Response(200, json={"items": [{ "project_id": project_id, "work_order_id": order_id, "origin": "synthetic", "revision": 1, "title": "外围测试事件", "summary": "模拟外部系统上报", "priority": "urgent", "status": "open", "created_at": "2026-09-23T08:00:00Z", "updated_at": "2026-09-23T08:00:00Z", "scheduled_at": "2026-09-23T07:30:00Z", "work_order_type": event_kind, "location": "测试位置", }], "next_cursor": None}) payload = json.loads(request.content) if request.url.path.endswith("external-feedback"): return httpx.Response(200, json={}) task = {**payload, "task_id": str(uuid4())} created.append(task) return httpx.Response(201, json=task) settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY") async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: await sync_once(client, settings, "http://sample.test", CredentialProvider(settings)) assert {task["task_type"] for task in created} == {"event_investigation", "external_work_order"} investigation = next(task for task in created if task["task_type"] == "event_investigation") assert investigation["workflow"] == event_kind assert investigation["source"]["external_id"] == order_id assert investigation["trigger"]["occurred_at"] == "2026-09-23T07:30:00Z" @pytest.mark.anyio @pytest.mark.parametrize("failure_target", ["parent", "projection"]) async def test_child_feedback_retry_keeps_projection_cursor_behind_all_parent_deliveries(failure_target): project_id, order_id, parent_id, projection_id = map(str, (uuid4(), uuid4(), uuid4(), uuid4())) parent = {"task_id": parent_id, "task_type": "event_investigation", "status": "completed", "source": {}} projection = {"task_id": projection_id, "task_type": "external_work_order", "source": { "external_id": f"peripheral_sample:{order_id}", "external_revision": 1, }} order = {"project_id": project_id, "work_order_id": order_id, "origin": "synthetic", "revision": 2, "title": "采样", "summary": "复测通过", "status": "completed", "priority": "normal", "created_at": "2026-10-10T00:00:00Z", "updated_at": "2026-10-10T01:00:00Z", "scheduled_at": None, "work_order_type": "general"} failed = False accepted_parent_requests = set() detail_reads = [] def handler(request): nonlocal failed if request.url.host == "sample.test": return httpx.Response(200, json={"items": [order], "next_cursor": None}) if request.method == "GET": if request.url.path.endswith(parent_id): detail_reads.append(parent_id) return httpx.Response(200, json={"actions": [{"kind": "create_work_order", "receipt": { "adapter": "peripheral_sample", "receipt_id": order_id, }}]}) return httpx.Response(200, json={"items": [parent, projection], "next_offset": None}) payload = json.loads(request.content) is_parent = request.url.path.endswith(f"{parent_id}/external-feedback") if not failed and is_parent == (failure_target == "parent"): failed = True return httpx.Response(503, json={}) if is_parent: accepted_parent_requests.add(payload["request_id"]) parent["status"] = "queued" else: assert accepted_parent_requests, "Projection must not advance before its associated investigation" projection["source"]["external_revision"] = payload["source_revision"] return httpx.Response(200, json={}) settings = WorkerSettings("http://server.test", "http://agent.test", project_id, bearer="TEST_ONLY") async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: credentials = CredentialProvider(settings) with pytest.raises(httpx.HTTPStatusError): await sync_once(client, settings, "http://sample.test", credentials) assert projection["source"]["external_revision"] == 1 assert await sync_once(client, settings, "http://sample.test", credentials) == 2 assert len(accepted_parent_requests) == 1 assert projection["source"]["external_revision"] == 2 parent["status"] = "completed" detail_reads.clear() assert await sync_once(client, settings, "http://sample.test", credentials) == 0 assert detail_reads == [], "Unchanged source versions must not scan completed parent details forever" def test_snapshot_preserves_recorded_activity_without_legacy_subtasks(): order = { "work_order_id": str(uuid4()), "revision": 4, "title": "合成预约", "summary": "仅测试过程同步", "status": "in_progress", "priority": "normal", "origin": "synthetic", "updated_at": "2026-09-23T09:00:00Z", "work_order_type": "general", "feedback_requirements": ["回填现场检查结果"], "activity": [{ "sequence": 1, "occurred_at": "2026-09-23T08:00:00Z", "actor": "测试调度", "kind": "created", "title": "工单创建", "detail": "已创建现场检查工单。", }], "subtasks": [{ "id": "visit", "title": "现场检查", "summary": "合成测试,未现场执行", "status": "pending", "scheduled_at": "2026-09-24T08:00:00Z", "location": "测试位置", "assignee": "测试班组", "duration_minutes": 30, "reply_window_minutes": 15, "reply_requirements": ["检查记录"], "stages": [{"id": "dispatch", "sequence": 1, "title": "派发", "status": "pending", "owner": "测试调度", "time_label": "待安排", "description": "尚未派发"}], }], } snapshot = work_order_snapshot(order) assert snapshot["feedback_requirements"] == ["回填现场检查结果"] assert snapshot["activity"] == order["activity"] assert "subtasks" not in snapshot