test_migrations.py 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
  1. from __future__ import annotations
  2. import hashlib
  3. import os
  4. from io import StringIO
  5. from pathlib import Path
  6. import pytest
  7. from alembic import command
  8. from alembic.config import Config
  9. from sqlalchemy import create_engine, inspect, text
  10. from sqlalchemy.ext.asyncio import create_async_engine
  11. def test_alembic_upgrade_and_downgrade(tmp_path: Path, monkeypatch: object) -> None:
  12. root = Path(__file__).parents[1]
  13. database = tmp_path / "migration.db"
  14. url = f"sqlite+aiosqlite:///{database}"
  15. monkeypatch.setenv("SCRIPT_BUILD_WRITE_DATABASE_URL", url) # type: ignore[attr-defined]
  16. config = Config(str(root / "alembic.ini"))
  17. command.upgrade(config, "head")
  18. sync_engine = create_engine(f"sqlite:///{database}")
  19. expected = {
  20. "script_build_mission_binding",
  21. "script_build_input_snapshot",
  22. "script_build_artifact_version",
  23. "script_build_publication",
  24. }
  25. assert expected <= set(inspect(sync_engine).get_table_names())
  26. command.downgrade(config, "base")
  27. assert not (expected & set(inspect(sync_engine).get_table_names()))
  28. sync_engine.dispose()
  29. def test_migration_checksum_manifest() -> None:
  30. root = Path(__file__).parents[1]
  31. manifest = root / "migrations" / "CHECKSUMS.sha256"
  32. lines = [line.split(maxsplit=1) for line in manifest.read_text(encoding="utf-8").splitlines()]
  33. assert lines
  34. for expected, relative in lines:
  35. path = root / relative
  36. assert hashlib.sha256(path.read_bytes()).hexdigest() == expected
  37. def test_mysql_offline_sql_compiles_without_credentials(monkeypatch: object) -> None:
  38. root = Path(__file__).parents[1]
  39. output = StringIO()
  40. monkeypatch.setenv( # type: ignore[attr-defined]
  41. "SCRIPT_BUILD_WRITE_DATABASE_URL", "mysql+asyncmy://"
  42. )
  43. config = Config(str(root / "alembic.ini"), output_buffer=output)
  44. command.upgrade(config, "head", sql=True)
  45. sql = output.getvalue()
  46. assert sql.count("CREATE TABLE script_build_") == 4
  47. assert "script_build_paragraph" not in sql
  48. assert "script_build_element" not in sql
  49. assert "DROP CHECK ck_artifact_phase_one_type" in sql
  50. assert "ADD CONSTRAINT ck_artifact_business_type CHECK" in sql
  51. def test_phase_two_artifacts_make_downgrade_refuse_data_loss(
  52. tmp_path: Path, monkeypatch: object
  53. ) -> None:
  54. root = Path(__file__).parents[1]
  55. database = tmp_path / "migration-refusal.db"
  56. url = f"sqlite+aiosqlite:///{database}"
  57. monkeypatch.setenv("SCRIPT_BUILD_WRITE_DATABASE_URL", url) # type: ignore[attr-defined]
  58. config = Config(str(root / "alembic.ini"))
  59. command.upgrade(config, "head")
  60. sync_engine = create_engine(f"sqlite:///{database}")
  61. with sync_engine.begin() as connection:
  62. connection.execute(
  63. text(
  64. "INSERT INTO script_build_artifact_version "
  65. "(id, script_build_id, task_id, attempt_id, spec_version, artifact_type, "
  66. "legacy_branch_id, canonical_json, canonical_sha256, state, created_at, frozen_at) "
  67. "VALUES (1, 1, 'task', 'attempt', 1, 'paragraph', 1, '{}', :digest, "
  68. "'frozen', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)"
  69. ),
  70. {"digest": "a" * 64},
  71. )
  72. with pytest.raises(RuntimeError, match="cannot downgrade while phase-two artifacts exist"):
  73. command.downgrade(config, "0001_phase_one")
  74. assert inspect(sync_engine).has_table("script_build_artifact_version")
  75. sync_engine.dispose()
  76. @pytest.mark.mysql
  77. @pytest.mark.asyncio
  78. async def test_explicit_mysql_dsn_is_reachable() -> None:
  79. dsn = os.environ.get("SCRIPT_BUILD_TEST_MYSQL_DSN")
  80. if not dsn:
  81. pytest.skip("SCRIPT_BUILD_TEST_MYSQL_DSN is not configured")
  82. engine = create_async_engine(dsn, pool_pre_ping=True)
  83. try:
  84. async with engine.connect() as connection:
  85. assert await connection.scalar(text("SELECT 1")) == 1
  86. finally:
  87. await engine.dispose()