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"] == []