test_device_observability_postgres.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. from __future__ import annotations
  2. import os
  3. import uuid
  4. from datetime import UTC, datetime
  5. import pytest
  6. from sqlalchemy import text
  7. pytestmark = pytest.mark.integration
  8. def test_device_observability_persists_graph_and_root_cause(monkeypatch):
  9. platform_url = os.environ.get("TEST_DATABASE_URL")
  10. if not platform_url:
  11. pytest.skip("TEST_DATABASE_URL is required")
  12. monkeypatch.setenv("DATABASE_URL", platform_url)
  13. from app import create_app, db
  14. from app.core.data_research.device_observability import (
  15. DeviceObservabilityConflict,
  16. DeviceObservabilityService,
  17. )
  18. from app.core.data_research.device_observability_repository import (
  19. SqlAlchemyDeviceObservabilityRepository,
  20. )
  21. from app.models.data_research import DeviceAsset, IngestionSource
  22. app = create_app()
  23. app.config.update(TESTING=True)
  24. suffix = uuid.uuid4().hex[:10]
  25. actor_uid = str(uuid.uuid4())
  26. source_uid = str(uuid.uuid4())
  27. device_uid = str(uuid.uuid4())
  28. now = datetime.now(UTC).replace(microsecond=0)
  29. try:
  30. with app.app_context():
  31. db.session.execute(
  32. text(
  33. """
  34. INSERT INTO public.users (
  35. id, username, display_name, password_hash, status
  36. ) VALUES (
  37. CAST(:id AS uuid), :username, :username,
  38. 'integration-test', 'active'
  39. )
  40. """
  41. ),
  42. {
  43. "id": actor_uid,
  44. "username": f"wp09-actor-{suffix}",
  45. },
  46. )
  47. db.session.add(
  48. IngestionSource(
  49. uid=source_uid,
  50. source_type="database",
  51. name=f"WP09 source {suffix}",
  52. config={"database_type": "postgresql"},
  53. permission_scope={},
  54. status="active",
  55. created_by=actor_uid,
  56. )
  57. )
  58. db.session.add(
  59. DeviceAsset(
  60. uid=device_uid,
  61. asset_type="device",
  62. name=f"WP09 pump {suffix}",
  63. status="active",
  64. current_version=1,
  65. content_hash="9" * 64,
  66. attributes={},
  67. created_by=actor_uid,
  68. updated_by=actor_uid,
  69. )
  70. )
  71. db.session.commit()
  72. repository = SqlAlchemyDeviceObservabilityRepository(db.session)
  73. service = DeviceObservabilityService(
  74. repository,
  75. commit=db.session.commit,
  76. rollback=db.session.rollback,
  77. )
  78. imported = service.import_evidence(
  79. {
  80. "source_uid": source_uid,
  81. "events": [
  82. {
  83. "source_entity": "alarm_events",
  84. "source_code": f"AL-{suffix}",
  85. "event_type": "alarm",
  86. "asset_uid": device_uid,
  87. "title": "轴承温度高",
  88. "severity": "warning",
  89. "status": "observed",
  90. "occurred_at": now.isoformat(),
  91. "evidence": {"sample_ref": f"S-{suffix}"},
  92. },
  93. {
  94. "source_entity": "fault_events",
  95. "source_code": f"FT-{suffix}",
  96. "event_type": "fault",
  97. "asset_uid": device_uid,
  98. "title": "轴承故障",
  99. "severity": "error",
  100. "status": "observed",
  101. "occurred_at": now.isoformat(),
  102. "evidence": {"fault_code": "F-001"},
  103. },
  104. {
  105. "source_entity": "downtime_events",
  106. "source_code": f"DT-{suffix}",
  107. "event_type": "downtime",
  108. "asset_uid": device_uid,
  109. "title": "泵停机",
  110. "severity": "critical",
  111. "status": "resolved",
  112. "occurred_at": now.isoformat(),
  113. "ended_at": now.isoformat(),
  114. "evidence": {"duration_minutes": 18},
  115. },
  116. ],
  117. "relations": [
  118. {
  119. "from": {
  120. "kind": "event",
  121. "source_entity": "alarm_events",
  122. "source_code": f"AL-{suffix}",
  123. },
  124. "relation_type": "indicates",
  125. "to": {
  126. "kind": "event",
  127. "source_entity": "fault_events",
  128. "source_code": f"FT-{suffix}",
  129. },
  130. "evidence": {"window_minutes": 5},
  131. },
  132. {
  133. "from": {
  134. "kind": "event",
  135. "source_entity": "fault_events",
  136. "source_code": f"FT-{suffix}",
  137. },
  138. "relation_type": "triggered",
  139. "to": {
  140. "kind": "event",
  141. "source_entity": "downtime_events",
  142. "source_code": f"DT-{suffix}",
  143. },
  144. "evidence": {"operator_log_ref": f"OP-{suffix}"},
  145. },
  146. ],
  147. },
  148. actor_uid,
  149. )
  150. assert imported.created_events == 3
  151. assert imported.created_relations == 2
  152. duplicate = service.import_evidence(
  153. {
  154. "source_uid": source_uid,
  155. "events": [
  156. {
  157. "source_entity": "alarm_events",
  158. "source_code": f"AL-{suffix}",
  159. "event_type": "alarm",
  160. "asset_uid": device_uid,
  161. "title": "轴承温度高",
  162. "severity": "warning",
  163. "status": "observed",
  164. "occurred_at": now.isoformat(),
  165. "evidence": {"sample_ref": f"S-{suffix}"},
  166. }
  167. ],
  168. "relations": [],
  169. },
  170. actor_uid,
  171. )
  172. assert duplicate.existing_events == 1
  173. with pytest.raises(DeviceObservabilityConflict):
  174. service.import_evidence(
  175. {
  176. "source_uid": source_uid,
  177. "events": [
  178. {
  179. "source_entity": "alarm_events",
  180. "source_code": f"AL-{suffix}",
  181. "event_type": "alarm",
  182. "asset_uid": device_uid,
  183. "title": "冲突告警标题",
  184. "severity": "warning",
  185. "status": "observed",
  186. "occurred_at": now.isoformat(),
  187. "evidence": {},
  188. }
  189. ],
  190. "relations": [],
  191. },
  192. actor_uid,
  193. )
  194. records, total = repository.search_events(
  195. {"event_type": "downtime", "asset_uid": device_uid},
  196. page=1,
  197. page_size=20,
  198. )
  199. assert total == 1
  200. assert records[0].title == "泵停机"
  201. graph = service.graph("event", records[0].uid, max_hops=3)
  202. assert len(graph.nodes) == 3
  203. assert [item.relation_type for item in graph.relations] == [
  204. "triggered",
  205. "indicates",
  206. ]
  207. analysis = service.root_cause(
  208. "event",
  209. records[0].uid,
  210. max_hops=3,
  211. )
  212. assert analysis.analysis_status == "supported_candidates"
  213. assert [item.event_type for item in analysis.candidates] == [
  214. "fault",
  215. "alarm",
  216. ]
  217. finally:
  218. with app.app_context():
  219. db.session.execute(
  220. text(
  221. "DELETE FROM public.device_evidence_relations "
  222. "WHERE created_by = CAST(:actor_uid AS uuid)"
  223. ),
  224. {"actor_uid": actor_uid},
  225. )
  226. db.session.execute(
  227. text(
  228. "DELETE FROM public.device_operational_events "
  229. "WHERE created_by = CAST(:actor_uid AS uuid)"
  230. ),
  231. {"actor_uid": actor_uid},
  232. )
  233. db.session.execute(
  234. text(
  235. "DELETE FROM public.device_assets "
  236. "WHERE uid = CAST(:uid AS uuid)"
  237. ),
  238. {"uid": device_uid},
  239. )
  240. db.session.execute(
  241. text(
  242. "DELETE FROM public.ingestion_sources "
  243. "WHERE uid = CAST(:uid AS uuid)"
  244. ),
  245. {"uid": source_uid},
  246. )
  247. db.session.execute(
  248. text(
  249. "DELETE FROM public.users "
  250. "WHERE id = CAST(:id AS uuid)"
  251. ),
  252. {"id": actor_uid},
  253. )
  254. db.session.commit()