test_dataflow_create_reconciliation.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. from __future__ import annotations
  2. import os
  3. import pytest
  4. from neo4j import GraphDatabase
  5. from sqlalchemy import create_engine, text
  6. from sqlalchemy.orm import Session
  7. from app.core.common.identifiers import new_governance_uid
  8. from app.core.data_flow.create_reconciliation import DataFlowCreateReconciler
  9. from app.core.data_flow.dataflows import DataFlowService
  10. from app.core.data_rules.repository import DataRuleRepository
  11. def test_real_postgres_neo4j_orphan_is_reconciled_from_stored_intent(
  12. monkeypatch,
  13. ):
  14. pg_url = os.environ.get("DATA_RULE_POSTGRES_ACCEPTANCE_URL")
  15. neo4j_uri = os.environ.get("DATA_RULE_NEO4J_ACCEPTANCE_URI")
  16. password = os.environ.get("DATA_RULE_NEO4J_ACCEPTANCE_PASSWORD")
  17. if not pg_url or not neo4j_uri or not password:
  18. pytest.skip("real PostgreSQL and Neo4j acceptance are not configured")
  19. user = os.environ.get("DATA_RULE_NEO4J_ACCEPTANCE_USER", "neo4j")
  20. monkeypatch.setenv("DATABASE_URL", pg_url)
  21. from app import create_app
  22. app = create_app()
  23. app.config.update(
  24. TESTING=True,
  25. NEO4J_URI=neo4j_uri,
  26. NEO4J_USER=user,
  27. NEO4J_PASSWORD=password,
  28. NEO4J_ENCRYPTED=False,
  29. )
  30. pg = create_engine(pg_url)
  31. graph = GraphDatabase.driver(
  32. neo4j_uri, auth=(user, password), encrypted=False
  33. )
  34. actor = new_governance_uid()
  35. reservation_id = None
  36. dataflow_uid = None
  37. try:
  38. with app.app_context(), Session(pg) as session:
  39. session.execute(
  40. text(
  41. "INSERT INTO public.users "
  42. "(id, username, display_name, password_hash, status) "
  43. "VALUES (CAST(:id AS uuid), :username, "
  44. "'Reconcile Acceptance', 'not-a-login-hash', 'active')"
  45. ),
  46. {"id": actor, "username": f"reconcile-{actor}"},
  47. )
  48. session.commit()
  49. repository = DataRuleRepository(session)
  50. receipt = repository.reserve_dataflow_draft(actor_uid=actor)
  51. reservation_id = receipt["reservation_id"]
  52. dataflow_uid = receipt["dataflow_uid"]
  53. session.commit()
  54. node = {
  55. "uid": dataflow_uid,
  56. "name_zh": f"孤儿生产线-{dataflow_uid}",
  57. "name_en": f"orphan-{dataflow_uid}",
  58. "category": "应用类",
  59. "organization": "acceptance",
  60. "leader": "system",
  61. "frequency": "月",
  62. "describe": "reconciliation acceptance",
  63. "status": "active",
  64. "update_mode": "append",
  65. "script_type": "governed",
  66. "script_requirement": "{}",
  67. "script_path": "",
  68. }
  69. intent = {
  70. "dataflow_uid": dataflow_uid,
  71. "node": node,
  72. "tags": [],
  73. }
  74. create_request = {
  75. "payload": {"name_zh": node["name_zh"]},
  76. "intent": intent,
  77. }
  78. closed = {
  79. key: receipt[key]
  80. for key in ("reservation_id", "dataflow_uid", "nonce")
  81. }
  82. claim = repository.begin_dataflow_create(
  83. closed,
  84. actor_uid=actor,
  85. create_request=create_request,
  86. create_intent=intent,
  87. lease_seconds=10,
  88. )
  89. session.commit()
  90. DataFlowService._merge_governed_dataflow(node)
  91. session.execute(
  92. text(
  93. "UPDATE public.dataflow_draft_reservations "
  94. "SET lease_expires_at = CURRENT_TIMESTAMP - INTERVAL '1 second' "
  95. "WHERE id = CAST(:id AS uuid)"
  96. ),
  97. {"id": reservation_id},
  98. )
  99. session.commit()
  100. monkeypatch.setattr(
  101. DataFlowService,
  102. "validate_governed_create_intent",
  103. lambda value, *, repository: value,
  104. )
  105. reconciler = DataFlowCreateReconciler(repository)
  106. before_preview = session.execute(
  107. text(
  108. "SELECT state, attempt_count "
  109. "FROM public.dataflow_draft_reservations "
  110. "WHERE id = CAST(:id AS uuid)"
  111. ),
  112. {"id": reservation_id},
  113. ).one()
  114. dry_run = reconciler.run(dry_run=True)
  115. assert dry_run["candidate_count"] == 1
  116. after_preview = session.execute(
  117. text(
  118. "SELECT state, attempt_count "
  119. "FROM public.dataflow_draft_reservations "
  120. "WHERE id = CAST(:id AS uuid)"
  121. ),
  122. {"id": reservation_id},
  123. ).one()
  124. assert after_preview == before_preview
  125. report = reconciler.run(dry_run=False)
  126. assert report["reconciled_count"] == 1
  127. assert report["items"][0]["attempt"] == claim["attempt"] + 1
  128. assert reconciler.run(dry_run=False)["candidate_count"] == 0
  129. state = session.execute(
  130. text(
  131. "SELECT state, request_digest, result_digest "
  132. "FROM public.dataflow_draft_reservations "
  133. "WHERE id = CAST(:id AS uuid)"
  134. ),
  135. {"id": reservation_id},
  136. ).mappings().one()
  137. assert state["state"] == "completed"
  138. assert state["request_digest"]
  139. assert state["result_digest"]
  140. with graph.session() as graph_session:
  141. count = graph_session.run(
  142. "MATCH (n:DataFlow {uid: $uid}) RETURN count(n) AS count",
  143. {"uid": dataflow_uid},
  144. ).single()["count"]
  145. assert count == 1
  146. finally:
  147. if dataflow_uid:
  148. with graph.session() as graph_session:
  149. graph_session.run(
  150. "MATCH (n:DataFlow {uid: $uid}) DETACH DELETE n",
  151. {"uid": dataflow_uid},
  152. )
  153. graph.close()
  154. with Session(pg) as session:
  155. if reservation_id:
  156. session.execute(
  157. text(
  158. "DELETE FROM public.dataflow_draft_reservations "
  159. "WHERE id = CAST(:id AS uuid)"
  160. ),
  161. {"id": reservation_id},
  162. )
  163. session.execute(
  164. text(
  165. "DELETE FROM public.users WHERE id = CAST(:id AS uuid)"
  166. ),
  167. {"id": actor},
  168. )
  169. session.commit()
  170. pg.dispose()