feat(workbench): add runtime GIS and sample integrations
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import asyncio
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.api.v1.endpoints import project_data
|
||||
|
||||
|
||||
def test_element_gis_properties_endpoint_returns_typed_groups(monkeypatch):
|
||||
async def list_element_groups(conn, element_type, element_id):
|
||||
assert conn is connection
|
||||
assert element_type == "pipes"
|
||||
assert element_id == "DEMO-P001"
|
||||
return [
|
||||
{
|
||||
"dataset_key": "fx_pipe_inventory_20200811",
|
||||
"dataset_title": "奉贤 DN100 及以上管网台账",
|
||||
"source_date": date(2020, 8, 11),
|
||||
"layer_key": "fx_20200811_dn100_pipes",
|
||||
"layer_name": "DN100 及以上管线台账",
|
||||
"source_fid": 117,
|
||||
"match_method": "node_pair",
|
||||
"match_distance_m": 0,
|
||||
"attributes": {"PIPE_D": 300},
|
||||
}
|
||||
]
|
||||
|
||||
connection = object()
|
||||
monkeypatch.setattr(
|
||||
project_data.GisAttributeRepository,
|
||||
"list_element_groups",
|
||||
list_element_groups,
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
project_data.get_element_gis_properties(
|
||||
element_type="pipes",
|
||||
element_id=" DEMO-P001 ",
|
||||
conn=connection,
|
||||
)
|
||||
)
|
||||
|
||||
assert result.element_id == "DEMO-P001"
|
||||
assert result.groups[0].attributes == {"PIPE_D": 300}
|
||||
|
||||
|
||||
def test_element_gis_properties_endpoint_rejects_blank_id():
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
asyncio.run(
|
||||
project_data.get_element_gis_properties(
|
||||
element_type="pipes",
|
||||
element_id=" ",
|
||||
conn=object(),
|
||||
)
|
||||
)
|
||||
|
||||
assert failure.value.status_code == 422
|
||||
|
||||
|
||||
def test_source_gis_feature_endpoint_returns_direct_source_group(monkeypatch):
|
||||
async def get_source_feature_group(conn, layer_key, source_fid):
|
||||
assert conn is connection
|
||||
assert layer_key == "fx_20200423_flow_meters"
|
||||
assert source_fid == 17
|
||||
return {
|
||||
"dataset_key": "fx_water_assets_20200423",
|
||||
"dataset_title": "奉贤全网除头桥 GIS 数据导出",
|
||||
"source_date": date(2020, 4, 23),
|
||||
"layer_key": layer_key,
|
||||
"layer_name": "流量仪",
|
||||
"source_fid": source_fid,
|
||||
"match_method": "source",
|
||||
"match_distance_m": None,
|
||||
"attributes": {"流量仪ID编码": "FM-17"},
|
||||
}
|
||||
|
||||
connection = object()
|
||||
monkeypatch.setattr(
|
||||
project_data.GisAttributeRepository,
|
||||
"get_source_feature_group",
|
||||
get_source_feature_group,
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
project_data.get_source_gis_feature_properties(
|
||||
layer_key=" fx_20200423_flow_meters ",
|
||||
source_fid=17,
|
||||
conn=connection,
|
||||
)
|
||||
)
|
||||
|
||||
assert result.layer_key == "fx_20200423_flow_meters"
|
||||
assert result.group.match_method == "source"
|
||||
assert result.group.attributes == {"流量仪ID编码": "FM-17"}
|
||||
|
||||
|
||||
def test_analysis_run_endpoint_forwards_page_bounds(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def list_runs(conn, *, limit, offset):
|
||||
captured.update(conn=conn, limit=limit, offset=offset)
|
||||
return [{"run_id": "run-1"}]
|
||||
|
||||
connection = object()
|
||||
monkeypatch.setattr(project_data.AnalysisRepository, "list_runs", list_runs)
|
||||
|
||||
result = asyncio.run(
|
||||
project_data.get_analysis_runs(limit=25, offset=50, conn=connection)
|
||||
)
|
||||
|
||||
assert captured == {"conn": connection, "limit": 25, "offset": 50}
|
||||
assert result == {
|
||||
"success": True,
|
||||
"data": [{"run_id": "run-1"}],
|
||||
"count": 1,
|
||||
"limit": 25,
|
||||
"offset": 50,
|
||||
}
|
||||
|
||||
|
||||
def test_analysis_endpoints_do_not_return_database_exception_text(monkeypatch):
|
||||
async def unavailable(*_args, **_kwargs):
|
||||
raise RuntimeError("postgresql://user:secret@database/internal")
|
||||
|
||||
monkeypatch.setattr(project_data.AnalysisRepository, "list_runs", unavailable)
|
||||
|
||||
with pytest.raises(HTTPException) as failure:
|
||||
asyncio.run(
|
||||
project_data.get_analysis_runs(limit=100, offset=0, conn=object())
|
||||
)
|
||||
|
||||
assert failure.value.status_code == 500
|
||||
assert failure.value.detail == "查询分析运行失败"
|
||||
assert "secret" not in failure.value.detail
|
||||
@@ -0,0 +1,508 @@
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.api.v1.endpoints import workbench_runtime
|
||||
from app.auth.project_dependencies import ProjectContext
|
||||
from app.infra.db.postgresql.workbench_operations import (
|
||||
WorkbenchOperationsAvailability,
|
||||
)
|
||||
from tests.conftest import build_test_app
|
||||
|
||||
|
||||
PROJECT_CONTEXT = ProjectContext(
|
||||
project_id=uuid4(),
|
||||
project_code="test-project",
|
||||
user_id=uuid4(),
|
||||
project_role="viewer",
|
||||
)
|
||||
|
||||
|
||||
def configure_dependencies(app):
|
||||
app.dependency_overrides[workbench_runtime.get_project_context] = lambda: PROJECT_CONTEXT
|
||||
app.dependency_overrides[workbench_runtime.get_project_pg_connection] = lambda: object()
|
||||
|
||||
|
||||
def test_alert_ingress_binds_producer_to_authenticated_project_principal(monkeypatch):
|
||||
app = build_test_app(workbench_runtime.router, "/api/v1")
|
||||
member = ProjectContext(
|
||||
project_id=uuid4(),
|
||||
project_code="test-project",
|
||||
user_id=uuid4(),
|
||||
project_role="member",
|
||||
)
|
||||
app.dependency_overrides[workbench_runtime.get_project_context] = lambda: member
|
||||
app.dependency_overrides[workbench_runtime.get_project_pg_connection] = lambda: object()
|
||||
captured = {}
|
||||
|
||||
class CapturingAlertService:
|
||||
def __init__(self, _repository, context):
|
||||
assert context == member
|
||||
|
||||
async def push(self, event):
|
||||
captured["event"] = event
|
||||
return event.model_dump(exclude={"event_id", "event_type"}) | {
|
||||
"revision": 1,
|
||||
"status": "active",
|
||||
"updated_at": datetime(2026, 9, 18, 2, 30, tzinfo=timezone.utc),
|
||||
}
|
||||
|
||||
monkeypatch.setattr(workbench_runtime, "TaskAlertService", CapturingAlertService)
|
||||
body = {
|
||||
"event_id": str(uuid4()),
|
||||
"alert_id": "schedule-1:occurrence-1",
|
||||
"event_type": "raised",
|
||||
"occurred_at": "2026-09-18T02:30:00Z",
|
||||
"condition_id": None,
|
||||
"source": {
|
||||
"task_type": "scheduled_task",
|
||||
"task_id": "schedule-1",
|
||||
"run_id": "occurrence-1",
|
||||
},
|
||||
"title": "定时任务失败",
|
||||
"description": "任务显式推送的告警。",
|
||||
"severity": "warning",
|
||||
}
|
||||
|
||||
response = TestClient(app).post("/api/v1/workbench/task-alert-events", json=body)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["producer_id"] == f"principal:{member.user_id}"
|
||||
assert captured["event"].producer_id == f"principal:{member.user_id}"
|
||||
spoofed = TestClient(app).post(
|
||||
"/api/v1/workbench/task-alert-events",
|
||||
json=body | {"producer_id": "investigation-scheduler"},
|
||||
)
|
||||
assert spoofed.status_code == 422
|
||||
|
||||
|
||||
def test_runtime_endpoint_returns_typed_empty_projection(monkeypatch):
|
||||
app = build_test_app(workbench_runtime.router, "/api/v1")
|
||||
configure_dependencies(app)
|
||||
|
||||
async def empty_runs(_conn, *, limit):
|
||||
assert limit == 100
|
||||
return []
|
||||
|
||||
async def no_operations(_conn):
|
||||
return WorkbenchOperationsAvailability(
|
||||
schedules=False,
|
||||
work_orders=False,
|
||||
alerts=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "list_recent_runs", empty_runs)
|
||||
monkeypatch.setattr(
|
||||
workbench_runtime.WorkbenchOperationsRepository,
|
||||
"availability",
|
||||
no_operations,
|
||||
)
|
||||
|
||||
response = TestClient(app).get("/api/v1/workbench/runtime")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"availability": "not_configured",
|
||||
"source": "none",
|
||||
"scenario_availability": "not_configured",
|
||||
"artifact_availability": "not_configured",
|
||||
"schedule_availability": "not_configured",
|
||||
"work_order_availability": "not_configured",
|
||||
"alert_availability": "not_configured",
|
||||
"generated_at": response.json()["generated_at"],
|
||||
"conditions": [],
|
||||
"alerts": [],
|
||||
"scenarios": [],
|
||||
"latest_analysis": None,
|
||||
"work_orders": [],
|
||||
}
|
||||
assert datetime.fromisoformat(response.json()["generated_at"]).tzinfo is timezone.utc
|
||||
|
||||
|
||||
def test_analysis_history_endpoint_returns_a_sanitized_bounded_page(monkeypatch):
|
||||
app = build_test_app(workbench_runtime.router, "/api/v1")
|
||||
configure_dependencies(app)
|
||||
now = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc)
|
||||
run_ids = [uuid4(), uuid4(), uuid4()]
|
||||
|
||||
async def analysis_runs(_conn, *, limit, offset):
|
||||
assert (limit, offset) == (3, 20)
|
||||
return [
|
||||
{
|
||||
"run_id": run_ids[index],
|
||||
"name": f"分析运行 {index + 1}",
|
||||
"run_type": "sensor_placement",
|
||||
"created_by": "alice",
|
||||
"created_at": now,
|
||||
"started_at": now,
|
||||
"status": "completed",
|
||||
"has_results": index != 1,
|
||||
"parameters": {"internal_secret": "must-not-leak"},
|
||||
}
|
||||
for index in range(3)
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
workbench_runtime.AnalysisRepository,
|
||||
"list_run_summaries",
|
||||
analysis_runs,
|
||||
)
|
||||
|
||||
response = TestClient(app).get(
|
||||
"/api/v1/workbench/analysis-runs?limit=2&offset=20"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["next_offset"] == 22
|
||||
assert [item["run_id"] for item in payload["items"]] == [
|
||||
str(run_ids[0]),
|
||||
str(run_ids[1]),
|
||||
]
|
||||
assert payload["items"][0]["run_type_label"] == "传感器布点"
|
||||
assert payload["items"][1]["has_results"] is False
|
||||
assert "internal_secret" not in response.text
|
||||
|
||||
|
||||
def test_runtime_endpoint_keeps_analysis_projection_out_of_conditions(monkeypatch):
|
||||
app = build_test_app(workbench_runtime.router, "/api/v1")
|
||||
configure_dependencies(app)
|
||||
run_id = uuid4()
|
||||
now = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc)
|
||||
|
||||
async def analysis_runs(_conn, *, limit):
|
||||
assert limit == 100
|
||||
return [{
|
||||
"run_id": run_id,
|
||||
"name": "北区爆管定位",
|
||||
"run_type": "burst_location",
|
||||
"created_by": "alice",
|
||||
"created_at": now,
|
||||
"started_at": now,
|
||||
"status": "completed",
|
||||
"parameters": {},
|
||||
}]
|
||||
|
||||
async def analysis_results(_conn, requested_run_id, *, limit):
|
||||
assert requested_run_id == run_id
|
||||
assert limit == 100
|
||||
return [{"result_type": "burst_location", "created_at": now}]
|
||||
|
||||
async def no_operations(_conn):
|
||||
return WorkbenchOperationsAvailability(
|
||||
schedules=False,
|
||||
work_orders=False,
|
||||
alerts=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "list_recent_runs", analysis_runs)
|
||||
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "list_recent_results", analysis_results)
|
||||
monkeypatch.setattr(
|
||||
workbench_runtime.WorkbenchOperationsRepository,
|
||||
"availability",
|
||||
no_operations,
|
||||
)
|
||||
|
||||
response = TestClient(app).get("/api/v1/workbench/runtime")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["availability"] == "not_configured"
|
||||
assert payload["source"] == "none"
|
||||
assert payload["conditions"] == []
|
||||
assert payload["scenarios"][0]["run_id"] == str(run_id)
|
||||
assert payload["latest_analysis"]["results"] == [{
|
||||
"result_type": "burst_location",
|
||||
"created_at": "2026-09-17T02:30:00Z",
|
||||
"facts": [],
|
||||
}]
|
||||
assert payload["latest_analysis"]["map_payload"] is None
|
||||
|
||||
|
||||
def test_analysis_run_endpoint_returns_the_requested_typed_projection(monkeypatch):
|
||||
app = build_test_app(workbench_runtime.router, "/api/v1")
|
||||
configure_dependencies(app)
|
||||
run_id = uuid4()
|
||||
now = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc)
|
||||
|
||||
async def analysis_run(_conn, requested_run_id):
|
||||
assert requested_run_id == run_id
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"name": "历史爆管定位",
|
||||
"run_type": "burst_location",
|
||||
"created_by": "alice",
|
||||
"created_at": now,
|
||||
"started_at": now,
|
||||
"status": "completed",
|
||||
"parameters": {},
|
||||
}
|
||||
|
||||
async def analysis_results(_conn, requested_run_id, *, limit):
|
||||
assert requested_run_id == run_id
|
||||
assert limit == 100
|
||||
return [{
|
||||
"result_type": "burst_location",
|
||||
"link_id": "P-42",
|
||||
"created_at": now,
|
||||
"payload": {
|
||||
"drawing_payload": {
|
||||
"type": "FeatureCollection",
|
||||
"features": [{
|
||||
"type": "Feature",
|
||||
"properties": {"label": "历史运行几何"},
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [121.4, 30.8],
|
||||
},
|
||||
}],
|
||||
},
|
||||
"internal_debug": "must-not-leak",
|
||||
},
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "get_run", analysis_run)
|
||||
monkeypatch.setattr(
|
||||
workbench_runtime.AnalysisRepository,
|
||||
"list_recent_results",
|
||||
analysis_results,
|
||||
)
|
||||
|
||||
response = TestClient(app).get(f"/api/v1/workbench/analysis-runs/{run_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["run_id"] == str(run_id)
|
||||
assert payload["name"] == "历史爆管定位"
|
||||
assert payload["results"][0]["facts"] == [
|
||||
{"label": "关联管段", "value": "P-42"}
|
||||
]
|
||||
assert payload["map_payload"]["features"][0]["properties"] == {
|
||||
"label": "历史运行几何"
|
||||
}
|
||||
assert "must-not-leak" not in response.text
|
||||
|
||||
|
||||
def test_analysis_run_endpoint_projects_sensor_locations_as_map_points(monkeypatch):
|
||||
app = build_test_app(workbench_runtime.router, "/api/v1")
|
||||
configure_dependencies(app)
|
||||
run_id = uuid4()
|
||||
now = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc)
|
||||
|
||||
async def analysis_run(_conn, requested_run_id):
|
||||
assert requested_run_id == run_id
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"name": "北区传感器布点",
|
||||
"run_type": "sensor_placement",
|
||||
"created_by": "alice",
|
||||
"created_at": now,
|
||||
"started_at": now,
|
||||
"status": "completed",
|
||||
"parameters": {},
|
||||
}
|
||||
|
||||
async def analysis_results(_conn, requested_run_id, *, limit):
|
||||
assert requested_run_id == run_id
|
||||
assert limit == 100
|
||||
return [{
|
||||
"result_type": "sensor_placement",
|
||||
"created_at": now,
|
||||
"payload": {
|
||||
"sensor_number": 2,
|
||||
"minimum_diameter": 300,
|
||||
"sensor_locations": ["J-2", "J-1"],
|
||||
"internal_debug": "must-not-leak",
|
||||
},
|
||||
}]
|
||||
|
||||
async def junction_points(_conn, node_ids):
|
||||
assert node_ids == ["J-2", "J-1"]
|
||||
return [
|
||||
{"node_id": "J-1", "longitude": 121.4, "latitude": 30.8},
|
||||
{"node_id": "J-2", "longitude": 121.5, "latitude": 30.9},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "get_run", analysis_run)
|
||||
monkeypatch.setattr(
|
||||
workbench_runtime.AnalysisRepository,
|
||||
"list_recent_results",
|
||||
analysis_results,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
workbench_runtime.AnalysisRepository,
|
||||
"list_junction_map_points",
|
||||
junction_points,
|
||||
)
|
||||
|
||||
response = TestClient(app).get(f"/api/v1/workbench/analysis-runs/{run_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert [
|
||||
feature["properties"]["node_id"]
|
||||
for feature in payload["map_payload"]["features"]
|
||||
] == ["J-2", "J-1"]
|
||||
assert payload["map_payload"]["features"][0] == {
|
||||
"type": "Feature",
|
||||
"id": "J-2",
|
||||
"properties": {
|
||||
"label": "监测点 1 · J-2",
|
||||
"node_id": "J-2",
|
||||
"result_type": "sensor_placement",
|
||||
"position": 1,
|
||||
},
|
||||
"geometry": {"type": "Point", "coordinates": [121.5, 30.9]},
|
||||
}
|
||||
assert "must-not-leak" not in response.text
|
||||
|
||||
|
||||
def test_analysis_run_endpoint_returns_404_for_an_unknown_run(monkeypatch):
|
||||
app = build_test_app(workbench_runtime.router, "/api/v1")
|
||||
configure_dependencies(app)
|
||||
|
||||
async def missing_run(_conn, _run_id):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "get_run", missing_run)
|
||||
|
||||
response = TestClient(app).get(
|
||||
f"/api/v1/workbench/analysis-runs/{uuid4()}"
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_runtime_endpoint_reads_work_order_and_alert_storage(monkeypatch):
|
||||
app = build_test_app(workbench_runtime.router, "/api/v1")
|
||||
configure_dependencies(app)
|
||||
now = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc)
|
||||
|
||||
async def empty_runs(_conn, *, limit):
|
||||
assert limit == 100
|
||||
return []
|
||||
|
||||
async def operations_available(_conn):
|
||||
return WorkbenchOperationsAvailability(
|
||||
schedules=False,
|
||||
work_orders=True,
|
||||
alerts=True,
|
||||
)
|
||||
|
||||
async def work_orders(_conn, project_id):
|
||||
assert project_id == str(PROJECT_CONTEXT.project_id)
|
||||
return [{
|
||||
"id": "wo-1",
|
||||
"code": "WO-1",
|
||||
"scheduled_at": now,
|
||||
"title": "阀门复核",
|
||||
"summary": "现场复核阀门。",
|
||||
"status": "completed",
|
||||
"risk_level": "normal",
|
||||
"updated_at": now,
|
||||
"detail": "正式工单同步记录。",
|
||||
"recommendation": None,
|
||||
"duration_minutes": 30,
|
||||
"source": "生产工单系统",
|
||||
"dispatcher": "调度中心",
|
||||
"priority": "normal",
|
||||
"subtasks": [{
|
||||
"id": "inspect-valve",
|
||||
"title": "阀门复核",
|
||||
"summary": "现场复核阀门。",
|
||||
"status": "completed",
|
||||
"scheduled_at": now,
|
||||
"location": "V-1",
|
||||
"assignee": "现场班组",
|
||||
"duration_minutes": 30,
|
||||
"reply_window_minutes": 15,
|
||||
"stages": [
|
||||
{"id": "dispatch", "sequence": 1, "title": "派发", "status": "done", "owner": "调度中心", "time_label": "10:30", "description": "已派发。"},
|
||||
{"id": "execution", "sequence": 2, "title": "执行", "status": "done", "owner": "现场班组", "time_label": "10:30—11:00", "description": "已执行。"},
|
||||
{"id": "reply", "sequence": 3, "title": "复令", "status": "done", "owner": "现场班组", "time_label": "11:00—11:15", "description": "已复令。"},
|
||||
],
|
||||
"reply_requirements": ["回传结果"],
|
||||
}],
|
||||
}]
|
||||
|
||||
async def alerts(_conn, project_id):
|
||||
assert project_id == str(PROJECT_CONTEXT.project_id)
|
||||
return [{
|
||||
"alert_id": "alarm-1",
|
||||
"condition_id": None,
|
||||
"source": {
|
||||
"task_type": "scheduled_task",
|
||||
"task_id": "diagnosis-5m",
|
||||
"run_id": "trigger-1",
|
||||
},
|
||||
"title": "泵站通信告警",
|
||||
"description": "由诊断任务明确推送。",
|
||||
"occurred_at": now,
|
||||
"severity": "warning",
|
||||
"status": "active",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "list_recent_runs", empty_runs)
|
||||
monkeypatch.setattr(workbench_runtime.WorkbenchOperationsRepository, "availability", operations_available)
|
||||
monkeypatch.setattr(workbench_runtime.WorkbenchOperationsRepository, "list_work_orders", work_orders)
|
||||
monkeypatch.setattr(workbench_runtime.WorkbenchOperationsRepository, "list_alerts", alerts)
|
||||
|
||||
response = TestClient(app).get("/api/v1/workbench/runtime")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["source"] == "none"
|
||||
assert payload["schedule_availability"] == "not_configured"
|
||||
assert payload["conditions"] == []
|
||||
assert payload["work_order_availability"] == "ready"
|
||||
assert payload["work_orders"][0]["code"] == "WO-1"
|
||||
assert payload["work_orders"][0]["status"] == "completed"
|
||||
assert payload["alert_availability"] == "ready"
|
||||
assert payload["alerts"][0]["title"] == "泵站通信告警"
|
||||
assert payload["alerts"][0]["source"] == {
|
||||
"task_type": "scheduled_task",
|
||||
"task_id": "diagnosis-5m",
|
||||
"run_id": "trigger-1",
|
||||
}
|
||||
|
||||
|
||||
def test_runtime_endpoint_isolates_invalid_work_order_documents(monkeypatch):
|
||||
app = build_test_app(workbench_runtime.router, "/api/v1")
|
||||
configure_dependencies(app)
|
||||
|
||||
async def empty_runs(_conn, *, limit):
|
||||
assert limit == 100
|
||||
return []
|
||||
|
||||
async def operations_available(_conn):
|
||||
return WorkbenchOperationsAvailability(
|
||||
schedules=False,
|
||||
work_orders=True,
|
||||
alerts=False,
|
||||
)
|
||||
|
||||
async def invalid_work_orders(_conn, _project_id):
|
||||
return [{"id": "wo-invalid", "subtasks": []}]
|
||||
|
||||
monkeypatch.setattr(workbench_runtime.AnalysisRepository, "list_recent_runs", empty_runs)
|
||||
monkeypatch.setattr(
|
||||
workbench_runtime.WorkbenchOperationsRepository,
|
||||
"availability",
|
||||
operations_available,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
workbench_runtime.WorkbenchOperationsRepository,
|
||||
"list_work_orders",
|
||||
invalid_work_orders,
|
||||
)
|
||||
|
||||
response = TestClient(app).get("/api/v1/workbench/runtime")
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["source"] == "none"
|
||||
assert payload["schedule_availability"] == "not_configured"
|
||||
assert payload["work_order_availability"] == "degraded"
|
||||
assert payload["availability"] == "degraded"
|
||||
assert payload["work_orders"] == []
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Opt-in persistence checks for the isolated generated demo dataset."""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from uuid import uuid4
|
||||
|
||||
import psycopg
|
||||
import pytest
|
||||
from psycopg.rows import dict_row
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.content_digest import digest
|
||||
from samples.peripheral.auth import Identity
|
||||
from samples.peripheral.config import SampleSettings
|
||||
from samples.peripheral.demo_data import build_demo_content
|
||||
from samples.peripheral.errors import SampleFailure
|
||||
from samples.peripheral.store import PostgresStore
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
os.getenv("PERIPHERAL_SAMPLE_LIVE_TEST") != "1",
|
||||
reason="set PERIPHERAL_SAMPLE_LIVE_TEST=1 to use provisioned peripheral_sample storage",
|
||||
)
|
||||
|
||||
|
||||
def connection():
|
||||
return psycopg.connect(
|
||||
host=settings.DB_HOST, port=settings.DB_PORT, user=settings.DB_USER,
|
||||
password=settings.DB_PASSWORD, dbname="tjwater_v2", connect_timeout=3,
|
||||
options="-c statement_timeout=10000", row_factory=dict_row,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def live_demo_project():
|
||||
project_id = uuid4()
|
||||
content = build_demo_content("live-" + str(project_id))
|
||||
revision = digest(content)
|
||||
with connection() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO peripheral_sample.demo_datasets
|
||||
(project_id,dataset_id,digest,document) VALUES (%s,'water-demo-v1',%s,%s)""",
|
||||
(project_id, revision, Jsonb(content)),
|
||||
)
|
||||
try:
|
||||
yield project_id, content, revision
|
||||
finally:
|
||||
with connection() as conn:
|
||||
conn.execute("DELETE FROM peripheral_sample.demo_datasets WHERE project_id=%s", (project_id,))
|
||||
conn.execute("DELETE FROM peripheral_sample.scenarios WHERE project_id=%s", (project_id,))
|
||||
|
||||
|
||||
def test_persisted_demo_is_read_from_fresh_connection_and_process(live_demo_project):
|
||||
project_id, content, revision = live_demo_project
|
||||
store = PostgresStore(SampleSettings(db_name="tjwater_v2"))
|
||||
result = store.demo_data(Identity(str(project_id), str(uuid4()), False))
|
||||
assert result["source_revision"] == revision
|
||||
assert result["observations"] == content["observations"]
|
||||
|
||||
script = """
|
||||
import sys
|
||||
from samples.peripheral.auth import Identity
|
||||
from samples.peripheral.config import SampleSettings
|
||||
from samples.peripheral.store import PostgresStore
|
||||
result = PostgresStore(SampleSettings(db_name='tjwater_v2')).demo_data(Identity(sys.argv[1], sys.argv[2], False))
|
||||
assert result['source_revision'] == sys.argv[3] and len(result['observations']) == 240
|
||||
print('ok')
|
||||
"""
|
||||
child = subprocess.run(
|
||||
[sys.executable, "-c", script, str(project_id), str(uuid4()), revision],
|
||||
check=True, capture_output=True, text=True,
|
||||
)
|
||||
assert child.stdout.strip() == "ok"
|
||||
|
||||
|
||||
def test_demo_read_is_project_isolated_and_never_calls_generator(live_demo_project, monkeypatch):
|
||||
project_id, _, _ = live_demo_project
|
||||
monkeypatch.setattr(
|
||||
"samples.peripheral.demo_data.build_demo_content",
|
||||
lambda *args, **kwargs: pytest.fail("persisted reads must not invoke the generator"),
|
||||
)
|
||||
store = PostgresStore(SampleSettings(db_name="tjwater_v2"))
|
||||
assert store.demo_data(Identity(str(project_id), str(uuid4()), False))["project_id"] == str(project_id)
|
||||
with pytest.raises(SampleFailure) as missing:
|
||||
store.demo_data(Identity(str(uuid4()), str(uuid4()), False))
|
||||
assert missing.value.code == "SOURCE_NOT_CONFIGURED"
|
||||
|
||||
|
||||
def test_tampered_document_is_rejected_and_cleanup_remains_scoped(live_demo_project):
|
||||
project_id, _, _ = live_demo_project
|
||||
with connection() as conn:
|
||||
conn.execute(
|
||||
"""UPDATE peripheral_sample.demo_datasets
|
||||
SET document=jsonb_set(document,'{seed}',to_jsonb(%s::text))
|
||||
WHERE project_id=%s AND dataset_id='water-demo-v1'""",
|
||||
("tampered", project_id),
|
||||
)
|
||||
store = PostgresStore(SampleSettings(db_name="tjwater_v2"))
|
||||
with pytest.raises(SampleFailure) as rejected:
|
||||
store.demo_data(Identity(str(project_id), str(uuid4()), False))
|
||||
assert rejected.value.code == "INTEGRITY_MISMATCH"
|
||||
@@ -0,0 +1,355 @@
|
||||
"""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",
|
||||
)
|
||||
order = store.create_work_order(who, order_command)
|
||||
WorkOrder.model_validate(order)
|
||||
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
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Opt-in PostgreSQL checks for the task alert event state machine."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from psycopg import AsyncConnection
|
||||
from psycopg.rows import dict_row
|
||||
|
||||
from app.core.config import settings
|
||||
from app.domain.schemas.task_alert import TaskAlertEvent
|
||||
from app.infra.db.postgresql.task_alerts import TaskAlertRepository
|
||||
from app.services.task_alerts import TaskAlertService
|
||||
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.skipif(
|
||||
os.getenv("TASK_ALERT_LIVE_TEST") != "1",
|
||||
reason="set TASK_ALERT_LIVE_TEST=1 to use provisioned workbench alert tables",
|
||||
),
|
||||
pytest.mark.anyio,
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_alert_event_round_trip_and_idempotency():
|
||||
project_id = uuid4()
|
||||
now = datetime(2026, 9, 18, 3, tzinfo=timezone.utc)
|
||||
conn = await AsyncConnection.connect(
|
||||
host=settings.DB_HOST,
|
||||
port=settings.DB_PORT,
|
||||
user=settings.DB_USER,
|
||||
password=settings.DB_PASSWORD,
|
||||
dbname="tjwater_v2",
|
||||
connect_timeout=3,
|
||||
options="-c statement_timeout=10000",
|
||||
row_factory=dict_row,
|
||||
)
|
||||
try:
|
||||
service = TaskAlertService(
|
||||
TaskAlertRepository(conn, project_id),
|
||||
None,
|
||||
clock=lambda: now,
|
||||
internal_producer_id="AUTOTEST-scheduler",
|
||||
)
|
||||
event = TaskAlertEvent(
|
||||
event_id=uuid4(),
|
||||
producer_id="AUTOTEST-scheduler",
|
||||
alert_id=f"AUTOTEST-{uuid4()}",
|
||||
event_type="raised",
|
||||
occurred_at=now,
|
||||
condition_id=None,
|
||||
source={
|
||||
"task_type": "scheduled_task",
|
||||
"task_id": "AUTOTEST-schedule",
|
||||
"run_id": "AUTOTEST-occurrence",
|
||||
},
|
||||
title="AUTOTEST alert",
|
||||
description="Synthetic integration record",
|
||||
severity="warning",
|
||||
)
|
||||
first = await service.push(event)
|
||||
repeated = await service.push(event)
|
||||
assert first == repeated
|
||||
row = await conn.execute(
|
||||
"""SELECT revision,status,document FROM workbench.alerts
|
||||
WHERE project_id=%s AND alert_id=%s""",
|
||||
(project_id, event.alert_id),
|
||||
)
|
||||
stored = await row.fetchone()
|
||||
assert stored["revision"] == 1 and stored["status"] == "active"
|
||||
assert stored["document"]["producer_id"] == "AUTOTEST-scheduler"
|
||||
finally:
|
||||
# Assertion reads open an implicit transaction. End it before starting the
|
||||
# cleanup transaction, otherwise closing the connection rolls the nested
|
||||
# cleanup savepoint back together with that read transaction.
|
||||
await conn.rollback()
|
||||
async with conn.transaction():
|
||||
await conn.execute(
|
||||
"DELETE FROM workbench.alert_events WHERE project_id=%s", (project_id,)
|
||||
)
|
||||
await conn.execute(
|
||||
"DELETE FROM workbench.alerts WHERE project_id=%s", (project_id,)
|
||||
)
|
||||
await conn.close()
|
||||
@@ -44,3 +44,34 @@ def test_project_workspace_sets_30_day_client_cache(monkeypatch):
|
||||
|
||||
assert layers == geoserver_client.PROJECT_LAYER_NAMES
|
||||
assert cache_updates == ["2592000"] * len(layers)
|
||||
|
||||
|
||||
def test_publish_feature_type_updates_existing_layer_and_cache(monkeypatch):
|
||||
requests: list[tuple[str, str]] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
requests.append((request.method, request.url.path))
|
||||
if request.method == "GET" and request.url.path.endswith("/featuretypes/demo_layer.json"):
|
||||
return httpx.Response(200, json={"featureType": {"name": "demo_layer"}})
|
||||
if request.method == "GET" and "/gwc/rest/layers/" in request.url.path:
|
||||
return httpx.Response(200, content=b"<GeoServerLayer><name>demo</name></GeoServerLayer>")
|
||||
return httpx.Response(200)
|
||||
|
||||
monkeypatch.setattr(geoserver_client.settings, "GEOSERVER_USERNAME", "admin")
|
||||
monkeypatch.setattr(geoserver_client.settings, "GEOSERVER_PASSWORD", "secret")
|
||||
monkeypatch.setattr(geoserver_client.settings, "GEOSERVER_CLIENT_CACHE_SECONDS", 2_592_000)
|
||||
http_client = httpx.Client(
|
||||
base_url="http://geoserver.example/geoserver",
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
geoserver_client.GeoServerAdminClient(client=http_client).publish_feature_type(
|
||||
workspace="demo",
|
||||
datastore="demo",
|
||||
layer="demo_layer",
|
||||
title="Demo layer",
|
||||
map_bbox=(1.0, 2.0, 3.0, 4.0),
|
||||
)
|
||||
|
||||
assert ("PUT", "/geoserver/rest/workspaces/demo/datastores/demo/featuretypes/demo_layer") in requests
|
||||
assert ("PUT", "/geoserver/gwc/rest/layers/demo:demo_layer.xml") in requests
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import asyncio
|
||||
from datetime import date
|
||||
|
||||
from app.infra.db.postgresql.gis_attributes import GisAttributeRepository
|
||||
from tests.conftest import FakeConnection, FakeCursor
|
||||
|
||||
|
||||
def test_list_element_groups_returns_empty_when_source_gis_is_not_provisioned():
|
||||
cursor = FakeCursor(fetchone_results=[{"relation": None}])
|
||||
|
||||
result = asyncio.run(
|
||||
GisAttributeRepository.list_element_groups(
|
||||
FakeConnection(cursor), "pipes", "P-1"
|
||||
)
|
||||
)
|
||||
|
||||
assert result == []
|
||||
assert len(cursor.executed) == 1
|
||||
|
||||
|
||||
def test_list_element_groups_reads_linked_attributes():
|
||||
row = {
|
||||
"dataset_key": "fx_pipe_inventory_20200811",
|
||||
"dataset_title": "奉贤 DN100 及以上管网台账",
|
||||
"source_date": date(2020, 8, 11),
|
||||
"layer_key": "fx_20200811_dn100_pipes",
|
||||
"layer_name": "DN100 及以上管线台账",
|
||||
"source_fid": 42,
|
||||
"match_method": "geometry",
|
||||
"match_distance_m": 0.12,
|
||||
"attributes": {"PIPE_D": 300, "MATERIAL": "DI"},
|
||||
}
|
||||
cursor = FakeCursor(
|
||||
fetchone_results=[{"relation": "source_gis.element_links"}],
|
||||
fetchall_results=[[row]],
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
GisAttributeRepository.list_element_groups(
|
||||
FakeConnection(cursor), "pipes", "P-42"
|
||||
)
|
||||
)
|
||||
|
||||
assert result == [row]
|
||||
assert cursor.executed[1][1] == ("pipes", "P-42")
|
||||
@@ -0,0 +1,154 @@
|
||||
import json
|
||||
import inspect
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.services.content_digest import digest
|
||||
from samples.peripheral import auth
|
||||
from samples.peripheral.api import create_app, store
|
||||
from samples.peripheral.config import SampleSettings
|
||||
from samples.peripheral.demo_data import VERSION, build_demo_content
|
||||
from samples.peripheral.errors import SampleFailure
|
||||
from samples.peripheral.store import PostgresStore
|
||||
|
||||
|
||||
START = datetime(2026, 9, 17, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_same_seed_start_and_version_are_stable_across_processes():
|
||||
content = build_demo_content("repeatable", START)
|
||||
assert content == build_demo_content("repeatable", START)
|
||||
assert content["generator_version"] == VERSION
|
||||
expected_digest = digest(content)
|
||||
script = """
|
||||
import json
|
||||
from datetime import datetime
|
||||
from app.services.content_digest import digest
|
||||
from samples.peripheral.demo_data import build_demo_content
|
||||
content = build_demo_content('repeatable', datetime.fromisoformat('2026-09-17T00:00:00+00:00'))
|
||||
print(json.dumps({'digest': digest(content), 'ids': [x['record_id'] for x in content['observations'][:3]]}))
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", script], check=True, capture_output=True, text=True,
|
||||
)
|
||||
child = json.loads(result.stdout)
|
||||
assert child["digest"] == expected_digest
|
||||
assert child["ids"] == [item["record_id"] for item in content["observations"][:3]]
|
||||
assert all(UUID(item["record_id"]).version == 5 for item in content["observations"])
|
||||
|
||||
|
||||
def test_default_canonical_digest_is_versioned_regression_anchor():
|
||||
content = build_demo_content()
|
||||
assert digest(content) == "sha256:c630f66ad0247f70a4afe79cc5f95f4be35d54acfe85fbfe39f3166ee09a57c5"
|
||||
assert (len(content["points"]), len(content["observations"]), len(content["device_states"])) == (5, 240, 9)
|
||||
|
||||
|
||||
def test_seed_changes_content_and_timezone_is_normalized():
|
||||
base = build_demo_content("seed-a", START)
|
||||
assert digest(base) != digest(build_demo_content("seed-b", START))
|
||||
same_in_china = build_demo_content("seed-a", START.astimezone(timezone(timedelta(hours=8))))
|
||||
assert same_in_china == base
|
||||
assert base["window"]["start"] == "2026-09-17T00:00:00Z"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seed", ["", "x" * 129, None, 7])
|
||||
def test_invalid_seed_is_rejected(seed):
|
||||
with pytest.raises(ValueError, match="seed"):
|
||||
build_demo_content(seed, START)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("start", [
|
||||
datetime(2026, 9, 17),
|
||||
datetime(2026, 9, 17, 0, 1, tzinfo=timezone.utc),
|
||||
datetime(2026, 9, 17, 0, 0, 1, tzinfo=timezone.utc),
|
||||
])
|
||||
def test_invalid_start_is_rejected(start):
|
||||
with pytest.raises(ValueError, match="timezone|aligned"):
|
||||
build_demo_content("seed", start)
|
||||
|
||||
|
||||
def test_story_counts_null_zero_and_temporal_links_are_explicit():
|
||||
content = build_demo_content("story", START)
|
||||
assert len(content["points"]) == 5
|
||||
assert len(content["observations"]) == 240
|
||||
assert len(content["device_states"]) == 9
|
||||
assert len(content["alerts"]) == 2
|
||||
assert len(content["operations"]) == 4
|
||||
|
||||
missing = [item for item in content["observations"] if item["value"] is None]
|
||||
zero = [item for item in content["observations"] if item["value"] == 0.0]
|
||||
assert len(missing) == 4 and all(item["quality"] == "missing" for item in missing)
|
||||
assert len(zero) == 4 and all(item["quality"] == "valid" for item in zero)
|
||||
|
||||
states = {(item["asset_id"], item["state"]): item["observed_at"] for item in content["device_states"]}
|
||||
communication = next(item for item in content["alerts"] if item["code"] == "communication_lost")
|
||||
assert communication["asset_id"] == "DEMO-P001"
|
||||
assert communication["occurred_at"] == states[("DEMO-P001", "offline")]
|
||||
assert communication["cleared_at"] == states[("DEMO-P001", "online")]
|
||||
operations = {(item["asset_id"], item["operation"]): item["occurred_at"] for item in content["operations"]}
|
||||
assert operations[("DEMO-PUMP-01", "pump_stop")] == states[("DEMO-PUMP-01", "stopped")]
|
||||
assert operations[("DEMO-VALVE-01", "valve_close")] == states[("DEMO-VALVE-01", "closed")]
|
||||
assert len({item["record_id"] for item in content["observations"] + content["device_states"] + content["operations"]}) == 253
|
||||
|
||||
|
||||
class FakeStore:
|
||||
def __init__(self, result=None, failure=None):
|
||||
self.result = result
|
||||
self.failure = failure
|
||||
self.reads = 0
|
||||
|
||||
def demo_data(self, who):
|
||||
self.reads += 1
|
||||
if self.failure:
|
||||
raise self.failure
|
||||
return {**self.result, "project_id": who.project_id,
|
||||
"source_id": "test:demo", "source_revision": digest(self.result)}
|
||||
|
||||
|
||||
def test_demo_endpoint_uses_next_auth_and_allows_read_only_permission(monkeypatch):
|
||||
project_id, user_id = uuid4(), uuid4()
|
||||
fake = FakeStore(build_demo_content("api", START))
|
||||
app = create_app(SampleSettings(project_code="tjwater_v2"))
|
||||
app.dependency_overrides[store] = lambda: fake
|
||||
monkeypatch.setattr(auth, "next_get", lambda request, path, params=None: {
|
||||
"project_id": str(project_id), "user_id": str(user_id), "network": "tjwater_v2",
|
||||
"permissions": ["simulation.view"],
|
||||
})
|
||||
response = TestClient(app).get("/api/sample/v1/demo-data", headers={
|
||||
"Authorization": "Bearer TEST", "X-Project-Id": str(project_id),
|
||||
})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["project_id"] == str(project_id)
|
||||
assert fake.reads == 1
|
||||
|
||||
|
||||
def test_demo_endpoint_requires_auth_and_reports_missing_storage_without_seeding():
|
||||
fake = FakeStore(failure=SampleFailure(
|
||||
"STORAGE_NOT_CONFIGURED", "样板存储表尚未配置", 503,
|
||||
))
|
||||
app = create_app(SampleSettings(project_code="tjwater_v2"))
|
||||
app.dependency_overrides[store] = lambda: fake
|
||||
client = TestClient(app)
|
||||
unauthorized = client.get("/api/sample/v1/demo-data")
|
||||
assert unauthorized.status_code == 401
|
||||
assert fake.reads == 0
|
||||
# Authentication is deliberately isolated here so this assertion tests the
|
||||
# missing-table behavior: the API reports it and never invokes a generator.
|
||||
from samples.peripheral.api import identity
|
||||
from samples.peripheral.auth import Identity
|
||||
app.dependency_overrides[identity] = lambda: Identity(str(uuid4()), str(uuid4()), False)
|
||||
unavailable = client.get("/api/sample/v1/demo-data")
|
||||
assert unavailable.status_code == 503
|
||||
assert unavailable.json()["code"] == "STORAGE_NOT_CONFIGURED"
|
||||
assert fake.reads == 1
|
||||
|
||||
|
||||
def test_demo_read_path_contains_no_schema_or_seed_writes():
|
||||
source = inspect.getsource(PostgresStore.demo_data).upper()
|
||||
assert "SELECT " in source
|
||||
assert all(keyword not in source for keyword in ("CREATE ", "ALTER ", "INSERT ", "UPDATE ", "DELETE "))
|
||||
@@ -0,0 +1,129 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from samples.peripheral.api import create_app, store
|
||||
from samples.peripheral.auth import Identity, identity
|
||||
from samples.peripheral.schemas import CreateTestDataRecord, UpdateTestDataRecord, UpdateWorkOrder
|
||||
|
||||
|
||||
PROJECT_ID = uuid4()
|
||||
USER_ID = uuid4()
|
||||
|
||||
|
||||
class ManagedStore:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def list_work_orders(self, who, limit, cursor):
|
||||
self.calls.append(("list_work_orders", who, limit, cursor))
|
||||
return {"items": [], "next_cursor": None}
|
||||
|
||||
def create_work_order(self, who, command):
|
||||
self.calls.append(("create_work_order", who, command))
|
||||
now = "2026-09-18T00:00:00Z"
|
||||
return command.model_dump(mode="json", exclude={"request_id"}) | {
|
||||
"work_order_id": str(uuid4()), "project_id": str(PROJECT_ID), "revision": 1,
|
||||
"origin": "synthetic", "created_at": now, "updated_at": now,
|
||||
"created_by": str(USER_ID), "updated_by": str(USER_ID),
|
||||
}
|
||||
|
||||
def update_work_order(self, who, work_order_id, command):
|
||||
self.calls.append(("update_work_order", who, work_order_id, command))
|
||||
return self.create_work_order(who, type("Command", (), {"model_dump": lambda self, **kwargs: {
|
||||
"title": command.title or "updated", "summary": command.summary or "summary",
|
||||
"status": command.status or "open", "priority": command.priority or "normal",
|
||||
"assignee": None, "scheduled_at": None}})()) | {"revision": command.expected_revision + 1,
|
||||
"work_order_id": str(work_order_id)}
|
||||
|
||||
def delete_work_order(self, who, work_order_id, command):
|
||||
self.calls.append(("delete_work_order", who, work_order_id, command))
|
||||
return {"resource_type": "work_order", "resource_id": str(work_order_id),
|
||||
"revision": command.expected_revision + 1, "deleted": True}
|
||||
|
||||
def list_test_data(self, who, limit, cursor):
|
||||
self.calls.append(("list_test_data", who, limit, cursor))
|
||||
return {"items": [], "next_cursor": None}
|
||||
|
||||
def create_test_data(self, who, command):
|
||||
self.calls.append(("create_test_data", who, command))
|
||||
now = "2026-09-18T00:00:00Z"
|
||||
return command.model_dump(mode="json", exclude={"request_id"}) | {
|
||||
"record_id": str(uuid4()), "project_id": str(PROJECT_ID), "revision": 1,
|
||||
"origin": "synthetic", "created_at": now, "updated_at": now,
|
||||
"created_by": str(USER_ID), "updated_by": str(USER_ID),
|
||||
}
|
||||
|
||||
def update_test_data(self, who, record_id, command):
|
||||
self.calls.append(("update_test_data", who, record_id, command))
|
||||
return self.create_test_data(who, CreateTestDataRecord(
|
||||
request_id=command.request_id, label=command.label or "updated", point_id=command.point_id or "演示",
|
||||
observed_at=command.observed_at or "2026-09-18T00:00:00Z", value=command.value,
|
||||
unit=command.unit or "m", quality=command.quality or "valid", notes=command.notes or "",
|
||||
)) | {"revision": command.expected_revision + 1, "record_id": str(record_id)}
|
||||
|
||||
def delete_test_data(self, who, record_id, command):
|
||||
self.calls.append(("delete_test_data", who, record_id, command))
|
||||
return {"resource_type": "test_data_record", "resource_id": str(record_id),
|
||||
"revision": command.expected_revision + 1, "deleted": True}
|
||||
|
||||
|
||||
def client_with(fake):
|
||||
app = create_app()
|
||||
app.dependency_overrides[store] = lambda: fake
|
||||
app.dependency_overrides[identity] = lambda: Identity(str(PROJECT_ID), str(USER_ID), True)
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_managed_work_order_and_data_routes_are_typed_crud():
|
||||
fake = ManagedStore()
|
||||
with client_with(fake) as client:
|
||||
assert client.get("/api/sample/v1/work-orders").json()["items"] == []
|
||||
created = client.post("/api/sample/v1/work-orders", json={
|
||||
"request_id": str(uuid4()), "title": "演示 测试工单", "summary": "外围联调",
|
||||
"status": "open", "priority": "high", "assignee": None, "scheduled_at": None,
|
||||
})
|
||||
assert created.status_code == 201 and created.json()["origin"] == "synthetic"
|
||||
work_order_id = created.json()["work_order_id"]
|
||||
changed = client.patch(f"/api/sample/v1/work-orders/{work_order_id}", json={
|
||||
"request_id": str(uuid4()), "expected_revision": 1, "status": "in_progress",
|
||||
"clear_assignee": False, "clear_scheduled_at": False,
|
||||
})
|
||||
assert changed.status_code == 200 and changed.json()["revision"] == 2
|
||||
deleted = client.request("DELETE", f"/api/sample/v1/work-orders/{work_order_id}", json={
|
||||
"request_id": str(uuid4()), "expected_revision": 2,
|
||||
})
|
||||
assert deleted.json()["resource_type"] == "work_order"
|
||||
|
||||
assert client.get("/api/sample/v1/test-data").json()["items"] == []
|
||||
data = client.post("/api/sample/v1/test-data", json={
|
||||
"request_id": str(uuid4()), "label": "演示 压力", "point_id": "演示",
|
||||
"observed_at": "2026-09-18T00:00:00Z", "value": 31.2, "unit": "m",
|
||||
"quality": "valid", "notes": "联调",
|
||||
})
|
||||
assert data.status_code == 201 and data.json()["point_id"] == "演示"
|
||||
record_id = data.json()["record_id"]
|
||||
changed_data = client.patch(f"/api/sample/v1/test-data/{record_id}", json={
|
||||
"request_id": str(uuid4()), "expected_revision": 1, "value": 30.8, "clear_value": False,
|
||||
})
|
||||
assert changed_data.status_code == 200 and changed_data.json()["revision"] == 2
|
||||
deleted_data = client.request("DELETE", f"/api/sample/v1/test-data/{record_id}", json={
|
||||
"request_id": str(uuid4()), "expected_revision": 2,
|
||||
})
|
||||
assert deleted_data.json()["resource_type"] == "test_data_record"
|
||||
|
||||
|
||||
def test_managed_update_models_reject_empty_or_conflicting_changes():
|
||||
request_id = uuid4()
|
||||
for model, payload in (
|
||||
(UpdateWorkOrder, {"request_id": request_id, "expected_revision": 1}),
|
||||
(UpdateTestDataRecord, {"request_id": request_id, "expected_revision": 1}),
|
||||
(UpdateTestDataRecord, {"request_id": request_id, "expected_revision": 1,
|
||||
"value": 1.0, "clear_value": True}),
|
||||
):
|
||||
try:
|
||||
model.model_validate(payload)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("invalid partial update was accepted")
|
||||
@@ -40,7 +40,7 @@ def test_list_results_uses_typed_comparison_when_result_type_is_present():
|
||||
query, params = conn.cursor_instance.calls[0]
|
||||
assert "result_type = %s" in query
|
||||
assert "%s IS NULL" not in query
|
||||
assert params == (run_id, "leakage_identification")
|
||||
assert params == (run_id, "leakage_identification", 100, 0)
|
||||
|
||||
|
||||
def test_list_results_omits_result_type_filter_when_not_requested():
|
||||
@@ -51,4 +51,56 @@ def test_list_results_omits_result_type_filter_when_not_requested():
|
||||
|
||||
query, params = conn.cursor_instance.calls[0]
|
||||
assert "result_type = %s" not in query
|
||||
assert params == (run_id,)
|
||||
assert params == (run_id, 100, 0)
|
||||
|
||||
|
||||
def test_list_runs_and_results_apply_explicit_page_bounds():
|
||||
conn = _FakeConnection()
|
||||
run_id = uuid4()
|
||||
|
||||
asyncio.run(AnalysisRepository.list_runs(conn, limit=25, offset=50))
|
||||
asyncio.run(
|
||||
AnalysisRepository.list_results(conn, run_id, limit=40, offset=80)
|
||||
)
|
||||
|
||||
run_query, run_params = conn.cursor_instance.calls[0]
|
||||
result_query, result_params = conn.cursor_instance.calls[1]
|
||||
assert "LIMIT %s OFFSET %s" in run_query
|
||||
assert run_params == (25, 50)
|
||||
assert "LIMIT %s OFFSET %s" in result_query
|
||||
assert result_params == (run_id, 40, 80)
|
||||
|
||||
|
||||
def test_list_recent_runs_is_bounded_and_orders_newest_first():
|
||||
conn = _FakeConnection()
|
||||
|
||||
asyncio.run(AnalysisRepository.list_recent_runs(conn, limit=25))
|
||||
|
||||
query, params = conn.cursor_instance.calls[0]
|
||||
assert "ORDER BY created_at DESC, run_id" in query
|
||||
assert "LIMIT %s" in query
|
||||
assert params == (25,)
|
||||
|
||||
|
||||
def test_list_recent_results_bounds_latest_rows_then_restores_display_order():
|
||||
conn = _FakeConnection()
|
||||
run_id = uuid4()
|
||||
|
||||
asyncio.run(AnalysisRepository.list_recent_results(conn, run_id, limit=25))
|
||||
|
||||
query, params = conn.cursor_instance.calls[0]
|
||||
assert "ORDER BY created_at DESC, result_id DESC" in query
|
||||
assert "ORDER BY created_at, result_id" in query
|
||||
assert params == (run_id, 25)
|
||||
|
||||
|
||||
def test_list_run_summaries_is_bounded_and_checks_for_results():
|
||||
conn = _FakeConnection()
|
||||
|
||||
asyncio.run(AnalysisRepository.list_run_summaries(conn, limit=11, offset=20))
|
||||
|
||||
query, params = conn.cursor_instance.calls[0]
|
||||
assert "EXISTS" in query
|
||||
assert "analysis.results" in query
|
||||
assert "ORDER BY r.created_at DESC, r.run_id" in query
|
||||
assert params == (11, 20)
|
||||
|
||||
@@ -159,9 +159,13 @@ def test_create_sensor_placement_writes_run_and_result_atomically(monkeypatch):
|
||||
min_diameter=300,
|
||||
created_by="alice",
|
||||
sensor_locations=["J1", "J2"],
|
||||
sensor_type="water_quality",
|
||||
method="sensitivity",
|
||||
)
|
||||
|
||||
assert created["sensor_count"] == 2
|
||||
assert created["sensor_type"] == "water_quality"
|
||||
assert created["method"] == "sensitivity"
|
||||
statements = [call.args[0] for call in cursor.execute.call_args_list]
|
||||
assert "INSERT INTO analysis.runs" in statements[0]
|
||||
assert "INSERT INTO analysis.results" in statements[1]
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app.domain.domain_errors import DomainError
|
||||
from app.domain.schemas.task_alert import TaskAlertEvent
|
||||
from app.services.task_alerts import TaskAlertService
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 18, 2, 30, tzinfo=timezone.utc)
|
||||
USER_ID = uuid4()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
class Repository:
|
||||
def __init__(self):
|
||||
self.events = {}
|
||||
self.alerts = {}
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self):
|
||||
yield self
|
||||
|
||||
async def lock_alert(self, _alert_id):
|
||||
return None
|
||||
|
||||
async def get_event(self, producer_id, event_id):
|
||||
return deepcopy(self.events.get((producer_id, event_id)))
|
||||
|
||||
async def get_alert(self, alert_id):
|
||||
value = self.alerts.get(alert_id)
|
||||
return (value["revision"], deepcopy(value["document"])) if value else None
|
||||
|
||||
async def save_event(self, event, resulting_state):
|
||||
self.events[(event["producer_id"], event["event_id"])] = deepcopy(
|
||||
event | {"resulting_state": resulting_state}
|
||||
)
|
||||
|
||||
async def save_alert(self, state):
|
||||
self.alerts[state["alert_id"]] = {
|
||||
"revision": state["revision"],
|
||||
"document": deepcopy({key: value for key, value in state.items() if key != "revision"}),
|
||||
}
|
||||
|
||||
|
||||
def command(
|
||||
event_type="raised",
|
||||
*,
|
||||
event_id=None,
|
||||
title="Low pressure",
|
||||
producer_id=f"principal:{USER_ID}",
|
||||
):
|
||||
return TaskAlertEvent(
|
||||
event_id=event_id or uuid4(),
|
||||
producer_id=producer_id,
|
||||
alert_id="schedule-1:2026-09-18T02:30:00Z",
|
||||
event_type=event_type,
|
||||
occurred_at=NOW,
|
||||
condition_id=None,
|
||||
source={
|
||||
"task_type": "scheduled_task",
|
||||
"task_id": "schedule-1",
|
||||
"run_id": "occurrence-1",
|
||||
},
|
||||
title=title,
|
||||
description="Scheduled task reported a failure.",
|
||||
severity="warning",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_alert_events_are_idempotent_and_transitions_are_monotonic():
|
||||
repository = Repository()
|
||||
service = TaskAlertService(
|
||||
repository,
|
||||
SimpleNamespace(project_role="member", user_id=USER_ID),
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
raised_command = command()
|
||||
raised = await service.push(raised_command)
|
||||
repeated = await service.push(raised_command)
|
||||
assert raised.status == repeated.status == "active"
|
||||
assert raised.revision == repeated.revision == 1
|
||||
|
||||
acknowledged = await service.push(command("acknowledged"))
|
||||
resolved = await service.push(command("resolved"))
|
||||
assert acknowledged.status == "acknowledged" and acknowledged.revision == 2
|
||||
assert resolved.status == "resolved" and resolved.revision == 3
|
||||
|
||||
with pytest.raises(DomainError) as invalid:
|
||||
await service.push(command("acknowledged"))
|
||||
assert invalid.value.code == "INVALID_TRANSITION"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_alert_event_reuse_and_fact_rewrites_are_rejected():
|
||||
repository = Repository()
|
||||
service = TaskAlertService(
|
||||
repository,
|
||||
SimpleNamespace(project_role="member", user_id=USER_ID),
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
event_id = uuid4()
|
||||
await service.push(command(event_id=event_id))
|
||||
|
||||
with pytest.raises(DomainError) as reused:
|
||||
await service.push(command(event_id=event_id, title="Changed"))
|
||||
assert reused.value.code == "IDEMPOTENCY_CONFLICT"
|
||||
|
||||
with pytest.raises(DomainError) as rewritten:
|
||||
await service.push(command("resolved", title="Changed"))
|
||||
assert rewritten.value.code == "IDEMPOTENCY_CONFLICT"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_internal_alert_producer_is_bound_without_user_membership():
|
||||
repository = Repository()
|
||||
service = TaskAlertService(
|
||||
repository,
|
||||
None,
|
||||
clock=lambda: NOW,
|
||||
internal_producer_id="scheduler:demo",
|
||||
)
|
||||
|
||||
state = await service.push(command(producer_id="scheduler:demo"))
|
||||
assert state.status == "active"
|
||||
|
||||
mismatched = command(producer_id="scheduler:demo")
|
||||
mismatched.producer_id = "another-task"
|
||||
with pytest.raises(DomainError) as denied:
|
||||
await service.push(mismatched)
|
||||
assert denied.value.code == "PERMISSION_DENIED"
|
||||
@@ -0,0 +1,284 @@
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.domain.schemas.workbench_runtime import WorkbenchPersistedAlert
|
||||
|
||||
from app.services.workbench_runtime import (
|
||||
build_workbench_runtime_snapshot,
|
||||
project_latest_analysis,
|
||||
project_sensor_location_map_payload,
|
||||
sensor_location_ids_for_map_projection,
|
||||
)
|
||||
|
||||
NOW = datetime(2026, 9, 17, 2, 30, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend():
|
||||
return "asyncio"
|
||||
|
||||
|
||||
def test_workbench_alert_rejects_naive_datetimes():
|
||||
with pytest.raises(ValidationError):
|
||||
WorkbenchPersistedAlert.model_validate({
|
||||
"alert_id": "alert-1",
|
||||
"condition_id": None,
|
||||
"source": {
|
||||
"task_type": "scheduled_task",
|
||||
"task_id": "schedule-1",
|
||||
"run_id": "occurrence-1",
|
||||
},
|
||||
"title": "test",
|
||||
"description": "test",
|
||||
"occurred_at": "2026-09-18T02:30:00",
|
||||
"severity": "warning",
|
||||
"status": "active",
|
||||
})
|
||||
@pytest.mark.anyio
|
||||
async def test_snapshot_reports_not_configured_without_persisted_sections():
|
||||
snapshot = await build_workbench_runtime_snapshot(
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
|
||||
assert snapshot == {
|
||||
"availability": "not_configured",
|
||||
"source": "none",
|
||||
"scenario_availability": "not_configured",
|
||||
"artifact_availability": "not_configured",
|
||||
"schedule_availability": "not_configured",
|
||||
"work_order_availability": "not_configured",
|
||||
"alert_availability": "not_configured",
|
||||
"generated_at": NOW,
|
||||
"conditions": [],
|
||||
"alerts": [],
|
||||
"scenarios": [],
|
||||
"latest_analysis": None,
|
||||
"work_orders": [],
|
||||
}
|
||||
|
||||
|
||||
def analysis_run(**overrides):
|
||||
value = {
|
||||
"run_id": uuid4(),
|
||||
"name": "北区爆管定位",
|
||||
"run_type": "burst_location",
|
||||
"created_by": "alice",
|
||||
"created_at": NOW,
|
||||
"started_at": NOW,
|
||||
"status": "completed",
|
||||
"parameters": {},
|
||||
}
|
||||
value.update(overrides)
|
||||
return value
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_snapshot_keeps_analysis_results_out_of_operational_conditions():
|
||||
failed_analysis = analysis_run(status="failed")
|
||||
result_created_at = NOW.replace(minute=31)
|
||||
|
||||
snapshot = await build_workbench_runtime_snapshot(
|
||||
analysis_runs=[failed_analysis],
|
||||
latest_analysis_results=[
|
||||
{
|
||||
"result_type": "burst_location",
|
||||
"created_at": result_created_at,
|
||||
"payload": {
|
||||
"drawing_payload": {
|
||||
"type": "FeatureCollection",
|
||||
"features": [{
|
||||
"type": "Feature",
|
||||
"properties": {"label": "后端结果"},
|
||||
"geometry": {
|
||||
"type": "Point",
|
||||
"coordinates": [121.5, 30.9],
|
||||
},
|
||||
}],
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
|
||||
assert snapshot["source"] == "none"
|
||||
assert snapshot["scenario_availability"] == "ready"
|
||||
assert snapshot["artifact_availability"] == "ready"
|
||||
assert snapshot["schedule_availability"] == "not_configured"
|
||||
assert snapshot["work_order_availability"] == "not_configured"
|
||||
assert snapshot["conditions"] == []
|
||||
assert snapshot["scenarios"] == [
|
||||
{
|
||||
"id": f"analysis-run-{failed_analysis['run_id']}",
|
||||
"run_id": str(failed_analysis["run_id"]),
|
||||
"name": "北区爆管定位",
|
||||
"description": "爆管定位 · 已失败 · 创建人 alice",
|
||||
"status": "review",
|
||||
"run_type": "burst_location",
|
||||
"run_type_label": "爆管定位",
|
||||
"run_status": "failed",
|
||||
"created_at": NOW,
|
||||
}
|
||||
]
|
||||
assert snapshot["latest_analysis"]["results"] == [
|
||||
{
|
||||
"result_type": "burst_location",
|
||||
"created_at": result_created_at,
|
||||
"facts": [],
|
||||
}
|
||||
]
|
||||
assert snapshot["latest_analysis"]["map_payload"]["features"][0]["properties"] == {
|
||||
"label": "后端结果"
|
||||
}
|
||||
assert snapshot["alerts"] == []
|
||||
|
||||
|
||||
def test_latest_analysis_projects_only_known_result_facts():
|
||||
projected = project_latest_analysis(
|
||||
analysis_run(run_type="sensor_placement"),
|
||||
[{
|
||||
"result_type": "sensor_placement",
|
||||
"node_id": "J-1",
|
||||
"created_at": NOW,
|
||||
"payload": {
|
||||
"sensor_number": 2,
|
||||
"minimum_diameter": 300,
|
||||
"sensor_locations": ["J-1", "J-2"],
|
||||
"internal_debug": "must-not-leak",
|
||||
},
|
||||
}],
|
||||
)
|
||||
|
||||
assert projected["results"][0]["facts"] == [
|
||||
{"label": "关联节点", "value": "J-1"},
|
||||
{"label": "测点数量", "value": "2"},
|
||||
{"label": "最小管径参数", "value": "300"},
|
||||
{"label": "测点位置", "value": "J-1、J-2"},
|
||||
]
|
||||
assert "must-not-leak" not in str(projected)
|
||||
|
||||
|
||||
def test_sensor_location_projection_uses_latest_result_order_and_known_nodes():
|
||||
results = [
|
||||
{
|
||||
"result_type": "sensor_placement",
|
||||
"payload": {"sensor_locations": ["J-old"]},
|
||||
},
|
||||
{
|
||||
"result_type": "sensor_placement",
|
||||
"payload": {"sensor_locations": ["J-2", "J-1", "J-2"]},
|
||||
},
|
||||
]
|
||||
|
||||
node_ids = sensor_location_ids_for_map_projection(results)
|
||||
payload = project_sensor_location_map_payload(node_ids, [
|
||||
{"node_id": "J-1", "longitude": 121.4, "latitude": 30.8},
|
||||
{"node_id": "J-2", "longitude": 121.5, "latitude": 30.9},
|
||||
])
|
||||
|
||||
assert node_ids == ["J-2", "J-1"]
|
||||
assert payload is not None
|
||||
assert [feature["id"] for feature in payload["features"]] == ["J-2", "J-1"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_snapshot_projects_persisted_work_orders_and_alerts():
|
||||
work_order = {
|
||||
"id": "wo-1",
|
||||
"code": "WO-20260917-001",
|
||||
"scheduled_at": NOW.replace(hour=3),
|
||||
"title": "北区阀门现场复核",
|
||||
"summary": "复核阀门开度并回传。",
|
||||
"status": "running",
|
||||
"risk_level": "attention",
|
||||
"updated_at": NOW,
|
||||
"detail": "来自正式工单同步适配器。",
|
||||
"recommendation": "完成后复核压力。",
|
||||
"duration_minutes": 45,
|
||||
"source": "生产工单系统",
|
||||
"dispatcher": "调度中心",
|
||||
"priority": "urgent",
|
||||
"subtasks": [{
|
||||
"id": "inspect-valve",
|
||||
"title": "阀门开度复核",
|
||||
"summary": "现场复核阀门开度。",
|
||||
"status": "running",
|
||||
"scheduled_at": NOW.replace(hour=3),
|
||||
"location": "北区阀门 V-1",
|
||||
"assignee": "北区班组",
|
||||
"duration_minutes": 45,
|
||||
"reply_window_minutes": 20,
|
||||
"stages": [
|
||||
{
|
||||
"id": "dispatch",
|
||||
"sequence": 1,
|
||||
"title": "派发",
|
||||
"status": "done",
|
||||
"owner": "调度中心",
|
||||
"time_label": "10:20",
|
||||
"description": "已派发。",
|
||||
},
|
||||
{
|
||||
"id": "execution",
|
||||
"sequence": 2,
|
||||
"title": "执行中",
|
||||
"status": "current",
|
||||
"owner": "北区班组",
|
||||
"time_label": "10:30—11:15",
|
||||
"description": "现场执行中。",
|
||||
},
|
||||
{
|
||||
"id": "reply",
|
||||
"sequence": 3,
|
||||
"title": "待复令",
|
||||
"status": "pending",
|
||||
"owner": "北区班组 → 调度中心",
|
||||
"time_label": "11:15—11:35",
|
||||
"description": "等待执行结果。",
|
||||
},
|
||||
],
|
||||
"reply_requirements": ["回传阀门开度", "复核压力"],
|
||||
}],
|
||||
}
|
||||
persisted_alert = {
|
||||
"alert_id": "alarm-1",
|
||||
"condition_id": None,
|
||||
"source": {
|
||||
"task_type": "scheduled_task",
|
||||
"task_id": "scada-diagnosis-5m",
|
||||
"run_id": "trigger-1",
|
||||
},
|
||||
"title": "北区设备通信告警",
|
||||
"description": "由 SCADA 数据诊断任务明确推送。",
|
||||
"occurred_at": NOW.replace(minute=29),
|
||||
"severity": "warning",
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
snapshot = await build_workbench_runtime_snapshot(
|
||||
work_order_storage_configured=True,
|
||||
work_orders=[work_order],
|
||||
alert_storage_configured=True,
|
||||
persisted_alerts=[persisted_alert],
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
|
||||
assert snapshot["availability"] == "ready"
|
||||
assert snapshot["source"] == "none"
|
||||
assert snapshot["schedule_availability"] == "not_configured"
|
||||
assert snapshot["work_order_availability"] == "ready"
|
||||
assert snapshot["alert_availability"] == "ready"
|
||||
assert snapshot["conditions"] == []
|
||||
assert snapshot["alerts"] == [{
|
||||
"id": "backend-alert-alarm-1",
|
||||
"condition_id": None,
|
||||
"source": persisted_alert["source"],
|
||||
"title": "北区设备通信告警",
|
||||
"description": "由 SCADA 数据诊断任务明确推送。",
|
||||
"occurred_at": NOW.replace(minute=29),
|
||||
"severity": "warning",
|
||||
}]
|
||||
assert snapshot["work_orders"][0]["code"] == "WO-20260917-001"
|
||||
Reference in New Issue
Block a user