test_device_quality_api.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. from __future__ import annotations
  2. from datetime import UTC, datetime, timedelta
  3. import pytest
  4. VERSION_UID = "01900000-0000-7000-8000-000000000801"
  5. RUN_UID = "01900000-0000-7000-8000-000000000802"
  6. ASSET_UID = "01900000-0000-7000-8000-000000000803"
  7. SOURCE_UID = "01900000-0000-7000-8000-000000000804"
  8. MAPPING_UID = "01900000-0000-7000-8000-000000000805"
  9. class FakeDeviceQualityService:
  10. def __init__(self):
  11. from app.core.data_research.device_quality import (
  12. DEFAULT_DEVICE_QUALITY_RULES,
  13. DeviceQualityAssetScoreRecord,
  14. DeviceQualityPolicyVersionRecord,
  15. DeviceQualityRuleResultRecord,
  16. DeviceQualityRunRecord,
  17. DeviceQualityViolationRecord,
  18. validate_device_quality_rules,
  19. )
  20. now = datetime(2026, 7, 29, 17, 0, tzinfo=UTC)
  21. self.version = DeviceQualityPolicyVersionRecord(
  22. uid=VERSION_UID,
  23. profile_uid="01900000-0000-7000-8000-000000000806",
  24. version=1,
  25. status="published",
  26. rules=validate_device_quality_rules(
  27. DEFAULT_DEVICE_QUALITY_RULES
  28. ),
  29. content_hash="a" * 64,
  30. created_by="editor-1",
  31. created_at=now,
  32. published_by="admin-1",
  33. published_at=now,
  34. )
  35. self.run_record = DeviceQualityRunRecord(
  36. uid=RUN_UID,
  37. policy_version_uid=VERSION_UID,
  38. policy_hash="a" * 64,
  39. source_uid=SOURCE_UID,
  40. status="success",
  41. total_assets=1,
  42. total_violations=1,
  43. score=85.0,
  44. created_by="editor-1",
  45. created_at=now,
  46. )
  47. self.rule_result = DeviceQualityRuleResultRecord(
  48. uid="01900000-0000-7000-8000-000000000807",
  49. run_uid=RUN_UID,
  50. rule_code="asset_context_complete",
  51. severity="error",
  52. weight=15.0,
  53. status="violated",
  54. evaluated_count=1,
  55. violation_count=1,
  56. sampled_count=1,
  57. pass_rate=0.0,
  58. weighted_score=0.0,
  59. created_at=now,
  60. )
  61. self.violation = DeviceQualityViolationRecord(
  62. uid="01900000-0000-7000-8000-000000000808",
  63. run_uid=RUN_UID,
  64. rule_code="asset_context_complete",
  65. severity="error",
  66. asset_uid=ASSET_UID,
  67. field_name="location",
  68. source_uid=SOURCE_UID,
  69. source_mapping_uid=MAPPING_UID,
  70. message="设备台账位置、组织或责任人不完整",
  71. evidence={
  72. "asset_uid": ASSET_UID,
  73. "source_mapping_uid": MAPPING_UID,
  74. "missing_fields": ["location"],
  75. },
  76. created_at=now,
  77. expires_at=now + timedelta(days=30),
  78. )
  79. self.asset_score = DeviceQualityAssetScoreRecord(
  80. uid="01900000-0000-7000-8000-000000000809",
  81. run_uid=RUN_UID,
  82. asset_uid=ASSET_UID,
  83. asset_type="device",
  84. status="violated",
  85. evaluated_rule_count=3,
  86. violation_count=1,
  87. score=62.5,
  88. created_at=now,
  89. )
  90. self.actions = []
  91. def profile(self):
  92. self.actions.append(("profile",))
  93. return {
  94. "profile_uid": self.version.profile_uid,
  95. "name": "设备台账与故障质量策略",
  96. "latest_version": self.version,
  97. "active_version": self.version,
  98. }
  99. def bootstrap(self, *, actor_uid):
  100. self.actions.append(("bootstrap", actor_uid))
  101. return self.version
  102. def revise(self, *, rules, expected_version, actor_uid):
  103. self.actions.append(
  104. ("revise", rules, expected_version, actor_uid)
  105. )
  106. return self.version
  107. def versions(self):
  108. self.actions.append(("versions",))
  109. return [self.version]
  110. def publish(self, version_uid, *, actor_uid):
  111. self.actions.append(("publish", version_uid, actor_uid))
  112. return self.version
  113. def run(self, *, actor_uid, source_uid=None):
  114. self.actions.append(("run", actor_uid, source_uid))
  115. return self.run_record
  116. def runs(self, *, page, page_size):
  117. self.actions.append(("runs", page, page_size))
  118. return [self.run_record], 1
  119. def get_run(self, run_uid):
  120. self.actions.append(("get_run", run_uid))
  121. return self.run_record, [self.rule_result]
  122. def violations(self, run_uid, *, rule_code, page, page_size):
  123. self.actions.append(
  124. ("violations", run_uid, rule_code, page, page_size)
  125. )
  126. return [self.violation], 1
  127. def asset_scores(self, run_uid, *, page, page_size):
  128. self.actions.append(("asset_scores", run_uid, page, page_size))
  129. return [self.asset_score], 1
  130. @pytest.fixture()
  131. def client(monkeypatch):
  132. from flask import request
  133. from app import create_app
  134. from app.api.data_development import routes
  135. from app.core.system import permissions
  136. service = FakeDeviceQualityService()
  137. def identity():
  138. role = request.headers.get(
  139. "Authorization",
  140. "",
  141. ).removeprefix("Bearer ")
  142. if role not in {"viewer", "editor", "admin"}:
  143. return None
  144. return {"id": f"{role}-1", "roles": [role]}
  145. monkeypatch.setattr(permissions, "authenticate_request", identity)
  146. monkeypatch.setattr(
  147. routes,
  148. "get_device_quality_service",
  149. lambda: service,
  150. raising=False,
  151. )
  152. app = create_app()
  153. app.config.update(TESTING=True)
  154. return app.test_client(), service
  155. def test_viewer_reads_profile_runs_and_evidence_but_cannot_bootstrap(client):
  156. http, service = client
  157. headers = {"Authorization": "Bearer viewer"}
  158. profile = http.get(
  159. "/api/development/v1/device-quality/profile",
  160. headers=headers,
  161. )
  162. versions = http.get(
  163. "/api/development/v1/device-quality/profile/versions",
  164. headers=headers,
  165. )
  166. runs = http.get(
  167. "/api/development/v1/device-quality/runs?page=1&page_size=20",
  168. headers=headers,
  169. )
  170. detail = http.get(
  171. f"/api/development/v1/device-quality/runs/{RUN_UID}",
  172. headers=headers,
  173. )
  174. violations = http.get(
  175. f"/api/development/v1/device-quality/runs/{RUN_UID}/violations"
  176. "?rule_code=asset_context_complete&page=1&page_size=20",
  177. headers=headers,
  178. )
  179. scores = http.get(
  180. f"/api/development/v1/device-quality/runs/{RUN_UID}/asset-scores"
  181. "?page=1&page_size=20",
  182. headers=headers,
  183. )
  184. denied = http.post(
  185. "/api/development/v1/device-quality/bootstrap",
  186. headers=headers,
  187. json={},
  188. )
  189. assert profile.status_code == 200
  190. assert profile.get_json()["data"]["active_version"]["version"] == 1
  191. assert len(profile.get_json()["data"]["active_version"]["rules"]) == 7
  192. assert versions.get_json()["data"]["total"] == 1
  193. assert runs.get_json()["data"]["records"][0]["score"] == 85.0
  194. assert detail.get_json()["data"]["rule_results"][0]["pass_rate"] == 0.0
  195. assert violations.get_json()["data"]["records"][0]["asset_uid"] == (
  196. ASSET_UID
  197. )
  198. assert violations.get_json()["data"]["records"][0]["evidence"] == {
  199. "asset_uid": ASSET_UID,
  200. "missing_fields": ["location"],
  201. "source_mapping_uid": MAPPING_UID,
  202. }
  203. assert scores.get_json()["data"]["records"][0]["score"] == 62.5
  204. assert denied.status_code == 403
  205. assert service.actions[0] == ("profile",)
  206. def test_editor_can_bootstrap_revise_and_execute_but_cannot_publish(client):
  207. http, service = client
  208. headers = {"Authorization": "Bearer editor"}
  209. bootstrapped = http.post(
  210. "/api/development/v1/device-quality/bootstrap",
  211. headers=headers,
  212. json={},
  213. )
  214. revised = http.post(
  215. "/api/development/v1/device-quality/profile/versions",
  216. headers=headers,
  217. json={
  218. "expected_version": 1,
  219. "rules": list(service.version.rules),
  220. },
  221. )
  222. executed = http.post(
  223. "/api/development/v1/device-quality/runs",
  224. headers=headers,
  225. json={"source_uid": SOURCE_UID},
  226. )
  227. denied = http.post(
  228. f"/api/development/v1/device-quality/profile/versions/"
  229. f"{VERSION_UID}/publish",
  230. headers=headers,
  231. json={},
  232. )
  233. assert bootstrapped.status_code == 201
  234. assert revised.status_code == 201
  235. assert executed.status_code == 201
  236. assert executed.get_json()["data"]["policy_version_uid"] == VERSION_UID
  237. assert denied.status_code == 403
  238. assert ("bootstrap", "editor-1") in service.actions
  239. assert ("run", "editor-1", SOURCE_UID) in service.actions
  240. def test_admin_publishes_exact_version_through_runtime_gate(client):
  241. http, service = client
  242. response = http.post(
  243. f"/api/development/v1/device-quality/profile/versions/"
  244. f"{VERSION_UID}/publish",
  245. headers={"Authorization": "Bearer admin"},
  246. json={},
  247. )
  248. assert response.status_code == 200
  249. assert response.get_json()["data"]["status"] == "published"
  250. assert service.actions[-1] == ("publish", VERSION_UID, "admin-1")