test_device_semantic_api.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  1. from __future__ import annotations
  2. from dataclasses import replace
  3. from datetime import UTC, datetime
  4. import pytest
  5. ONTOLOGY_UID = "01900000-0000-7000-8000-000000000601"
  6. CODE_UID = "01900000-0000-7000-8000-000000000602"
  7. class FakeDeviceSemanticService:
  8. def __init__(self):
  9. from app.core.data_research.device_semantics import (
  10. DeviceSemanticBootstrapResult,
  11. assess_device_graph,
  12. build_device_graph,
  13. )
  14. from app.core.data_research.ontology.models import (
  15. DomainLink,
  16. GraphDocument,
  17. Ontology,
  18. OntologyVersion,
  19. )
  20. graph = build_device_graph(
  21. "01900000-0000-7000-8000-000000000603"
  22. )
  23. ontology = Ontology(
  24. uid=ONTOLOGY_UID,
  25. code="DEVICE_SEMANTIC",
  26. name="设备语义本体",
  27. owner_uid="editor-1",
  28. domain_links=(
  29. DomainLink(
  30. "01900000-0000-7000-8000-000000000603",
  31. "owner",
  32. ),
  33. ),
  34. )
  35. version = OntologyVersion(
  36. uid="01900000-0000-7000-8000-000000000604",
  37. ontology_uid=ONTOLOGY_UID,
  38. version=1,
  39. graph_document=GraphDocument.from_dict(graph),
  40. content_hash="a" * 64,
  41. )
  42. self.result = DeviceSemanticBootstrapResult(
  43. ontology=ontology,
  44. version=version,
  45. profile=assess_device_graph(graph),
  46. created=True,
  47. )
  48. self.actions = []
  49. def bootstrap(self, payload, actor_uid):
  50. self.actions.append(("bootstrap", payload, actor_uid))
  51. return self.result
  52. def profile(self):
  53. self.actions.append(("profile",))
  54. return replace(self.result, created=False)
  55. class FakeDeviceSemanticCodeService:
  56. def __init__(self):
  57. from app.core.data_research.device_semantics import (
  58. DeviceSemanticCodeRecord,
  59. DeviceSemanticCodeReviewRecord,
  60. DeviceSemanticCodeVersionRecord,
  61. )
  62. now = datetime(2026, 7, 29, 16, 0, tzinfo=UTC)
  63. self.record = DeviceSemanticCodeRecord(
  64. uid=CODE_UID,
  65. ontology_uid=ONTOLOGY_UID,
  66. code_type="fault",
  67. canonical_code="F-001",
  68. canonical_name="轴承过热",
  69. definition="轴承温度超过治理阈值",
  70. status="draft",
  71. current_version=1,
  72. source_mappings=(),
  73. evidence_uids=("evidence-1",),
  74. suggestion_source="rule",
  75. confidence=None,
  76. created_by="editor-1",
  77. updated_by="editor-1",
  78. created_at=now,
  79. updated_at=now,
  80. )
  81. self.version = DeviceSemanticCodeVersionRecord(
  82. uid="01900000-0000-7000-8000-000000000605",
  83. code_uid=CODE_UID,
  84. version=1,
  85. snapshot={
  86. "ontology_uid": ONTOLOGY_UID,
  87. "code_type": "fault",
  88. "canonical_code": "F-001",
  89. "canonical_name": "轴承过热",
  90. },
  91. created_by="editor-1",
  92. created_at=now,
  93. )
  94. self.review_record = DeviceSemanticCodeReviewRecord(
  95. uid="01900000-0000-7000-8000-000000000606",
  96. code_uid=CODE_UID,
  97. version=1,
  98. decision="approve",
  99. reason="企业故障字典已确认",
  100. actor_uid="admin-1",
  101. created_at=now,
  102. )
  103. self.actions = []
  104. def search(self, filters, *, page, page_size):
  105. self.actions.append(("search", filters, page, page_size))
  106. return [self.record], 1
  107. def create(self, payload, actor_uid):
  108. self.actions.append(("create", payload, actor_uid))
  109. return self.record
  110. def get(self, code_uid):
  111. self.actions.append(("get", code_uid))
  112. return self.record
  113. def revise(
  114. self,
  115. code_uid,
  116. payload,
  117. *,
  118. expected_version,
  119. actor_uid,
  120. ):
  121. self.actions.append(
  122. ("revise", code_uid, payload, expected_version, actor_uid)
  123. )
  124. return replace(
  125. self.record,
  126. canonical_name=payload.get(
  127. "canonical_name",
  128. self.record.canonical_name,
  129. ),
  130. current_version=2,
  131. )
  132. def submit(self, code_uid, *, expected_version, actor_uid):
  133. self.actions.append(
  134. ("submit", code_uid, expected_version, actor_uid)
  135. )
  136. return replace(self.record, status="in_review")
  137. def review(self, code_uid, payload, *, actor_uid):
  138. self.actions.append(("review", code_uid, payload, actor_uid))
  139. return replace(self.record, status="published"), self.review_record
  140. def versions(self, code_uid):
  141. self.actions.append(("versions", code_uid))
  142. return [self.version]
  143. def reviews(self, code_uid):
  144. self.actions.append(("reviews", code_uid))
  145. return [self.review_record]
  146. @pytest.fixture()
  147. def client(monkeypatch):
  148. from flask import request
  149. from app import create_app
  150. from app.api.data_development import routes
  151. from app.core.system import permissions
  152. semantic_service = FakeDeviceSemanticService()
  153. code_service = FakeDeviceSemanticCodeService()
  154. def identity():
  155. role = request.headers.get(
  156. "Authorization",
  157. "",
  158. ).removeprefix("Bearer ")
  159. if role not in {"viewer", "editor", "admin"}:
  160. return None
  161. return {"id": f"{role}-1", "roles": [role]}
  162. monkeypatch.setattr(permissions, "authenticate_request", identity)
  163. monkeypatch.setattr(
  164. routes,
  165. "get_device_semantic_service",
  166. lambda: semantic_service,
  167. )
  168. monkeypatch.setattr(
  169. routes,
  170. "get_device_semantic_code_service",
  171. lambda: code_service,
  172. )
  173. app = create_app()
  174. app.config.update(TESTING=True)
  175. return app.test_client(), semantic_service, code_service
  176. def test_viewer_reads_profile_codes_and_traceability_but_cannot_edit(client):
  177. http, _semantic_service, code_service = client
  178. headers = {"Authorization": "Bearer viewer"}
  179. profile = http.get(
  180. "/api/development/v1/device-semantics/profile",
  181. headers=headers,
  182. )
  183. codes = http.get(
  184. "/api/development/v1/device-semantics/codes"
  185. f"?ontology_uid={ONTOLOGY_UID}&code_type=fault&page=1&page_size=10",
  186. headers=headers,
  187. )
  188. versions = http.get(
  189. f"/api/development/v1/device-semantics/codes/{CODE_UID}/versions",
  190. headers=headers,
  191. )
  192. reviews = http.get(
  193. f"/api/development/v1/device-semantics/codes/{CODE_UID}/reviews",
  194. headers=headers,
  195. )
  196. denied = http.post(
  197. "/api/development/v1/device-semantics/codes",
  198. headers=headers,
  199. json={},
  200. )
  201. assert profile.status_code == 200
  202. assert profile.get_json()["data"]["profile"]["ready_to_publish"] is True
  203. assert codes.status_code == 200
  204. assert codes.get_json()["data"]["records"][0][
  205. "canonical_code"
  206. ] == "F-001"
  207. assert versions.get_json()["data"]["records"][0]["version"] == 1
  208. assert reviews.get_json()["data"]["records"][0][
  209. "decision"
  210. ] == "approve"
  211. assert denied.status_code == 403
  212. assert code_service.actions[0] == (
  213. "search",
  214. {"ontology_uid": ONTOLOGY_UID, "code_type": "fault"},
  215. "1",
  216. "10",
  217. )
  218. def test_editor_bootstraps_creates_and_submits_but_cannot_review(client):
  219. http, semantic_service, code_service = client
  220. headers = {"Authorization": "Bearer editor"}
  221. bootstrap = http.post(
  222. "/api/development/v1/device-semantics/bootstrap",
  223. headers=headers,
  224. json={
  225. "owner_domain_uid": (
  226. "01900000-0000-7000-8000-000000000603"
  227. )
  228. },
  229. )
  230. created = http.post(
  231. "/api/development/v1/device-semantics/codes",
  232. headers=headers,
  233. json={
  234. "ontology_uid": ONTOLOGY_UID,
  235. "code_type": "fault",
  236. "canonical_code": "F-001",
  237. "canonical_name": "轴承过热",
  238. },
  239. )
  240. submitted = http.post(
  241. f"/api/development/v1/device-semantics/codes/{CODE_UID}/submit",
  242. headers=headers,
  243. json={"expected_version": 1},
  244. )
  245. denied = http.post(
  246. f"/api/development/v1/device-semantics/codes/{CODE_UID}/review",
  247. headers=headers,
  248. json={
  249. "decision": "approve",
  250. "expected_version": 1,
  251. },
  252. )
  253. assert bootstrap.status_code == 201
  254. assert created.status_code == 201
  255. assert submitted.get_json()["data"]["status"] == "in_review"
  256. assert denied.status_code == 403
  257. assert semantic_service.actions[0][2] == "editor-1"
  258. assert code_service.actions[-1] == (
  259. "submit",
  260. CODE_UID,
  261. 1,
  262. "editor-1",
  263. )
  264. def test_admin_review_returns_audited_decision(client):
  265. http, _semantic_service, code_service = client
  266. response = http.post(
  267. f"/api/development/v1/device-semantics/codes/{CODE_UID}/review",
  268. headers={"Authorization": "Bearer admin"},
  269. json={
  270. "decision": "approve",
  271. "reason": "企业故障字典已确认",
  272. "expected_version": 1,
  273. },
  274. )
  275. assert response.status_code == 200
  276. assert response.get_json()["data"]["record"]["status"] == "published"
  277. assert response.get_json()["data"]["review"]["actor_uid"] == "admin-1"
  278. assert code_service.actions[-1][3] == "admin-1"