352 lines
16 KiB
Python
352 lines
16 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(
|
|
("investigation_status", "expected_feedback_count", "routes_to_investigation"),
|
|
[
|
|
("waiting_human", 2, True),
|
|
("queued", 2, True),
|
|
("completed", 1, False),
|
|
],
|
|
)
|
|
async def test_created_work_order_receipt_preserves_projection_and_routes_active_feedback(
|
|
investigation_status,
|
|
expected_feedback_count,
|
|
routes_to_investigation,
|
|
):
|
|
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",
|
|
"created_by": str(uuid4()),
|
|
"revision": 2, "summary": "采样已完成", "status": "completed", "updated_at": "2026-09-23T09:00:00Z",
|
|
"title": "合成采样工单", "priority": "normal",
|
|
"work_order_type": "general", "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)
|
|
) == expected_feedback_count
|
|
assert any(investigation_id in path for path in received) is routes_to_investigation
|
|
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_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
|