test_device_entity_resolution_api.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. from __future__ import annotations
  2. from dataclasses import replace
  3. from datetime import UTC, datetime
  4. import pytest
  5. CANDIDATE_UID = "01900000-0000-7000-8000-000000000701"
  6. LEFT_UID = "01900000-0000-7000-8000-000000000702"
  7. RIGHT_UID = "01900000-0000-7000-8000-000000000703"
  8. MERGE_UID = "01900000-0000-7000-8000-000000000704"
  9. class FakeDeviceEntityResolutionService:
  10. def __init__(self):
  11. from app.core.data_research.device_entity_resolution import (
  12. DeviceEntityCandidateRecord,
  13. DeviceEntityGenerationResult,
  14. DeviceEntityMergeRecord,
  15. DeviceEntityReviewRecord,
  16. DeviceEntityRollbackRecord,
  17. )
  18. now = datetime(2026, 7, 29, 16, 0, tzinfo=UTC)
  19. self.candidate = DeviceEntityCandidateRecord(
  20. uid=CANDIDATE_UID,
  21. left_asset_uid=LEFT_UID,
  22. right_asset_uid=RIGHT_UID,
  23. canonical_asset_uid=None,
  24. status="pending",
  25. suggestion_source="rule",
  26. confidence=0.99,
  27. explanation=(
  28. {
  29. "signal": "name",
  30. "matched": True,
  31. "weight": 0.5,
  32. "left": "一号循环泵",
  33. "right": "一号循环泵",
  34. },
  35. ),
  36. evidence_uids=("evidence-1", "evidence-2"),
  37. model_provider=None,
  38. model_name=None,
  39. current_version=1,
  40. created_by="editor-1",
  41. reviewed_by=None,
  42. created_at=now,
  43. updated_at=now,
  44. )
  45. self.review_record = DeviceEntityReviewRecord(
  46. uid="01900000-0000-7000-8000-000000000705",
  47. candidate_uid=CANDIDATE_UID,
  48. version=1,
  49. decision="approve",
  50. reason="企业设备管理员确认",
  51. actor_uid="admin-1",
  52. created_at=now,
  53. )
  54. self.merge_record = DeviceEntityMergeRecord(
  55. uid=MERGE_UID,
  56. candidate_uid=CANDIDATE_UID,
  57. canonical_asset_uid=LEFT_UID,
  58. member_asset_uid=RIGHT_UID,
  59. review_uid=self.review_record.uid,
  60. snapshot={"canonical": {"uid": LEFT_UID}, "member": {"uid": RIGHT_UID}},
  61. actor_uid="admin-1",
  62. created_at=now,
  63. )
  64. self.rollback_record = DeviceEntityRollbackRecord(
  65. uid="01900000-0000-7000-8000-000000000706",
  66. merge_uid=MERGE_UID,
  67. candidate_uid=CANDIDATE_UID,
  68. reason="现场确认不是同一台设备",
  69. snapshot={"merge": {"uid": MERGE_UID}},
  70. actor_uid="admin-1",
  71. created_at=now,
  72. )
  73. self.generation = DeviceEntityGenerationResult(
  74. records=(self.candidate,),
  75. created_count=1,
  76. existing_count=0,
  77. evaluated_pair_count=1,
  78. auto_merged_count=0,
  79. )
  80. self.actions = []
  81. def search(self, filters, *, page, page_size):
  82. self.actions.append(("search", filters, page, page_size))
  83. return [self.candidate], 1
  84. def get(self, candidate_uid):
  85. self.actions.append(("get", candidate_uid))
  86. return self.candidate
  87. def generate(self, payload, actor_uid):
  88. self.actions.append(("generate", payload, actor_uid))
  89. return self.generation
  90. def submit_ai_candidate(self, payload, actor_uid):
  91. self.actions.append(("submit_ai", payload, actor_uid))
  92. return replace(
  93. self.candidate,
  94. suggestion_source="ai",
  95. model_provider=payload.get("model_provider"),
  96. model_name=payload.get("model_name"),
  97. )
  98. def review(self, candidate_uid, payload, actor_uid):
  99. self.actions.append(("review", candidate_uid, payload, actor_uid))
  100. return (
  101. replace(
  102. self.candidate,
  103. status="merged",
  104. canonical_asset_uid=LEFT_UID,
  105. current_version=2,
  106. ),
  107. self.review_record,
  108. self.merge_record,
  109. )
  110. def rollback(self, merge_uid, payload, actor_uid):
  111. self.actions.append(("rollback", merge_uid, payload, actor_uid))
  112. return (
  113. replace(
  114. self.candidate,
  115. status="rolled_back",
  116. canonical_asset_uid=LEFT_UID,
  117. current_version=3,
  118. ),
  119. self.rollback_record,
  120. )
  121. def reviews(self, candidate_uid):
  122. self.actions.append(("reviews", candidate_uid))
  123. return [self.review_record]
  124. def merges(self, candidate_uid):
  125. self.actions.append(("merges", candidate_uid))
  126. return [self.merge_record]
  127. def rollbacks(self, merge_uid):
  128. self.actions.append(("rollbacks", merge_uid))
  129. return [self.rollback_record]
  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 = FakeDeviceEntityResolutionService()
  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_entity_resolution_service",
  149. lambda: service,
  150. raising=False,
  151. )
  152. app = create_app()
  153. app.config.update(
  154. TESTING=True,
  155. DEVICE_ENTITY_AUTO_MERGE_ENABLED=False,
  156. )
  157. return app.test_client(), service
  158. def test_viewer_reads_candidates_and_evidence_but_cannot_generate(client):
  159. http, service = client
  160. headers = {"Authorization": "Bearer viewer"}
  161. listed = http.get(
  162. "/api/development/v1/device-entities/candidates"
  163. "?status=pending&suggestion_source=rule&page=1&page_size=20",
  164. headers=headers,
  165. )
  166. detail = http.get(
  167. f"/api/development/v1/device-entities/candidates/{CANDIDATE_UID}",
  168. headers=headers,
  169. )
  170. reviews = http.get(
  171. f"/api/development/v1/device-entities/candidates/{CANDIDATE_UID}/reviews",
  172. headers=headers,
  173. )
  174. merges = http.get(
  175. f"/api/development/v1/device-entities/candidates/{CANDIDATE_UID}/merges",
  176. headers=headers,
  177. )
  178. rollbacks = http.get(
  179. f"/api/development/v1/device-entities/merges/{MERGE_UID}/rollbacks",
  180. headers=headers,
  181. )
  182. denied = http.post(
  183. "/api/development/v1/device-entities/candidates/generate",
  184. headers=headers,
  185. json={},
  186. )
  187. assert listed.status_code == 200
  188. assert listed.get_json()["data"]["auto_merge_enabled"] is False
  189. assert listed.get_json()["data"]["records"][0]["confidence"] == 0.99
  190. assert detail.get_json()["data"]["evidence_uids"] == [
  191. "evidence-1",
  192. "evidence-2",
  193. ]
  194. assert reviews.get_json()["data"]["records"][0]["decision"] == "approve"
  195. assert merges.get_json()["data"]["records"][0]["uid"] == MERGE_UID
  196. assert rollbacks.get_json()["data"]["records"][0]["reason"].startswith(
  197. "现场确认"
  198. )
  199. assert denied.status_code == 403
  200. assert service.actions[0] == (
  201. "search",
  202. {"status": "pending", "suggestion_source": "rule"},
  203. "1",
  204. "20",
  205. )
  206. def test_editor_generates_and_submits_ai_candidate_but_cannot_review(client):
  207. http, service = client
  208. headers = {"Authorization": "Bearer editor"}
  209. generated = http.post(
  210. "/api/development/v1/device-entities/candidates/generate",
  211. headers=headers,
  212. json={"asset_type": "device", "threshold": 0.8},
  213. )
  214. submitted = http.post(
  215. "/api/development/v1/device-entities/candidates",
  216. headers=headers,
  217. json={
  218. "left_asset_uid": LEFT_UID,
  219. "right_asset_uid": RIGHT_UID,
  220. "confidence": 0.91,
  221. "model_provider": "governed-provider",
  222. "model_name": "entity-match-v1",
  223. "evidence_uids": ["evidence-1"],
  224. "explanation": "同一台设备",
  225. },
  226. )
  227. denied = http.post(
  228. f"/api/development/v1/device-entities/candidates/{CANDIDATE_UID}/review",
  229. headers=headers,
  230. json={
  231. "decision": "approve",
  232. "canonical_asset_uid": LEFT_UID,
  233. "expected_version": 1,
  234. "reason": "证据充分",
  235. },
  236. )
  237. assert generated.status_code == 201
  238. assert generated.get_json()["data"]["created_count"] == 1
  239. assert submitted.status_code == 201
  240. assert submitted.get_json()["data"]["suggestion_source"] == "ai"
  241. assert denied.status_code == 403
  242. assert service.actions[0][2] == "editor-1"
  243. assert service.actions[1][2] == "editor-1"
  244. def test_admin_review_and_rollback_return_immutable_evidence(client):
  245. http, service = client
  246. headers = {"Authorization": "Bearer admin"}
  247. reviewed = http.post(
  248. f"/api/development/v1/device-entities/candidates/{CANDIDATE_UID}/review",
  249. headers=headers,
  250. json={
  251. "decision": "approve",
  252. "canonical_asset_uid": LEFT_UID,
  253. "expected_version": 1,
  254. "reason": "企业设备管理员确认",
  255. },
  256. )
  257. rolled_back = http.post(
  258. f"/api/development/v1/device-entities/merges/{MERGE_UID}/rollback",
  259. headers=headers,
  260. json={
  261. "expected_version": 2,
  262. "reason": "现场确认不是同一台设备",
  263. },
  264. )
  265. assert reviewed.status_code == 200
  266. assert reviewed.get_json()["data"]["candidate"]["status"] == "merged"
  267. assert reviewed.get_json()["data"]["review"]["actor_uid"] == "admin-1"
  268. assert reviewed.get_json()["data"]["merge"]["member_asset_uid"] == (
  269. RIGHT_UID
  270. )
  271. assert rolled_back.status_code == 200
  272. assert rolled_back.get_json()["data"]["candidate"]["status"] == (
  273. "rolled_back"
  274. )
  275. assert rolled_back.get_json()["data"]["rollback"]["merge_uid"] == (
  276. MERGE_UID
  277. )
  278. assert service.actions[-1][3] == "admin-1"