test_data_rule_schema.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. from __future__ import annotations
  2. import importlib.util
  3. from pathlib import Path
  4. import pytest
  5. ROOT = Path(__file__).resolve().parents[1]
  6. MIGRATION = (
  7. ROOT
  8. / "migrations"
  9. / "versions"
  10. / "20260723_110_ai_data_rules.py"
  11. )
  12. RUNTIME_MIGRATION = (
  13. ROOT
  14. / "migrations"
  15. / "versions"
  16. / "20260723_120_rule_execution_runtime.py"
  17. )
  18. PLAN_STATUS_MIGRATION = (
  19. ROOT
  20. / "migrations"
  21. / "versions"
  22. / "20260723_130_bound_plan_lifecycle.py"
  23. )
  24. ARTIFACT_CATALOG_MIGRATION = (
  25. ROOT
  26. / "migrations"
  27. / "versions"
  28. / "20260723_140_rule_run_artifacts.py"
  29. )
  30. ARTIFACT_HANDOFF_MIGRATION = (
  31. ROOT
  32. / "migrations"
  33. / "versions"
  34. / "20260723_150_rule_artifact_handoff_state.py"
  35. )
  36. RULE_EVIDENCE_MIGRATION = (
  37. ROOT
  38. / "migrations"
  39. / "versions"
  40. / "20260723_160_rule_execution_evidence.py"
  41. )
  42. RULE_ATTEMPT_MIGRATION = (
  43. ROOT
  44. / "migrations"
  45. / "versions"
  46. / "20260723_170_rule_execution_attempts.py"
  47. )
  48. RULE_RECONCILIATION_MIGRATION = (
  49. ROOT
  50. / "migrations"
  51. / "versions"
  52. / "20260723_180_rule_reconciliation.py"
  53. )
  54. RULE_CLEANUP_LEASE_MIGRATION = (
  55. ROOT
  56. / "migrations"
  57. / "versions"
  58. / "20260723_190_rule_cleanup_leases.py"
  59. )
  60. EXPECTED_TABLES = {
  61. "data_rules",
  62. "data_rule_versions",
  63. "rule_generation_runs",
  64. "data_standards",
  65. "data_standard_versions",
  66. "standard_rule_bindings",
  67. "dataflow_versions",
  68. "dataflow_component_bindings",
  69. "rule_execution_plans",
  70. "rule_artifacts",
  71. "dataflow_deployments",
  72. "rule_runs",
  73. "rule_violation_samples",
  74. }
  75. def test_ai_data_rule_migration_defines_all_three_domain_layers():
  76. source = MIGRATION.read_text(encoding="utf-8")
  77. assert 'revision = "20260723_110"' in source
  78. assert 'down_revision = "20260722_110"' in source
  79. for table in EXPECTED_TABLES:
  80. assert f"CREATE TABLE public.{table}" in source
  81. def test_ai_data_rule_migration_enforces_immutable_versions_and_fixed_bindings():
  82. source = MIGRATION.read_text(encoding="utf-8")
  83. for expected in (
  84. "UNIQUE (rule_uid, version_no)",
  85. "UNIQUE (standard_uid, version_no)",
  86. "UNIQUE (dataflow_uid, version_no)",
  87. "standard_version_id UUID NOT NULL",
  88. "rule_version_id UUID NOT NULL",
  89. "dataflow_version_id UUID NOT NULL",
  90. "component_kind VARCHAR(30) NOT NULL",
  91. "package_hash CHAR(64)",
  92. "plan_hash CHAR(64)",
  93. "artifact_digest CHAR(64)",
  94. "context_hash CHAR(64)",
  95. "candidate_hash CHAR(64)",
  96. "workflow_version_id UUID",
  97. ):
  98. assert expected in source
  99. def test_ai_data_rule_migration_is_forward_preserving():
  100. source = MIGRATION.read_text(encoding="utf-8")
  101. downgrade = source.split("def downgrade()", 1)[1]
  102. assert "DROP TABLE" not in downgrade.upper()
  103. assert "pass" in downgrade
  104. def test_rule_execution_runtime_migration_adds_pinned_runtime_evidence():
  105. source = RUNTIME_MIGRATION.read_text(encoding="utf-8")
  106. assert 'revision = "20260723_120"' in source
  107. assert 'down_revision = "20260723_110"' in source
  108. for table in (
  109. "data_schema_snapshots",
  110. "dataflow_dataset_bindings",
  111. "rule_compile_evidence",
  112. "rule_test_evidence",
  113. ):
  114. assert f"CREATE TABLE public.{table}" in source
  115. for expected in (
  116. "rule_execution_plan_id UUID NOT NULL",
  117. "created_by UUID REFERENCES public.users(id)",
  118. "candidate JSONB",
  119. "UNIQUE (schema_ref, schema_hash)",
  120. "UNIQUE (dataflow_deployment_id, logical_ref)",
  121. ):
  122. assert expected in source
  123. def test_rule_execution_runtime_migration_remains_forward_preserving():
  124. source = RUNTIME_MIGRATION.read_text(encoding="utf-8")
  125. downgrade = source.split("def downgrade()", 1)[1]
  126. assert "DROP TABLE" not in downgrade.upper()
  127. assert "pass" in downgrade
  128. def test_bound_plan_status_migration_is_forward_only_and_keeps_compiled_valid():
  129. source = PLAN_STATUS_MIGRATION.read_text(encoding="utf-8")
  130. assert 'revision = "20260723_130"' in source
  131. assert 'down_revision = "20260723_120"' in source
  132. assert "'compiled','published','revoked'" in source
  133. assert "'tested'" not in source
  134. assert "raise RuntimeError" in source.split("def downgrade()", 1)[1]
  135. spec = importlib.util.spec_from_file_location(
  136. "bound_plan_status_migration",
  137. PLAN_STATUS_MIGRATION,
  138. )
  139. assert spec is not None and spec.loader is not None
  140. module = importlib.util.module_from_spec(spec)
  141. spec.loader.exec_module(module)
  142. with pytest.raises(RuntimeError, match="forward-only|cannot downgrade"):
  143. module.downgrade()
  144. def test_rule_run_artifact_catalog_is_correlation_scoped_and_forward_preserving():
  145. source = ARTIFACT_CATALOG_MIGRATION.read_text(encoding="utf-8")
  146. assert 'revision = "20260723_140"' in source
  147. assert 'down_revision = "20260723_130"' in source
  148. assert "CREATE TABLE public.rule_run_artifacts" in source
  149. for expected in (
  150. "correlation_id UUID NOT NULL",
  151. "binding_id UUID NOT NULL",
  152. "artifact_ref VARCHAR(1000) NOT NULL",
  153. "artifact_digest CHAR(64) NOT NULL",
  154. "schema_fields JSONB NOT NULL",
  155. "expires_at TIMESTAMPTZ NOT NULL",
  156. "UNIQUE (correlation_id, binding_id, artifact_digest)",
  157. ):
  158. assert expected in source
  159. downgrade = source.split("def downgrade()", 1)[1]
  160. assert "DROP TABLE" not in downgrade.upper()
  161. assert "pass" in downgrade
  162. def test_artifact_handoff_state_migration_upgrades_old_140_forward_only():
  163. source = ARTIFACT_HANDOFF_MIGRATION.read_text(encoding="utf-8")
  164. assert 'revision = "20260723_150"' in source
  165. assert 'down_revision = "20260723_140"' in source
  166. for expected in (
  167. "binding_hash CHAR(64)",
  168. "handoff_status VARCHAR(20)",
  169. "ready_at TIMESTAMPTZ",
  170. "failed_at TIMESTAMPTZ",
  171. "updated_at TIMESTAMPTZ",
  172. "UNIQUE (correlation_id, binding_id, artifact_kind)",
  173. "'pending','ready','failed'",
  174. "SELECT binding_hash",
  175. ):
  176. assert expected in source
  177. assert "artifact_digest" in source
  178. assert "RAISE EXCEPTION" in source
  179. downgrade = source.split("def downgrade()", 1)[1]
  180. assert "DROP TABLE" not in downgrade.upper()
  181. assert "raise RuntimeError" in downgrade
  182. spec = importlib.util.spec_from_file_location(
  183. "rule_artifact_handoff_state_migration",
  184. ARTIFACT_HANDOFF_MIGRATION,
  185. )
  186. assert spec is not None and spec.loader is not None
  187. module = importlib.util.module_from_spec(spec)
  188. spec.loader.exec_module(module)
  189. with pytest.raises(RuntimeError, match="forward-only|cannot downgrade"):
  190. module.downgrade()
  191. def test_rule_evidence_upgrade_preflights_legacy_duplicates_and_oversize():
  192. source = RULE_EVIDENCE_MIGRATION.read_text(encoding="utf-8")
  193. duplicate_check = source.index("HAVING COUNT(*) > 1")
  194. unique_constraint = source.index(
  195. "ADD CONSTRAINT rule_violation_samples_rule_run_key"
  196. )
  197. assert duplicate_check < unique_constraint
  198. assert "duplicate rule_run_id" in source
  199. assert "sample_count > 100" in source
  200. assert "rows above 100" in source
  201. def test_rule_attempt_migration_adds_exact_identity_replay_lease_and_receipts():
  202. source = RULE_ATTEMPT_MIGRATION.read_text(encoding="utf-8")
  203. assert 'revision = "20260723_170"' in source
  204. assert 'down_revision = "20260723_160"' in source
  205. for expected in (
  206. "lease_owner UUID",
  207. "lease_expires_at TIMESTAMPTZ",
  208. "evidence_digest CHAR(64)",
  209. "deployment_id UUID",
  210. "environment VARCHAR(20)",
  211. "replay_body JSONB",
  212. "replay_digest CHAR(64)",
  213. "CREATE TABLE public.rule_sql_staging_receipts",
  214. "producer_rule_run_id UUID NOT NULL",
  215. "relation_digest CHAR(64) NOT NULL",
  216. "UNIQUE (producer_rule_run_id, output_binding_id)",
  217. ):
  218. assert expected in source
  219. assert "DROP TABLE" not in source.split("def downgrade()", 1)[1].upper()
  220. def test_rule_reconciliation_migration_extends_170_without_rewriting_it():
  221. source = RULE_RECONCILIATION_MIGRATION.read_text(encoding="utf-8")
  222. assert 'revision = "20260723_180"' in source
  223. assert 'down_revision = "20260723_170"' in source
  224. for expected in (
  225. "runner_task_executions",
  226. "lease_expires_at",
  227. "'unknown','expired'",
  228. "cleanup_claim UUID",
  229. "schema_fields JSONB",
  230. "rule_sql_staging_receipts",
  231. ):
  232. assert expected in source
  233. assert "forward-only" in source
  234. def test_rule_cleanup_lease_migration_extends_180_forward_only():
  235. source = RULE_CLEANUP_LEASE_MIGRATION.read_text(encoding="utf-8")
  236. assert 'revision = "20260723_190"' in source
  237. assert 'down_revision = "20260723_180"' in source
  238. assert source.count("cleanup_claim_expires_at TIMESTAMPTZ") == 2
  239. assert "rule_violation_samples" in source
  240. assert "rule_sql_staging_receipts" in source
  241. assert source.count(
  242. "SET cleanup_claim_expires_at = CURRENT_TIMESTAMP"
  243. ) == 2
  244. assert source.count("WHERE cleanup_claim IS NOT NULL") == 2
  245. assert source.count("AND cleanup_claim_expires_at IS NULL") == 2
  246. assert "forward-only" in source