test_data_rule_schema.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  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. EXPECTED_TABLES = {
  55. "data_rules",
  56. "data_rule_versions",
  57. "rule_generation_runs",
  58. "data_standards",
  59. "data_standard_versions",
  60. "standard_rule_bindings",
  61. "dataflow_versions",
  62. "dataflow_component_bindings",
  63. "rule_execution_plans",
  64. "rule_artifacts",
  65. "dataflow_deployments",
  66. "rule_runs",
  67. "rule_violation_samples",
  68. }
  69. def test_ai_data_rule_migration_defines_all_three_domain_layers():
  70. source = MIGRATION.read_text(encoding="utf-8")
  71. assert 'revision = "20260723_110"' in source
  72. assert 'down_revision = "20260722_110"' in source
  73. for table in EXPECTED_TABLES:
  74. assert f"CREATE TABLE public.{table}" in source
  75. def test_ai_data_rule_migration_enforces_immutable_versions_and_fixed_bindings():
  76. source = MIGRATION.read_text(encoding="utf-8")
  77. for expected in (
  78. "UNIQUE (rule_uid, version_no)",
  79. "UNIQUE (standard_uid, version_no)",
  80. "UNIQUE (dataflow_uid, version_no)",
  81. "standard_version_id UUID NOT NULL",
  82. "rule_version_id UUID NOT NULL",
  83. "dataflow_version_id UUID NOT NULL",
  84. "component_kind VARCHAR(30) NOT NULL",
  85. "package_hash CHAR(64)",
  86. "plan_hash CHAR(64)",
  87. "artifact_digest CHAR(64)",
  88. "context_hash CHAR(64)",
  89. "candidate_hash CHAR(64)",
  90. "workflow_version_id UUID",
  91. ):
  92. assert expected in source
  93. def test_ai_data_rule_migration_is_forward_preserving():
  94. source = MIGRATION.read_text(encoding="utf-8")
  95. downgrade = source.split("def downgrade()", 1)[1]
  96. assert "DROP TABLE" not in downgrade.upper()
  97. assert "pass" in downgrade
  98. def test_rule_execution_runtime_migration_adds_pinned_runtime_evidence():
  99. source = RUNTIME_MIGRATION.read_text(encoding="utf-8")
  100. assert 'revision = "20260723_120"' in source
  101. assert 'down_revision = "20260723_110"' in source
  102. for table in (
  103. "data_schema_snapshots",
  104. "dataflow_dataset_bindings",
  105. "rule_compile_evidence",
  106. "rule_test_evidence",
  107. ):
  108. assert f"CREATE TABLE public.{table}" in source
  109. for expected in (
  110. "rule_execution_plan_id UUID NOT NULL",
  111. "created_by UUID REFERENCES public.users(id)",
  112. "candidate JSONB",
  113. "UNIQUE (schema_ref, schema_hash)",
  114. "UNIQUE (dataflow_deployment_id, logical_ref)",
  115. ):
  116. assert expected in source
  117. def test_rule_execution_runtime_migration_remains_forward_preserving():
  118. source = RUNTIME_MIGRATION.read_text(encoding="utf-8")
  119. downgrade = source.split("def downgrade()", 1)[1]
  120. assert "DROP TABLE" not in downgrade.upper()
  121. assert "pass" in downgrade
  122. def test_bound_plan_status_migration_is_forward_only_and_keeps_compiled_valid():
  123. source = PLAN_STATUS_MIGRATION.read_text(encoding="utf-8")
  124. assert 'revision = "20260723_130"' in source
  125. assert 'down_revision = "20260723_120"' in source
  126. assert "'compiled','published','revoked'" in source
  127. assert "'tested'" not in source
  128. assert "raise RuntimeError" in source.split("def downgrade()", 1)[1]
  129. spec = importlib.util.spec_from_file_location(
  130. "bound_plan_status_migration",
  131. PLAN_STATUS_MIGRATION,
  132. )
  133. assert spec is not None and spec.loader is not None
  134. module = importlib.util.module_from_spec(spec)
  135. spec.loader.exec_module(module)
  136. with pytest.raises(RuntimeError, match="forward-only|cannot downgrade"):
  137. module.downgrade()
  138. def test_rule_run_artifact_catalog_is_correlation_scoped_and_forward_preserving():
  139. source = ARTIFACT_CATALOG_MIGRATION.read_text(encoding="utf-8")
  140. assert 'revision = "20260723_140"' in source
  141. assert 'down_revision = "20260723_130"' in source
  142. assert "CREATE TABLE public.rule_run_artifacts" in source
  143. for expected in (
  144. "correlation_id UUID NOT NULL",
  145. "binding_id UUID NOT NULL",
  146. "artifact_ref VARCHAR(1000) NOT NULL",
  147. "artifact_digest CHAR(64) NOT NULL",
  148. "schema_fields JSONB NOT NULL",
  149. "expires_at TIMESTAMPTZ NOT NULL",
  150. "UNIQUE (correlation_id, binding_id, artifact_digest)",
  151. ):
  152. assert expected in source
  153. downgrade = source.split("def downgrade()", 1)[1]
  154. assert "DROP TABLE" not in downgrade.upper()
  155. assert "pass" in downgrade
  156. def test_artifact_handoff_state_migration_upgrades_old_140_forward_only():
  157. source = ARTIFACT_HANDOFF_MIGRATION.read_text(encoding="utf-8")
  158. assert 'revision = "20260723_150"' in source
  159. assert 'down_revision = "20260723_140"' in source
  160. for expected in (
  161. "binding_hash CHAR(64)",
  162. "handoff_status VARCHAR(20)",
  163. "ready_at TIMESTAMPTZ",
  164. "failed_at TIMESTAMPTZ",
  165. "updated_at TIMESTAMPTZ",
  166. "UNIQUE (correlation_id, binding_id, artifact_kind)",
  167. "'pending','ready','failed'",
  168. "SELECT binding_hash",
  169. ):
  170. assert expected in source
  171. assert "artifact_digest" in source
  172. assert "RAISE EXCEPTION" in source
  173. downgrade = source.split("def downgrade()", 1)[1]
  174. assert "DROP TABLE" not in downgrade.upper()
  175. assert "raise RuntimeError" in downgrade
  176. spec = importlib.util.spec_from_file_location(
  177. "rule_artifact_handoff_state_migration",
  178. ARTIFACT_HANDOFF_MIGRATION,
  179. )
  180. assert spec is not None and spec.loader is not None
  181. module = importlib.util.module_from_spec(spec)
  182. spec.loader.exec_module(module)
  183. with pytest.raises(RuntimeError, match="forward-only|cannot downgrade"):
  184. module.downgrade()
  185. def test_rule_evidence_upgrade_preflights_legacy_duplicates_and_oversize():
  186. source = RULE_EVIDENCE_MIGRATION.read_text(encoding="utf-8")
  187. duplicate_check = source.index("HAVING COUNT(*) > 1")
  188. unique_constraint = source.index(
  189. "ADD CONSTRAINT rule_violation_samples_rule_run_key"
  190. )
  191. assert duplicate_check < unique_constraint
  192. assert "duplicate rule_run_id" in source
  193. assert "sample_count > 100" in source
  194. assert "rows above 100" in source
  195. def test_rule_attempt_migration_adds_exact_identity_replay_lease_and_receipts():
  196. source = RULE_ATTEMPT_MIGRATION.read_text(encoding="utf-8")
  197. assert 'revision = "20260723_170"' in source
  198. assert 'down_revision = "20260723_160"' in source
  199. for expected in (
  200. "lease_owner UUID",
  201. "lease_expires_at TIMESTAMPTZ",
  202. "evidence_digest CHAR(64)",
  203. "deployment_id UUID",
  204. "environment VARCHAR(20)",
  205. "replay_body JSONB",
  206. "replay_digest CHAR(64)",
  207. "CREATE TABLE public.rule_sql_staging_receipts",
  208. "producer_rule_run_id UUID NOT NULL",
  209. "relation_digest CHAR(64) NOT NULL",
  210. "UNIQUE (producer_rule_run_id, output_binding_id)",
  211. ):
  212. assert expected in source
  213. assert "DROP TABLE" not in source.split("def downgrade()", 1)[1].upper()
  214. def test_rule_reconciliation_migration_extends_170_without_rewriting_it():
  215. source = RULE_RECONCILIATION_MIGRATION.read_text(encoding="utf-8")
  216. assert 'revision = "20260723_180"' in source
  217. assert 'down_revision = "20260723_170"' in source
  218. for expected in (
  219. "runner_task_executions",
  220. "lease_expires_at",
  221. "'unknown','expired'",
  222. "cleanup_claim UUID",
  223. "schema_fields JSONB",
  224. "rule_sql_staging_receipts",
  225. ):
  226. assert expected in source
  227. assert "forward-only" in source