Files
next-tjwater-server/tests/integration/test_peripheral_sample_live.py
T

360 lines
16 KiB
Python

"""Opt-in checks against provisioned sample tables, never business-table writes."""
from concurrent.futures import ThreadPoolExecutor
import os
import subprocess
import sys
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from samples.peripheral import schemas as peripheral_schemas
from samples.peripheral.api import create_app
from samples.peripheral.auth import Identity, identity
from samples.peripheral.config import SampleSettings
from samples.peripheral.errors import SampleFailure
from samples.peripheral.schemas import (
AddFeedback,
CreateTask,
CreateTestDataRecord,
CreateWorkOrder,
DeleteResource,
Observations,
ScenarioCommand,
Task,
Transition,
UpdateTestDataRecord,
UpdateWorkOrder,
WorkOrder,
)
from samples.peripheral.store import PostgresStore
pytestmark = pytest.mark.skipif(os.getenv("PERIPHERAL_SAMPLE_LIVE_TEST") != "1", reason="explicit live sample opt-in required")
@pytest.fixture
def sample():
store = PostgresStore(SampleSettings(enable_faults=True))
who = Identity(str(uuid4()), str(uuid4()), True)
yield store, who
# Only this test's unguessable project key; tables created by the authorized setup.
with store.connection() as conn:
for table in (
"work_order_revisions", "test_data_record_revisions", "work_orders",
"test_data_records", "feedback", "requests", "revisions", "tasks", "scenarios",
):
from psycopg import sql
exists = conn.execute(
"SELECT to_regclass(%s) AS name", (f"peripheral_sample.{table}",)
).fetchone()["name"]
if exists is not None:
conn.execute(
sql.SQL("DELETE FROM peripheral_sample.{} WHERE project_id=%s").format(
sql.Identifier(table)
),
(who.project_id,),
)
def command():
return CreateTask.model_validate({
"request_id": str(uuid4()), "title": "AUTOTEST isolated sample", "summary": "not a production source",
"source": {"system": "next", "task_id": str(uuid4()), "object_type": "validation", "object_id": str(uuid4()), "revision": 1},
"asset_ids": ["test-pressure"], "analysis_window": {"start": "2026-04-30T08:00:00Z", "end": "2026-04-30T09:00:00Z"},
"evidence_refs": [], "requested_check": "verification only",
})
def move(store, who, task, status):
return store.transition(who, task["task_id"], Transition(request_id=uuid4(), expected_revision=task["revision"], target_status=status, reason="AUTOTEST"))
def feedback(task):
return AddFeedback.model_validate({"request_id": str(uuid4()), "expected_revision": task["revision"], "feedback_id": str(uuid4()),
"occurred_at": "2026-04-30T08:30:00Z", "applicable_time": task["analysis_window"], "asset_ids": task["asset_ids"],
"finding": "instrument_fault", "notes": "AUTOTEST synthetic only", "attachment_refs": []})
def scenario(store, who, name):
return store.set_scenario(who, ScenarioCommand(request_id=uuid4(), scenario=name))
def test_persistence_transitions_feedback_idempotency_and_isolation(sample):
store, who = sample
cmd = command()
task = store.create_task(who, cmd)
Task.model_validate(task)
fresh = PostgresStore(store.settings)
assert fresh.get_task(who, task["task_id"]) == task
# A new interpreter has no service memory or open DB connections to reuse.
probe = subprocess.run([sys.executable, "-c",
"import sys; from samples.peripheral.store import PostgresStore; "
"from samples.peripheral.config import SampleSettings; from samples.peripheral.auth import Identity; "
"print(PostgresStore(SampleSettings()).get_task(Identity(sys.argv[1],sys.argv[2],True),sys.argv[3])['revision'])",
who.project_id, who.user_id, task["task_id"]], capture_output=True, text=True, timeout=15, check=True)
assert probe.stdout.strip() == "1"
assert fresh.create_task(who, cmd) == task
with pytest.raises(SampleFailure, match="同一 request_id"):
store.create_task(who, cmd.model_copy(update={"title": "changed"}))
outsider = Identity(str(uuid4()), who.user_id, True)
with pytest.raises(SampleFailure) as exc:
store.get_task(outsider, task["task_id"])
assert exc.value.code == "TASK_NOT_FOUND"
with pytest.raises(SampleFailure):
store.receipt(Identity(who.project_id, str(uuid4()), True), cmd.request_id)
with pytest.raises(SampleFailure):
store.create_task(Identity(who.project_id, who.user_id, False), command())
with pytest.raises(SampleFailure):
move(store, who, task, "completed")
task = move(store, who, task, "processing")
stale = task.copy()
cmd_feedback = feedback(task)
task = store.add_feedback(who, task["task_id"], cmd_feedback)
repeated = cmd_feedback.model_copy(update={"request_id": uuid4()})
assert store.add_feedback(who, task["task_id"], repeated) == task
with pytest.raises(SampleFailure) as exc:
store.add_feedback(who, task["task_id"], repeated.model_copy(update={"request_id": uuid4(), "notes": "different"}))
assert exc.value.code == "FEEDBACK_CONFLICT"
with pytest.raises(SampleFailure) as exc:
move(store, who, stale, "pending_review")
assert exc.value.code == "REVISION_CONFLICT"
task = move(store, who, task, "pending_review")
task = move(store, who, task, "processing")
task = move(store, who, task, "pending_review")
task = move(store, who, task, "completed")
assert len(task["feedback"]) == 1
assert len(task["history"]) == 6
with store.connection(readonly=True) as conn:
assert conn.execute("SELECT count(*) AS n FROM peripheral_sample.revisions WHERE project_id=%s", (who.project_id,)).fetchone()["n"] == task["revision"]
def test_concurrent_same_request_and_revision_conflict(sample):
store, who = sample
cmd = command()
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(lambda _: store.create_task(who, cmd), range(4)))
assert len({r["task_id"] for r in results}) == 1
task = results[0]
def race(_):
try:
return move(store, who, task, "processing")["revision"]
except SampleFailure as exc:
return exc.code
with ThreadPoolExecutor(max_workers=2) as pool:
outcomes = list(pool.map(race, range(2)))
assert sorted(map(str, outcomes)) == ["2", "REVISION_CONFLICT"]
def test_fault_receipt_recovery_duplicate_conflict_and_late_state(sample):
store, who = sample
scenario(store, who, "response_lost")
cmd = command()
with pytest.raises(SampleFailure) as exc:
store.create_task(who, cmd)
assert exc.value.effect == "unknown"
task = store.receipt(who, cmd.request_id)["result"]
assert store.create_task(who, cmd) == task
scenario(store, who, "unavailable")
with pytest.raises(SampleFailure):
store.get_task(who, task["task_id"])
assert store.receipt(who, cmd.request_id)["task_id"] == task["task_id"]
scenario(store, who, "conflicting_feedback")
task = move(store, who, task, "processing")
task = store.add_feedback(who, task["task_id"], feedback(task))
assert {f["finding"] for f in task["feedback"]} == {"instrument_fault", "no_instrument_fault"}
scenario(store, who, "duplicate_feedback")
assert len(store.get_task(who, task["task_id"])["feedback"]) == 3
scenario(store, who, "late_state")
assert store.get_task(who, task["task_id"])["revision"] == task["revision"] - 1
scenario(store, who, "normal")
assert store.get_task(who, task["task_id"]) == task
disabled = PostgresStore(SampleSettings(enable_faults=False))
with pytest.raises(SampleFailure) as exc:
scenario(disabled, who, "missing_data")
assert exc.value.code == "FAULTS_DISABLED"
def test_historical_monitoring_readonly_mapping_and_missing_data(sample):
store, who = sample
points = store.points(who, limit=500)
assert points["origin"] == "historical_replay"
assert points["items"]
point = points["items"][0]
kwargs = {"point_id": point["point_id"], "start": "2026-04-30T08:00:00Z", "end": "2026-04-30T16:00:00Z"}
normal = store.observations(who, **kwargs)
Observations.model_validate(normal)
assert normal["items"]
assert {x["unit"] for x in normal["items"]} == {point["unit"]}
with store.connection(readonly=True, timeseries=True) as conn:
assert conn.execute("SHOW transaction_read_only").fetchone()["transaction_read_only"] == "on"
scenario(store, who, "missing_data")
missing = store.observations(who, **kwargs)
assert missing["origin"] == "synthetic"
assert all(item["value"] is None for item in missing["items"][::2])
assert store.alerts(who)["availability"] == "not_configured"
def test_managed_work_orders_and_test_data_persist_revision_and_isolation(sample):
store, who = sample
capabilities = store.managed_resource_capabilities()
if capabilities != {"work_orders": "available", "test_data": "available"}:
pytest.skip("managed peripheral resource tables are not provisioned")
order_command = CreateWorkOrder(
request_id=uuid4(),
title="演示 live test work order",
summary="AUTOTEST isolated synthetic resource",
status="open",
priority="high",
assignee="AUTOTEST",
scheduled_at="2026-09-18T01:00:00Z",
work_order_type="water_quality_complaint",
location="AUTOTEST 演示小区",
scenario_code="WQ-SECONDARY-SUPPLY-001",
)
order = store.create_work_order(who, order_command)
WorkOrder.model_validate(order)
assert order["work_order_type"] == "water_quality_complaint"
assert PostgresStore(store.settings).get_work_order(who, order["work_order_id"]) == order
assert store.create_work_order(who, order_command) == order
order = store.update_work_order(who, order["work_order_id"], UpdateWorkOrder(
request_id=uuid4(), expected_revision=1, status="in_progress", priority="urgent",
))
assert order["revision"] == 2 and order["status"] == "in_progress"
with pytest.raises(SampleFailure) as stale_order:
store.update_work_order(who, order["work_order_id"], UpdateWorkOrder(
request_id=uuid4(), expected_revision=1, status="completed",
))
assert stale_order.value.code == "REVISION_CONFLICT"
data_command = CreateTestDataRecord(
request_id=uuid4(),
label="演示 live pressure",
point_id="演示",
observed_at="2026-09-18T00:00:00Z",
value=31.2,
unit="m",
quality="valid",
notes="AUTOTEST synthetic only",
)
record = store.create_test_data(who, data_command)
peripheral_schemas.TestDataRecord.model_validate(record)
assert PostgresStore(store.settings).get_test_data(who, record["record_id"]) == record
assert store.create_test_data(who, data_command) == record
record = store.update_test_data(who, record["record_id"], UpdateTestDataRecord(
request_id=uuid4(), expected_revision=1, value=None, clear_value=True, quality="missing",
))
assert record["revision"] == 2 and record["value"] is None
outsider = Identity(str(uuid4()), who.user_id, True)
assert store.list_work_orders(outsider, 20)["items"] == []
assert store.list_test_data(outsider, 20)["items"] == []
assert [item["work_order_id"] for item in store.list_work_orders(who, 20)["items"]] == [
order["work_order_id"]
]
assert [item["record_id"] for item in store.list_test_data(who, 20)["items"]] == [
record["record_id"]
]
deleted_order = store.delete_work_order(who, order["work_order_id"], DeleteResource(
request_id=uuid4(), expected_revision=2,
))
deleted_data = store.delete_test_data(who, record["record_id"], DeleteResource(
request_id=uuid4(), expected_revision=2,
))
assert deleted_order["revision"] == 3 and deleted_data["revision"] == 3
assert store.list_work_orders(who, 20)["items"] == []
assert store.list_test_data(who, 20)["items"] == []
with store.connection(readonly=True) as conn:
order_revisions = conn.execute(
"""SELECT count(*) AS n FROM peripheral_sample.work_order_revisions
WHERE project_id=%s AND work_order_id=%s""",
(who.project_id, order["work_order_id"]),
).fetchone()["n"]
data_revisions = conn.execute(
"""SELECT count(*) AS n FROM peripheral_sample.test_data_record_revisions
WHERE project_id=%s AND record_id=%s""",
(who.project_id, record["record_id"]),
).fetchone()["n"]
assert order_revisions == 3 and data_revisions == 3
def test_managed_resource_http_routes_use_provisioned_storage(sample):
store, who = sample
capabilities = store.managed_resource_capabilities()
if capabilities != {"work_orders": "available", "test_data": "available"}:
pytest.skip("managed peripheral resource tables are not provisioned")
app = create_app(store.settings)
app.dependency_overrides[identity] = lambda: who
headers = {
"Authorization": "Bearer TEST-FIXTURE-ONLY",
"X-Project-Id": who.project_id,
}
with TestClient(app) as client:
order = client.post("/api/sample/v1/work-orders", headers=headers, json={
"request_id": str(uuid4()),
"title": "演示 HTTP test work order",
"summary": "AUTOTEST isolated synthetic resource",
"status": "open",
"priority": "normal",
"assignee": None,
"scheduled_at": None,
})
assert order.status_code == 201, order.text
order_document = order.json()
listed_orders = client.get("/api/sample/v1/work-orders", headers=headers)
assert [item["work_order_id"] for item in listed_orders.json()["items"]] == [
order_document["work_order_id"]
]
changed_order = client.patch(
f"/api/sample/v1/work-orders/{order_document['work_order_id']}",
headers=headers,
json={
"request_id": str(uuid4()), "expected_revision": 1,
"status": "in_progress",
},
)
assert changed_order.status_code == 200 and changed_order.json()["revision"] == 2
record = client.post("/api/sample/v1/test-data", headers=headers, json={
"request_id": str(uuid4()),
"label": "演示 HTTP pressure",
"point_id": "演示",
"observed_at": "2026-09-18T00:00:00Z",
"value": 31.2,
"unit": "m",
"quality": "valid",
"notes": "AUTOTEST synthetic only",
})
assert record.status_code == 201, record.text
record_document = record.json()
listed_data = client.get("/api/sample/v1/test-data", headers=headers)
assert [item["record_id"] for item in listed_data.json()["items"]] == [
record_document["record_id"]
]
changed_data = client.patch(
f"/api/sample/v1/test-data/{record_document['record_id']}",
headers=headers,
json={
"request_id": str(uuid4()), "expected_revision": 1,
"value": 30.8,
},
)
assert changed_data.status_code == 200 and changed_data.json()["revision"] == 2
deleted_order = client.request(
"DELETE",
f"/api/sample/v1/work-orders/{order_document['work_order_id']}",
headers=headers,
json={"request_id": str(uuid4()), "expected_revision": 2},
)
deleted_data = client.request(
"DELETE",
f"/api/sample/v1/test-data/{record_document['record_id']}",
headers=headers,
json={"request_id": str(uuid4()), "expected_revision": 2},
)
assert deleted_order.status_code == 200 and deleted_order.json()["revision"] == 3
assert deleted_data.status_code == 200 and deleted_data.json()["revision"] == 3