test_device_observability_api.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. from __future__ import annotations
  2. from datetime import UTC, datetime
  3. import pytest
  4. SOURCE_UID = "01900000-0000-7000-8000-000000000911"
  5. DEVICE_UID = "01900000-0000-7000-8000-000000000912"
  6. EVENT_UID = "01900000-0000-7000-8000-000000000913"
  7. FAULT_UID = "01900000-0000-7000-8000-000000000914"
  8. RELATION_UID = "01900000-0000-7000-8000-000000000915"
  9. class FakeDeviceObservabilityService:
  10. def __init__(self):
  11. from app.core.data_research.device_observability import (
  12. EvidenceRelationRecord,
  13. GraphNode,
  14. GraphProjection,
  15. OperationalEventRecord,
  16. RootCauseAnalysis,
  17. RootCauseCandidate,
  18. )
  19. now = datetime(2026, 7, 29, 11, tzinfo=UTC)
  20. self.event = OperationalEventRecord(
  21. uid=EVENT_UID,
  22. source_uid=SOURCE_UID,
  23. source_entity="downtime_events",
  24. source_code="DT-001",
  25. event_type="downtime",
  26. asset_uid=DEVICE_UID,
  27. component_uid=None,
  28. title="一号泵停机",
  29. severity="critical",
  30. status="resolved",
  31. occurred_at=now,
  32. ended_at=now,
  33. evidence_refs=(
  34. {"type": "source_evidence", "data": {"duration": 18}},
  35. ),
  36. content_hash="a" * 64,
  37. created_by="editor-1",
  38. created_at=now,
  39. )
  40. self.fault_node = GraphNode(
  41. kind="event",
  42. uid=FAULT_UID,
  43. node_type="fault",
  44. label="轴承故障",
  45. occurred_at=now,
  46. evidence_refs=(),
  47. )
  48. self.anchor_node = GraphNode(
  49. kind="event",
  50. uid=EVENT_UID,
  51. node_type="downtime",
  52. label="一号泵停机",
  53. occurred_at=now,
  54. evidence_refs=self.event.evidence_refs,
  55. )
  56. self.relation = EvidenceRelationRecord(
  57. uid=RELATION_UID,
  58. from_kind="event",
  59. from_uid=FAULT_UID,
  60. relation_type="triggered",
  61. to_kind="event",
  62. to_uid=EVENT_UID,
  63. evidence_refs=(
  64. {"type": "source_evidence", "data": {"work_order": "WO-1"}},
  65. ),
  66. source="imported",
  67. created_by="editor-1",
  68. created_at=now,
  69. )
  70. self.graph_result = GraphProjection(
  71. anchor_kind="event",
  72. anchor_uid=EVENT_UID,
  73. nodes=(self.anchor_node, self.fault_node),
  74. relations=(self.relation,),
  75. truncated=False,
  76. )
  77. self.analysis = RootCauseAnalysis(
  78. analysis_status="supported_candidates",
  79. conclusion="发现有证据支持的根因候选,仍需设备专家确认",
  80. anchor=self.anchor_node,
  81. candidates=(
  82. RootCauseCandidate(
  83. event_uid=FAULT_UID,
  84. event_type="fault",
  85. title="轴承故障",
  86. occurred_at=now,
  87. support_level="direct",
  88. path_node_uids=(FAULT_UID, EVENT_UID),
  89. path_relation_uids=(RELATION_UID,),
  90. path_relation_types=("triggered",),
  91. evidence_refs=self.relation.evidence_refs,
  92. ),
  93. ),
  94. graph=self.graph_result,
  95. limitations=("不等同于已确认根因",),
  96. generated_at=now,
  97. )
  98. self.actions = []
  99. def events(self, filters, *, page, page_size):
  100. self.actions.append(("events", filters, page, page_size))
  101. return [self.event], 1
  102. def import_evidence(self, payload, actor_uid):
  103. from app.core.data_research.device_observability import (
  104. ObservabilityImportResult,
  105. )
  106. self.actions.append(("import", payload, actor_uid))
  107. return ObservabilityImportResult(
  108. events=(self.event,),
  109. relations=(self.relation,),
  110. created_events=1,
  111. existing_events=0,
  112. created_relations=1,
  113. existing_relations=0,
  114. )
  115. def graph(self, anchor_kind, anchor_uid, *, max_hops):
  116. self.actions.append(("graph", anchor_kind, anchor_uid, max_hops))
  117. return self.graph_result
  118. def root_cause(self, anchor_kind, anchor_uid, *, max_hops):
  119. self.actions.append(
  120. ("root_cause", anchor_kind, anchor_uid, max_hops)
  121. )
  122. return self.analysis
  123. @pytest.fixture()
  124. def client(monkeypatch):
  125. from flask import request
  126. from app import create_app
  127. from app.api.data_development import routes
  128. from app.core.system import permissions
  129. service = FakeDeviceObservabilityService()
  130. def identity():
  131. role = request.headers.get(
  132. "Authorization", ""
  133. ).removeprefix("Bearer ")
  134. if role not in {"viewer", "editor", "admin"}:
  135. return None
  136. return {"id": f"{role}-1", "roles": [role]}
  137. monkeypatch.setattr(permissions, "authenticate_request", identity)
  138. monkeypatch.setattr(
  139. routes,
  140. "get_device_observability_service",
  141. lambda: service,
  142. raising=False,
  143. )
  144. app = create_app()
  145. app.config.update(TESTING=True)
  146. return app.test_client(), service
  147. def test_viewer_reads_events_graph_and_evidence_bound_analysis(client):
  148. http, service = client
  149. headers = {"Authorization": "Bearer viewer"}
  150. listing = http.get(
  151. "/api/development/v1/device-observability/events"
  152. f"?event_type=downtime&asset_uid={DEVICE_UID}&page=1&page_size=20",
  153. headers=headers,
  154. )
  155. graph = http.get(
  156. "/api/development/v1/device-observability/graph"
  157. f"?anchor_kind=event&anchor_uid={EVENT_UID}&max_hops=2",
  158. headers=headers,
  159. )
  160. analysis = http.get(
  161. "/api/development/v1/device-observability/root-cause"
  162. f"?anchor_kind=event&anchor_uid={EVENT_UID}&max_hops=3",
  163. headers=headers,
  164. )
  165. denied = http.post(
  166. "/api/development/v1/device-observability/import",
  167. headers=headers,
  168. json={"source_uid": SOURCE_UID, "events": [], "relations": []},
  169. )
  170. assert listing.status_code == 200
  171. assert listing.get_json()["data"]["records"][0]["event_type"] == (
  172. "downtime"
  173. )
  174. assert graph.status_code == 200
  175. assert graph.get_json()["data"]["relations"][0]["relation_type"] == (
  176. "triggered"
  177. )
  178. assert analysis.status_code == 200
  179. data = analysis.get_json()["data"]
  180. assert data["analysis_status"] == "supported_candidates"
  181. assert data["candidates"][0]["path_relation_types"] == ["triggered"]
  182. assert denied.status_code == 403
  183. assert service.actions[0][0] == "events"
  184. def test_editor_imports_bounded_operational_evidence(client):
  185. http, service = client
  186. response = http.post(
  187. "/api/development/v1/device-observability/import",
  188. headers={"Authorization": "Bearer editor"},
  189. json={
  190. "source_uid": SOURCE_UID,
  191. "events": [
  192. {
  193. "source_entity": "downtime_events",
  194. "source_code": "DT-001",
  195. "event_type": "downtime",
  196. "asset_uid": DEVICE_UID,
  197. "title": "一号泵停机",
  198. "severity": "critical",
  199. "status": "resolved",
  200. "occurred_at": "2026-07-29T11:00:00+00:00",
  201. "evidence": {"duration": 18},
  202. }
  203. ],
  204. "relations": [],
  205. },
  206. )
  207. assert response.status_code == 201
  208. assert response.get_json()["data"]["created_events"] == 1
  209. assert service.actions[-1][0] == "import"
  210. assert service.actions[-1][2] == "editor-1"