509 lines
18 KiB
Python
509 lines
18 KiB
Python
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"] == []
|