test_quality_issue_api.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. from __future__ import annotations
  2. from datetime import UTC, datetime, timedelta
  3. import pytest
  4. ISSUE_UID = "01900000-0000-7000-8000-000000000901"
  5. VIOLATION_UID = "01900000-0000-7000-8000-000000000902"
  6. RUN_UID = "01900000-0000-7000-8000-000000000903"
  7. ASSET_UID = "01900000-0000-7000-8000-000000000904"
  8. ASSIGNEE_UID = "01900000-0000-7000-8000-000000000905"
  9. class FakeQualityIssueService:
  10. def __init__(self):
  11. from app.core.data_research.quality_issues import (
  12. QualityIssueImportResult,
  13. QualityIssueRecord,
  14. QualityIssueRemediationRecord,
  15. QualityIssueTimelineRecord,
  16. )
  17. now = datetime(2026, 7, 29, 10, tzinfo=UTC)
  18. self.issue = QualityIssueRecord(
  19. uid=ISSUE_UID,
  20. issue_code="QI-000000000901",
  21. source_violation_uid=VIOLATION_UID,
  22. source_run_uid=RUN_UID,
  23. rule_code="asset_context_complete",
  24. severity="error",
  25. priority="high",
  26. asset_uid=ASSET_UID,
  27. field_name="location",
  28. source_uid=None,
  29. source_mapping_uid=None,
  30. message="设备位置缺失",
  31. evidence={
  32. "asset_uid": ASSET_UID,
  33. "missing_fields": ["location"],
  34. },
  35. recurrence_key="a" * 64,
  36. occurrence_number=2,
  37. status="assigned",
  38. assignee_uid=ASSIGNEE_UID,
  39. due_at=now + timedelta(days=2),
  40. current_version=2,
  41. created_by="editor-1",
  42. updated_by="editor-1",
  43. created_at=now,
  44. updated_at=now,
  45. )
  46. self.remediation = QualityIssueRemediationRecord(
  47. uid="01900000-0000-7000-8000-000000000906",
  48. issue_uid=ISSUE_UID,
  49. round_number=1,
  50. summary="已完成补录",
  51. evidence_refs=(),
  52. submitted_by="editor-1",
  53. submitted_at=now,
  54. )
  55. self.event = QualityIssueTimelineRecord(
  56. uid="01900000-0000-7000-8000-000000000907",
  57. issue_uid=ISSUE_UID,
  58. action="assigned",
  59. from_status="open",
  60. to_status="assigned",
  61. actor_uid="editor-1",
  62. note=None,
  63. payload={"assignee_uid": ASSIGNEE_UID},
  64. created_at=now,
  65. )
  66. self.import_result = QualityIssueImportResult(
  67. records=(self.issue,),
  68. created_count=1,
  69. existing_count=0,
  70. )
  71. self.actions = []
  72. def issues(self, **filters):
  73. self.actions.append(("issues", filters))
  74. return [self.issue], 1
  75. def statistics(self):
  76. self.actions.append(("statistics",))
  77. return {
  78. "total": 2,
  79. "open": 1,
  80. "closed": 1,
  81. "overdue": 0,
  82. "recurrent": 1,
  83. }
  84. def assignees(self):
  85. self.actions.append(("assignees",))
  86. return [
  87. {
  88. "uid": ASSIGNEE_UID,
  89. "username": "assignee",
  90. "display_name": "整改负责人",
  91. }
  92. ]
  93. def import_violations(self, **payload):
  94. self.actions.append(("import", payload))
  95. return self.import_result
  96. def get(self, issue_uid):
  97. self.actions.append(("get", issue_uid))
  98. return self.issue, self.remediation
  99. def timeline(self, issue_uid):
  100. self.actions.append(("timeline", issue_uid))
  101. return [self.event]
  102. def assign(self, issue_uid, **payload):
  103. self.actions.append(("assign", issue_uid, payload))
  104. return self.issue
  105. def start(self, issue_uid, **payload):
  106. self.actions.append(("start", issue_uid, payload))
  107. return self.issue
  108. def submit(self, issue_uid, **payload):
  109. self.actions.append(("submit", issue_uid, payload))
  110. return self.issue
  111. def review(self, issue_uid, **payload):
  112. self.actions.append(("review", issue_uid, payload))
  113. return self.issue
  114. def reopen(self, issue_uid, **payload):
  115. self.actions.append(("reopen", issue_uid, payload))
  116. return self.issue
  117. @pytest.fixture()
  118. def client(monkeypatch):
  119. from flask import request
  120. from app import create_app
  121. from app.api.data_development import routes
  122. from app.core.system import permissions
  123. service = FakeQualityIssueService()
  124. def identity():
  125. role = request.headers.get(
  126. "Authorization", ""
  127. ).removeprefix("Bearer ")
  128. if role not in {"viewer", "editor", "admin"}:
  129. return None
  130. return {"id": f"{role}-1", "roles": [role]}
  131. monkeypatch.setattr(permissions, "authenticate_request", identity)
  132. monkeypatch.setattr(
  133. routes,
  134. "get_quality_issue_service",
  135. lambda: service,
  136. raising=False,
  137. )
  138. app = create_app()
  139. app.config.update(TESTING=True)
  140. return app.test_client(), service
  141. def test_viewer_reads_issue_register_detail_timeline_and_statistics(client):
  142. http, service = client
  143. headers = {"Authorization": "Bearer viewer"}
  144. listing = http.get(
  145. "/api/development/v1/device-quality/issues"
  146. "?status=assigned&overdue_only=true&page=1&page_size=20",
  147. headers=headers,
  148. )
  149. detail = http.get(
  150. f"/api/development/v1/device-quality/issues/{ISSUE_UID}",
  151. headers=headers,
  152. )
  153. timeline = http.get(
  154. f"/api/development/v1/device-quality/issues/{ISSUE_UID}/timeline",
  155. headers=headers,
  156. )
  157. statistics = http.get(
  158. "/api/development/v1/device-quality/issues/statistics",
  159. headers=headers,
  160. )
  161. assignees = http.get(
  162. "/api/development/v1/device-quality/issues/assignees",
  163. headers=headers,
  164. )
  165. denied = http.post(
  166. "/api/development/v1/device-quality/issues/import",
  167. headers=headers,
  168. json={"violation_uids": [VIOLATION_UID], "priority": "high"},
  169. )
  170. assert listing.status_code == 200
  171. assert listing.get_json()["data"]["records"][0]["occurrence_number"] == 2
  172. assert detail.get_json()["data"]["latest_remediation"]["round_number"] == 1
  173. assert timeline.get_json()["data"]["records"][0]["action"] == "assigned"
  174. assert statistics.get_json()["data"]["recurrent"] == 1
  175. assert assignees.get_json()["data"]["records"][0]["display_name"] == (
  176. "整改负责人"
  177. )
  178. assert denied.status_code == 403
  179. assert service.actions[0][0] == "issues"
  180. def test_editor_imports_assigns_starts_submits_and_reopens_but_not_reviews(
  181. client,
  182. ):
  183. http, service = client
  184. headers = {"Authorization": "Bearer editor"}
  185. base = f"/api/development/v1/device-quality/issues/{ISSUE_UID}"
  186. imported = http.post(
  187. "/api/development/v1/device-quality/issues/import",
  188. headers=headers,
  189. json={
  190. "violation_uids": [VIOLATION_UID],
  191. "priority": "high",
  192. "due_at": "2026-08-01T12:00:00+08:00",
  193. },
  194. )
  195. assigned = http.post(
  196. f"{base}/assign",
  197. headers=headers,
  198. json={
  199. "assignee_uid": ASSIGNEE_UID,
  200. "due_at": None,
  201. "expected_version": 1,
  202. },
  203. )
  204. started = http.post(
  205. f"{base}/start",
  206. headers=headers,
  207. json={"expected_version": 2},
  208. )
  209. submitted = http.post(
  210. f"{base}/submit",
  211. headers=headers,
  212. json={
  213. "summary": "已补录",
  214. "evidence_refs": [],
  215. "expected_version": 3,
  216. },
  217. )
  218. reopened = http.post(
  219. f"{base}/reopen",
  220. headers=headers,
  221. json={
  222. "reason": "现场复发",
  223. "due_at": None,
  224. "expected_version": 5,
  225. },
  226. )
  227. denied = http.post(
  228. f"{base}/review",
  229. headers=headers,
  230. json={
  231. "verification_result": "passed",
  232. "note": "复验通过",
  233. "expected_version": 4,
  234. },
  235. )
  236. assert imported.status_code == 201
  237. assert assigned.status_code == 200
  238. assert started.status_code == 200
  239. assert submitted.status_code == 200
  240. assert reopened.status_code == 200
  241. assert denied.status_code == 403
  242. assert (service.actions[0][0],) == ("import",)
  243. def test_admin_reviews_with_verification_evidence(client):
  244. http, service = client
  245. response = http.post(
  246. f"/api/development/v1/device-quality/issues/{ISSUE_UID}/review",
  247. headers={"Authorization": "Bearer admin"},
  248. json={
  249. "verification_result": "passed",
  250. "note": "人工核对与复验通过",
  251. "verification_run_uid": RUN_UID,
  252. "expected_version": 4,
  253. },
  254. )
  255. assert response.status_code == 200
  256. assert service.actions[-1][0] == "review"
  257. assert service.actions[-1][2]["verification_run_uid"] == RUN_UID
  258. assert service.actions[-1][2]["actor_uid"] == "admin-1"