Files
next-tjwater-server/tests/unit/test_peripheral_task_sync.py
T

163 lines
9.3 KiB
Python

import json
from uuid import uuid4
import httpx
import pytest
from scripts.run_operational_worker import CredentialProvider, WorkerSettings
from scripts.sync_peripheral_work_orders import sync_once, work_order_snapshot
from app.domain.schemas.operational_task import ExternalFeedbackCommand
@pytest.fixture
def anyio_backend():
return "asyncio"
@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["subtasks"] is None
# 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_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("investigation_status", ["waiting_human", "queued"])
async def test_created_work_order_receipt_routes_feedback_back_to_investigation(investigation_status):
project_id, order_id, investigation_id, projection_id = map(str, (uuid4(), uuid4(), uuid4(), uuid4()))
received = []
def handler(request):
if request.url.host == "sample.test":
return httpx.Response(200, json={"items": [{
"project_id": project_id, "work_order_id": order_id, "origin": "synthetic",
"revision": 2, "summary": "采样已完成", "status": "completed", "updated_at": "2026-09-23T09:00:00Z",
"title": "合成采样工单", "priority": "normal",
"work_order_type": "water_quality_complaint", "created_at": "2026-09-23T08:00:00Z",
"scheduled_at": None,
}], "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")
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,
}}]})
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,
}},
], "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)) == 2
assert any(investigation_id in path for path in received)
assert any(projection_id in path for path in received)
@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"
def test_snapshot_preserves_nested_process_without_inventing_completed_stages():
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",
"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["subtasks"] == order["subtasks"]
assert snapshot["subtasks"][0]["stages"][0]["status"] == "pending"
assert work_order_snapshot({key: value for key, value in order.items() if key != "subtasks"})["subtasks"] is None