test_database_migrations.py 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. from __future__ import annotations
  2. import os
  3. import subprocess
  4. import uuid
  5. from pathlib import Path
  6. import psycopg2
  7. import pytest
  8. from sqlalchemy import create_engine, inspect
  9. from sqlalchemy.engine import make_url
  10. ROOT = Path(__file__).resolve().parents[1]
  11. EXPECTED_BASELINE_TABLES = {
  12. "data_orders",
  13. "data_products",
  14. "metadata_review_records",
  15. "metadata_version_history",
  16. "task_list",
  17. "users",
  18. }
  19. EXPECTED_UPGRADED_TABLES = {
  20. "datasource_credentials",
  21. "datasource_credential_audit_events",
  22. }
  23. def test_alembic_configuration_is_environment_only():
  24. ini = (ROOT / "alembic.ini").read_text(encoding="utf-8")
  25. env = (ROOT / "migrations" / "env.py").read_text(encoding="utf-8")
  26. assert "sqlalchemy.url" not in ini
  27. assert "SQLALCHEMY_DATABASE_URI" in env
  28. assert "DATABASE_URL" in env
  29. assert "password" not in env.lower()
  30. def test_baseline_migration_is_non_destructive():
  31. baseline = (
  32. ROOT / "migrations" / "versions" / "20260716_01_baseline.py"
  33. ).read_text(encoding="utf-8")
  34. assert "def upgrade" in baseline
  35. assert "def downgrade" in baseline
  36. assert "drop_table" not in baseline
  37. for table in EXPECTED_BASELINE_TABLES:
  38. assert table in baseline
  39. @pytest.mark.integration
  40. def test_alembic_upgrade_is_repeatable_and_downgrade_preserves_tables():
  41. admin_url = os.environ.get("TEST_POSTGRES_ADMIN_URL")
  42. if not admin_url:
  43. pytest.skip("TEST_POSTGRES_ADMIN_URL is not configured")
  44. parsed = make_url(admin_url)
  45. database_name = f"dataops_migration_{uuid.uuid4().hex[:12]}"
  46. target_url = parsed.set(database=database_name).render_as_string(
  47. hide_password=False
  48. )
  49. connection = psycopg2.connect(admin_url)
  50. connection.autocommit = True
  51. try:
  52. with connection.cursor() as cursor:
  53. cursor.execute(f'CREATE DATABASE "{database_name}"')
  54. env = os.environ.copy()
  55. env["SQLALCHEMY_DATABASE_URI"] = target_url
  56. command = [str(ROOT / ".venv" / "bin" / "alembic"), "-c", "alembic.ini"]
  57. subprocess.run(command + ["upgrade", "head"], cwd=ROOT, env=env, check=True)
  58. subprocess.run(command + ["upgrade", "head"], cwd=ROOT, env=env, check=True)
  59. engine = create_engine(target_url)
  60. try:
  61. tables = set(inspect(engine).get_table_names(schema="public"))
  62. assert EXPECTED_BASELINE_TABLES | {"alembic_version"} <= tables
  63. assert EXPECTED_UPGRADED_TABLES <= tables
  64. finally:
  65. engine.dispose()
  66. subprocess.run(command + ["downgrade", "-1"], cwd=ROOT, env=env, check=True)
  67. engine = create_engine(target_url)
  68. try:
  69. tables = set(inspect(engine).get_table_names(schema="public"))
  70. assert EXPECTED_BASELINE_TABLES <= tables
  71. finally:
  72. engine.dispose()
  73. finally:
  74. with connection.cursor() as cursor:
  75. cursor.execute(
  76. "SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
  77. "WHERE datname = %s AND pid <> pg_backend_pid()",
  78. (database_name,),
  79. )
  80. cursor.execute(f'DROP DATABASE IF EXISTS "{database_name}"')
  81. connection.close()