test_data_rule_schema.py 7.6 KB

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