test_unified_work_center_postgres.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. from __future__ import annotations
  2. import os
  3. import uuid
  4. from datetime import UTC, datetime, timedelta
  5. import pytest
  6. from sqlalchemy import create_engine, text
  7. from sqlalchemy.orm import Session
  8. from app.core.governance.work_center import UnifiedWorkCenterService
  9. from app.core.governance.work_center_repository import SqlAlchemyWorkCenterRepository
  10. pytestmark = pytest.mark.integration
  11. @pytest.fixture()
  12. def postgres_center():
  13. database_url = os.environ.get("TEST_DATABASE_URL")
  14. if not database_url:
  15. pytest.skip("TEST_DATABASE_URL is required")
  16. engine = create_engine(database_url)
  17. session = Session(engine)
  18. prefix = uuid.uuid4().hex[:10]
  19. users = [str(uuid.uuid4()) for _ in range(4)]
  20. for index, user_uid in enumerate(users):
  21. session.execute(
  22. text(
  23. """
  24. INSERT INTO public.users (
  25. id, username, display_name, password_hash, status
  26. ) VALUES (
  27. CAST(:uid AS uuid), :username, :display_name, 'not-a-login', 'active'
  28. )
  29. """
  30. ),
  31. {
  32. "uid": user_uid,
  33. "username": f"wp07-{prefix}-{index}@example.test",
  34. "display_name": f"WP07 {index}",
  35. },
  36. )
  37. session.commit()
  38. service = UnifiedWorkCenterService(
  39. SqlAlchemyWorkCenterRepository(session),
  40. commit=session.commit,
  41. rollback=session.rollback,
  42. )
  43. yield service, session, users, prefix
  44. session.rollback()
  45. session.execute(
  46. text(
  47. "DELETE FROM public.outbox_events WHERE aggregate_type = 'governance_task' "
  48. "AND aggregate_id IN (SELECT uid::text FROM public.governance_tasks WHERE source_uid LIKE :prefix)"
  49. ),
  50. {"prefix": f"{prefix}-%"},
  51. )
  52. workflow_uids = [
  53. row[0]
  54. for row in session.execute(
  55. text("SELECT uid FROM public.governance_workflows WHERE code = :code"),
  56. {"code": f"WP07_{prefix.upper()}"},
  57. )
  58. ]
  59. session.execute(
  60. text(
  61. "DELETE FROM public.governance_notifications WHERE related_task_uid IN "
  62. "(SELECT uid FROM public.governance_tasks WHERE source_uid LIKE :prefix)"
  63. ),
  64. {"prefix": f"{prefix}-%"},
  65. )
  66. session.execute(
  67. text("DELETE FROM public.governance_tasks WHERE source_uid LIKE :prefix"),
  68. {"prefix": f"{prefix}-%"},
  69. )
  70. if workflow_uids:
  71. session.execute(
  72. text(
  73. "UPDATE public.governance_workflows SET active_version_uid = NULL "
  74. "WHERE uid = ANY(CAST(:uids AS uuid[]))"
  75. ),
  76. {"uids": [str(value) for value in workflow_uids]},
  77. )
  78. session.execute(
  79. text(
  80. "DELETE FROM public.governance_workflow_versions "
  81. "WHERE workflow_uid = ANY(CAST(:uids AS uuid[]))"
  82. ),
  83. {"uids": [str(value) for value in workflow_uids]},
  84. )
  85. session.execute(
  86. text(
  87. "DELETE FROM public.governance_workflows "
  88. "WHERE uid = ANY(CAST(:uids AS uuid[]))"
  89. ),
  90. {"uids": [str(value) for value in workflow_uids]},
  91. )
  92. session.execute(
  93. text(
  94. "DELETE FROM public.governance_notification_preferences "
  95. "WHERE user_uid = ANY(CAST(:uids AS uuid[]))"
  96. ),
  97. {"uids": users},
  98. )
  99. session.execute(
  100. text(
  101. "DELETE FROM public.governance_notification_templates "
  102. "WHERE created_by = ANY(CAST(:uids AS uuid[]))"
  103. ),
  104. {"uids": users},
  105. )
  106. session.execute(
  107. text("DELETE FROM public.users WHERE id = ANY(CAST(:uids AS uuid[]))"),
  108. {"uids": users},
  109. )
  110. session.commit()
  111. session.close()
  112. engine.dispose()
  113. def test_four_sources_share_persisted_contract_audit_and_retry(postgres_center):
  114. center, session, users, prefix = postgres_center
  115. actor, reviewer_a, reviewer_b, _reviewer_c = users
  116. workflow = center.create_workflow(
  117. {
  118. "code": f"WP07_{prefix.upper()}",
  119. "name": "WP07 PostgreSQL contract",
  120. "subject_types": [
  121. "quality_issue",
  122. "semantic_governance",
  123. "data_product",
  124. "agent",
  125. ],
  126. "routes": [
  127. {
  128. "priority": 1,
  129. "conditions": [
  130. {"field": "risk_level", "operator": "eq", "value": "high"}
  131. ],
  132. "approval_mode": "dual_control",
  133. "reviewer_uids": [reviewer_a, reviewer_b],
  134. "min_approvals": 2,
  135. "due_hours": 1,
  136. "timeout_action": "escalate",
  137. "notification_channels": ["in_app", "email"],
  138. }
  139. ],
  140. "default_route": {
  141. "approval_mode": "any",
  142. "reviewer_uids": [reviewer_a, reviewer_b],
  143. "min_approvals": 1,
  144. "due_hours": 24,
  145. "timeout_action": "close",
  146. "notification_channels": ["in_app"],
  147. },
  148. },
  149. actor_uid=actor,
  150. )
  151. workflow = center.publish_workflow(
  152. workflow["uid"], expected_version=1, actor_uid=actor
  153. )
  154. template = center.create_notification_template(
  155. {
  156. "code": "task_created",
  157. "channel": "email",
  158. "subject_template": "[DataOps] {{task_code}} {{title}}",
  159. "body_template": "{{description}}",
  160. },
  161. actor_uid=actor,
  162. )
  163. center.revise_notification_template(
  164. template["uid"],
  165. {
  166. "subject_template": "[DataOps] {{task_code}} {{title}}",
  167. "body_template": "待办:{{description}}",
  168. "status": "active",
  169. },
  170. expected_version=1,
  171. actor_uid=actor,
  172. )
  173. preference = center.replace_notification_preferences(
  174. {
  175. "enabled_channels": ["in_app"],
  176. "subscribed_events": ["task_created"],
  177. "quiet_hours": {},
  178. },
  179. expected_revision=0,
  180. actor_uid=reviewer_b,
  181. )
  182. assert preference["revision"] == 1
  183. tasks = []
  184. for subject_type in (
  185. "quality_issue",
  186. "semantic_governance",
  187. "data_product",
  188. "agent",
  189. ):
  190. tasks.append(
  191. center.create_task(
  192. {
  193. "workflow_uid": workflow["uid"],
  194. "task_type": "approval",
  195. "subject_type": subject_type,
  196. "subject_uid": f"{prefix}-{subject_type}",
  197. "source_type": subject_type,
  198. "source_uid": f"{prefix}-{subject_type}",
  199. "title": f"Review {subject_type}",
  200. "description": "PostgreSQL integration evidence",
  201. "priority": "high" if subject_type == "agent" else "medium",
  202. "context": {"risk_level": "high" if subject_type == "agent" else "low"},
  203. },
  204. actor_uid=actor,
  205. )
  206. )
  207. approved = center.review_task(
  208. tasks[0]["uid"],
  209. {"decision": "approve", "reason": "verified"},
  210. expected_version=1,
  211. actor_uid=reviewer_a,
  212. )
  213. center.close_task(
  214. approved["uid"],
  215. {"resolution": "closed with evidence", "evidence_refs": ["evidence://wp07"]},
  216. expected_version=2,
  217. actor_uid=reviewer_a,
  218. )
  219. email_attempts = center.deliver_notifications(
  220. "email",
  221. lambda _notification: (_ for _ in ()).throw(RuntimeError("SMTP unavailable")),
  222. at=datetime.now(UTC) + timedelta(minutes=1),
  223. )
  224. dashboard = center.dashboard(at=datetime.now(UTC) + timedelta(days=2))
  225. assert {item["subject_type"] for item in tasks} == {
  226. "quality_issue",
  227. "semantic_governance",
  228. "data_product",
  229. "agent",
  230. }
  231. assert all(item["source_state_unchanged"] for item in tasks)
  232. assert email_attempts[0]["status"] == "pending"
  233. assert email_attempts[0]["attempts"] == 1
  234. assert email_attempts[0]["subject"].startswith("[DataOps] GWT-")
  235. suppressed = session.execute(
  236. text(
  237. "SELECT COUNT(*) FROM public.governance_notifications "
  238. "WHERE recipient_uid = CAST(:uid AS uuid) AND channel = 'email' "
  239. "AND status = 'suppressed'"
  240. ),
  241. {"uid": reviewer_b},
  242. ).scalar_one()
  243. assert suppressed == 1
  244. assert dashboard["by_type"]["quality_issue"] >= 1
  245. assert dashboard["closed_count"] >= 1
  246. actions = {
  247. row[0]
  248. for row in session.execute(
  249. text(
  250. "SELECT action FROM public.governance_task_events "
  251. "WHERE task_uid = CAST(:uid AS uuid)"
  252. ),
  253. {"uid": tasks[0]["uid"]},
  254. )
  255. }
  256. assert {"created", "reviewed", "closed"} <= actions
  257. outbox = session.execute(
  258. text(
  259. "SELECT COUNT(*) FROM public.outbox_events "
  260. "WHERE aggregate_type = 'governance_task' AND aggregate_id = :uid"
  261. ),
  262. {"uid": tasks[0]["uid"]},
  263. ).scalar_one()
  264. assert outbox == 2