Files
next-tjwater-server/tests/unit/test_peripheral_task_sync.py
T
jiang be6317eb48 feat(server): integrate MCP queries and investigation messaging
Add project-bound OAuth MCP queries and controlled analysis with scoped evidence,
limits, timeouts and a nearby-model spatial query using EPSG:4326 inputs.
Default nearby searches to 200 meters and five results.

Integrate investigation messaging/presentation, health/readiness and typed history;
remove duplicate web search and fix online simulation and compressed baseline writes.
Include project migrations 023-025; keep local credentials and demo seeding excluded.

Validation: 319 focused pytest checks; real PostGIS temporary-table regression;
read-only Jinqian bridge radius comparison; cross-language MCP discovery/calls.
2026-10-19 18:46:58 +08:00

426 lines
20 KiB
Python

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