test_dataflow_create_saga.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. from __future__ import annotations
  2. import copy
  3. import pytest
  4. from app.core.common.identifiers import new_governance_uid
  5. from app.core.data_flow.dataflows import DataFlowService
  6. class GraphResult:
  7. def __init__(self, record=None):
  8. self.record = record
  9. def single(self):
  10. return self.record
  11. class GraphSession:
  12. def __init__(self, *, existing=None, conflict=False):
  13. self.existing = existing
  14. self.conflict = conflict
  15. self.calls = []
  16. def run(self, query, parameters=None, **kwargs):
  17. values = parameters or kwargs
  18. self.calls.append((query, values))
  19. if query.startswith("CREATE CONSTRAINT"):
  20. return GraphResult()
  21. if "WHERE n.uid IS NULL OR n.uid <> $uid" in query:
  22. return GraphResult({"uid": "other"}) if self.conflict else GraphResult()
  23. if query.startswith("MERGE"):
  24. node = copy.deepcopy(self.existing or values["properties"])
  25. self.existing = node
  26. return GraphResult({"n": node, "node_id": 73})
  27. raise AssertionError(query)
  28. def __enter__(self):
  29. return self
  30. def __exit__(self, *_args):
  31. return None
  32. class GraphDriver:
  33. def __init__(self, session):
  34. self.graph_session = session
  35. def session(self):
  36. return self.graph_session
  37. def close(self):
  38. return None
  39. def governed_node():
  40. return {
  41. "uid": new_governance_uid(),
  42. "name_zh": "客户治理生产线",
  43. "name_en": "customer_line",
  44. "script_type": "governed",
  45. "script_requirement": '{"dataflow_spec":{"schema_version":"2.0"}}',
  46. "script_path": "",
  47. }
  48. def test_governed_graph_create_installs_constraint_and_reconciles_same_uid(
  49. monkeypatch,
  50. ):
  51. node = governed_node()
  52. graph = GraphSession()
  53. monkeypatch.setattr(
  54. "app.core.data_flow.dataflows.connect_graph",
  55. lambda: GraphDriver(graph),
  56. )
  57. first_id, first = DataFlowService._merge_governed_dataflow(node)
  58. second_id, second = DataFlowService._merge_governed_dataflow(node)
  59. assert first_id == second_id == 73
  60. assert first == second
  61. assert any(
  62. call[0]
  63. == "CREATE CONSTRAINT data_flow_uid IF NOT EXISTS "
  64. "FOR (n:DataFlow) REQUIRE n.uid IS UNIQUE"
  65. for call in graph.calls
  66. )
  67. assert sum(call[0].startswith("MERGE") for call in graph.calls) == 2
  68. def test_governed_graph_reconcile_fails_closed_on_uid_or_name_conflict(
  69. monkeypatch,
  70. ):
  71. node = governed_node()
  72. graph = GraphSession(existing={**node, "name_zh": "被篡改的名称"})
  73. monkeypatch.setattr(
  74. "app.core.data_flow.dataflows.connect_graph",
  75. lambda: GraphDriver(graph),
  76. )
  77. with pytest.raises(ValueError, match="dataflow_uid_conflict"):
  78. DataFlowService._merge_governed_dataflow(node)
  79. name_conflict = GraphSession(conflict=True)
  80. monkeypatch.setattr(
  81. "app.core.data_flow.dataflows.connect_graph",
  82. lambda: GraphDriver(name_conflict),
  83. )
  84. with pytest.raises(ValueError, match="dataflow_uid_conflict"):
  85. DataFlowService._merge_governed_dataflow(node)
  86. def test_completed_saga_replays_result_without_another_neo4j_write(monkeypatch):
  87. expected = {"id": 73, **governed_node()}
  88. class Repository:
  89. def load_published_assets(self, _flow):
  90. return {}, {}
  91. def begin_dataflow_create(self, _receipt, *, actor_uid):
  92. assert actor_uid
  93. return {
  94. "status": "completed",
  95. "dataflow_uid": expected["uid"],
  96. "result": expected,
  97. }
  98. flow = {
  99. "schema_version": "2.0",
  100. "dataflow_uid": expected["uid"],
  101. "name": "客户治理生产线",
  102. "input_schema_refs": ["bd:customer:v1"],
  103. "output_schema_ref": "bd:customer_clean:v1",
  104. "components": [],
  105. }
  106. # Use the project's validator fixture shape through the repository-level
  107. # contract; the replay must happen before any graph write.
  108. from tests.core.data_rules.test_contracts import valid_dataflow_spec
  109. flow = valid_dataflow_spec()
  110. flow["dataflow_uid"] = expected["uid"]
  111. envelope = {
  112. "dataflow_spec": flow,
  113. "dataset_edges": {
  114. "source_table": flow["input_schema_refs"],
  115. "target_table": flow["output_schema_ref"],
  116. },
  117. "migration_metadata": {
  118. "status": "migrated",
  119. "legacy_fields_present": False,
  120. "preserved_for_read_only": True,
  121. "governed_semantics": "dataflow_spec",
  122. },
  123. }
  124. monkeypatch.setattr(
  125. DataFlowService,
  126. "_merge_governed_dataflow",
  127. lambda _node: pytest.fail("Neo4j was called during completed replay"),
  128. )
  129. result = DataFlowService.create_dataflow(
  130. {
  131. "name_zh": "客户治理生产线",
  132. "describe": "响应丢失重试",
  133. "script_type": "governed",
  134. "script_requirement": envelope,
  135. "draft_reservation": {
  136. "reservation_id": new_governance_uid(),
  137. "dataflow_uid": expected["uid"],
  138. "nonce": "response-loss",
  139. },
  140. },
  141. repository=Repository(),
  142. actor_uid=new_governance_uid(),
  143. )
  144. assert result == expected
  145. def test_graph_failure_is_persisted_but_finalize_failure_leaves_reconcilable_lease(
  146. monkeypatch,
  147. ):
  148. from tests.core.data_rules.test_contracts import valid_dataflow_spec
  149. from tests.test_legacy_governance_cutover import PublishedAssetRepository
  150. flow = valid_dataflow_spec()
  151. actor = new_governance_uid()
  152. def payload(repository):
  153. receipt = repository.reserve_dataflow_draft(actor_uid=actor)
  154. receipt["dataflow_uid"] = flow["dataflow_uid"]
  155. return {
  156. "name_zh": "故障注入生产线",
  157. "describe": "故障注入",
  158. "script_type": "governed",
  159. "script_requirement": {
  160. "dataflow_spec": flow,
  161. "dataset_edges": {
  162. "source_table": flow["input_schema_refs"],
  163. "target_table": flow["output_schema_ref"],
  164. },
  165. "migration_metadata": {
  166. "status": "migrated",
  167. "legacy_fields_present": False,
  168. "preserved_for_read_only": True,
  169. "governed_semantics": "dataflow_spec",
  170. },
  171. },
  172. "draft_reservation": {
  173. key: receipt[key]
  174. for key in ("reservation_id", "dataflow_uid", "nonce")
  175. },
  176. }
  177. graph_failure = PublishedAssetRepository()
  178. failures = []
  179. graph_failure.commit_dataflow_create_failure = (
  180. lambda **kwargs: failures.append(kwargs)
  181. )
  182. monkeypatch.setattr(
  183. DataFlowService,
  184. "_merge_governed_dataflow",
  185. lambda _node: (_ for _ in ()).throw(RuntimeError("neo4j unavailable")),
  186. )
  187. with pytest.raises(RuntimeError, match="neo4j unavailable"):
  188. DataFlowService.create_dataflow(
  189. payload(graph_failure), repository=graph_failure, actor_uid=actor
  190. )
  191. assert failures[0]["error_code"] == "neo4j_create_failed"
  192. finalize_failure = PublishedAssetRepository()
  193. monkeypatch.setattr(
  194. DataFlowService,
  195. "_merge_governed_dataflow",
  196. lambda node: (73, {**node, "id": 73}),
  197. )
  198. finalize_failure.complete_dataflow_create = (
  199. lambda **_kwargs: (_ for _ in ()).throw(
  200. RuntimeError("postgres finalize failed")
  201. )
  202. )
  203. with pytest.raises(RuntimeError, match="postgres finalize failed"):
  204. DataFlowService.create_dataflow(
  205. payload(finalize_failure),
  206. repository=finalize_failure,
  207. actor_uid=actor,
  208. )