feat: add migration framework with FastMigrate integration

- Add migrations.py with create_migration, run_migrations, get_db_version
- Implement CLI migrate and create-migration commands
- Use FastMigrate's sequential 0001-*.sql naming convention
- Add comprehensive unit, integration, and E2E tests (35 tests)
- Add fresh_db_path and temp_migrations_dir test fixtures

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-27 18:25:28 +00:00
parent 32bb3c01e0
commit 7d7cd2bb57
6 changed files with 756 additions and 11 deletions

View File

@@ -7,6 +7,33 @@ import tempfile
import pytest
@pytest.fixture
def temp_migrations_dir(tmp_path):
"""Create a temporary migrations directory for testing."""
migrations_path = tmp_path / "migrations"
migrations_path.mkdir()
return migrations_path
@pytest.fixture
def fresh_db_path(tmp_path):
"""Provide a path for a non-existent database file.
Unlike temp_db_path, this does not create the file - it just provides
a path. This is needed for fastmigrate which expects to create the db.
"""
db_path = tmp_path / "test.db"
yield db_path
# Cleanup
if db_path.exists():
db_path.unlink()
# Also clean up WAL files if they exist
for suffix in ["-shm", "-wal", "-journal"]:
wal_path = db_path.with_suffix(db_path.suffix + suffix)
if wal_path.exists():
wal_path.unlink()
@pytest.fixture
def temp_db_path():
"""Create a temporary database file path for testing."""

View File

@@ -0,0 +1,180 @@
# ABOUTME: End-to-end tests for migration CLI commands.
# ABOUTME: Tests migrate and create-migration commands via subprocess.
import os
import subprocess
import sys
from pathlib import Path
# Get the project root directory (parent of tests/)
PROJECT_ROOT = Path(__file__).parent.parent
class TestMigrateCLI:
"""End-to-end tests for migrate command."""
def test_migrate_command_success(self, tmp_path, temp_migrations_dir):
"""Should run migrations via CLI and exit 0."""
# Use a path that doesn't exist yet
db_path = tmp_path / "test.db"
# Create a migration file
migration_file = temp_migrations_dir / "0001-test.sql"
migration_file.write_text("CREATE TABLE test (id INTEGER PRIMARY KEY);")
# Set required env vars with PYTHONPATH to find the package
# Note: Settings uses no env_prefix, so use DB_PATH not AT_DB_PATH
env = os.environ.copy()
env["DB_PATH"] = str(db_path)
env["CSRF_SECRET"] = "test-secret-for-csrf"
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
# Run migrate command
result = subprocess.run(
[sys.executable, "-m", "animaltrack.cli", "migrate"],
capture_output=True,
text=True,
env=env,
cwd=str(temp_migrations_dir.parent),
)
assert result.returncode == 0, f"stdout: {result.stdout}, stderr: {result.stderr}"
assert "success" in result.stdout.lower() or "completed" in result.stdout.lower()
def test_migrate_command_no_migrations(self, tmp_path, temp_migrations_dir):
"""Should succeed when no migrations to run."""
# Use a path that doesn't exist yet
db_path = tmp_path / "test.db"
# Set required env vars
env = os.environ.copy()
env["DB_PATH"] = str(db_path)
env["CSRF_SECRET"] = "test-secret-for-csrf"
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
# Run migrate command with empty migrations dir
result = subprocess.run(
[sys.executable, "-m", "animaltrack.cli", "migrate"],
capture_output=True,
text=True,
env=env,
cwd=str(temp_migrations_dir.parent),
)
assert result.returncode == 0, f"stdout: {result.stdout}, stderr: {result.stderr}"
def test_migrate_command_failure(self, tmp_path, temp_migrations_dir):
"""Should exit 1 on migration failure."""
# Use a path that doesn't exist yet
db_path = tmp_path / "test.db"
# Create a broken migration file
migration_file = temp_migrations_dir / "0001-broken.sql"
migration_file.write_text("THIS IS INVALID SQL;")
# Set required env vars
env = os.environ.copy()
env["DB_PATH"] = str(db_path)
env["CSRF_SECRET"] = "test-secret-for-csrf"
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
# Run migrate command
result = subprocess.run(
[sys.executable, "-m", "animaltrack.cli", "migrate"],
capture_output=True,
text=True,
env=env,
cwd=str(temp_migrations_dir.parent),
)
assert result.returncode == 1
class TestCreateMigrationCLI:
"""End-to-end tests for create-migration command."""
def test_create_migration_command(self, temp_migrations_dir):
"""Should create migration file via CLI."""
# Set required env vars
env = os.environ.copy()
env["CSRF_SECRET"] = "test-secret-for-csrf"
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
# Run create-migration command
result = subprocess.run(
[sys.executable, "-m", "animaltrack.cli", "create-migration", "add users table"],
capture_output=True,
text=True,
env=env,
cwd=str(temp_migrations_dir.parent),
)
assert result.returncode == 0, f"stdout: {result.stdout}, stderr: {result.stderr}"
# Verify file was created
created_files = list(temp_migrations_dir.glob("0001-*.sql"))
assert len(created_files) == 1
assert "add-users-table" in created_files[0].name
def test_create_migration_shows_path(self, temp_migrations_dir):
"""Should print path to created migration."""
# Set required env vars
env = os.environ.copy()
env["CSRF_SECRET"] = "test-secret-for-csrf"
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
# Run create-migration command
result = subprocess.run(
[sys.executable, "-m", "animaltrack.cli", "create-migration", "test migration"],
capture_output=True,
text=True,
env=env,
cwd=str(temp_migrations_dir.parent),
)
assert result.returncode == 0
assert "0001-test-migration.sql" in result.stdout
def test_create_migration_increments_index(self, temp_migrations_dir):
"""Should create migrations with incrementing indexes."""
# Set required env vars
env = os.environ.copy()
env["CSRF_SECRET"] = "test-secret-for-csrf"
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
# Create first migration
subprocess.run(
[sys.executable, "-m", "animaltrack.cli", "create-migration", "first"],
capture_output=True,
env=env,
cwd=str(temp_migrations_dir.parent),
)
# Create second migration
result = subprocess.run(
[sys.executable, "-m", "animaltrack.cli", "create-migration", "second"],
capture_output=True,
text=True,
env=env,
cwd=str(temp_migrations_dir.parent),
)
assert result.returncode == 0
assert "0002-second.sql" in result.stdout
def test_create_migration_requires_description(self, temp_migrations_dir):
"""Should fail when description not provided."""
env = os.environ.copy()
env["CSRF_SECRET"] = "test-secret-for-csrf"
env["PYTHONPATH"] = str(PROJECT_ROOT / "src")
result = subprocess.run(
[sys.executable, "-m", "animaltrack.cli", "create-migration"],
capture_output=True,
text=True,
env=env,
cwd=str(temp_migrations_dir.parent),
)
# argparse should reject missing required argument
assert result.returncode != 0

367
tests/test_migrations.py Normal file
View File

@@ -0,0 +1,367 @@
# ABOUTME: Tests for the migration framework.
# ABOUTME: Validates migration discovery, creation, and application.
import pytest
class TestGetNextMigrationIndex:
"""Test discovery of next migration index."""
def test_empty_directory_returns_1(self, temp_migrations_dir):
"""First migration should be index 1."""
from animaltrack.migrations import get_next_migration_index
result = get_next_migration_index(temp_migrations_dir)
assert result == 1
def test_nonexistent_directory_returns_1(self, tmp_path):
"""Non-existent directory should return 1."""
from animaltrack.migrations import get_next_migration_index
nonexistent = tmp_path / "does_not_exist"
result = get_next_migration_index(nonexistent)
assert result == 1
def test_finds_highest_index(self, temp_migrations_dir):
"""Should find highest existing index and increment."""
from animaltrack.migrations import get_next_migration_index
# Create some migration files
(temp_migrations_dir / "0001-first.sql").write_text("-- migration 1")
(temp_migrations_dir / "0002-second.sql").write_text("-- migration 2")
result = get_next_migration_index(temp_migrations_dir)
assert result == 3
def test_handles_gaps_in_sequence(self, temp_migrations_dir):
"""Should handle non-contiguous indexes."""
from animaltrack.migrations import get_next_migration_index
# Create migrations with gaps
(temp_migrations_dir / "0001-first.sql").write_text("-- migration 1")
(temp_migrations_dir / "0003-third.sql").write_text("-- migration 3")
(temp_migrations_dir / "0005-fifth.sql").write_text("-- migration 5")
result = get_next_migration_index(temp_migrations_dir)
assert result == 6
def test_handles_python_migrations(self, temp_migrations_dir):
"""Should handle .py migration files."""
from animaltrack.migrations import get_next_migration_index
(temp_migrations_dir / "0001-first.sql").write_text("-- sql")
(temp_migrations_dir / "0002-second.py").write_text("# python")
result = get_next_migration_index(temp_migrations_dir)
assert result == 3
def test_handles_shell_migrations(self, temp_migrations_dir):
"""Should handle .sh migration files."""
from animaltrack.migrations import get_next_migration_index
(temp_migrations_dir / "0001-first.sql").write_text("-- sql")
(temp_migrations_dir / "0002-second.sh").write_text("#!/bin/bash")
result = get_next_migration_index(temp_migrations_dir)
assert result == 3
def test_ignores_non_migration_files(self, temp_migrations_dir):
"""Should ignore files that don't match migration pattern."""
from animaltrack.migrations import get_next_migration_index
# Create non-migration files
(temp_migrations_dir / "0001-first.sql").write_text("-- migration")
(temp_migrations_dir / "README.md").write_text("# readme")
(temp_migrations_dir / ".gitkeep").write_text("")
(temp_migrations_dir / "random.txt").write_text("random")
result = get_next_migration_index(temp_migrations_dir)
assert result == 2
class TestSanitizeDescription:
"""Test description sanitization for filenames."""
def test_lowercases_text(self):
"""Should convert to lowercase."""
from animaltrack.migrations import sanitize_description
result = sanitize_description("Add USERS Table")
assert result == "add-users-table"
def test_replaces_spaces_with_hyphens(self):
"""Should replace spaces with hyphens."""
from animaltrack.migrations import sanitize_description
result = sanitize_description("add users table")
assert result == "add-users-table"
def test_removes_special_characters(self):
"""Should remove special characters."""
from animaltrack.migrations import sanitize_description
result = sanitize_description("Add Users Table!")
assert result == "add-users-table"
def test_collapses_multiple_hyphens(self):
"""Should collapse multiple hyphens into one."""
from animaltrack.migrations import sanitize_description
result = sanitize_description("add users table")
assert result == "add-users-table"
def test_strips_leading_trailing_hyphens(self):
"""Should strip leading and trailing hyphens."""
from animaltrack.migrations import sanitize_description
result = sanitize_description(" add users table ")
assert result == "add-users-table"
def test_handles_numbers(self):
"""Should keep numbers."""
from animaltrack.migrations import sanitize_description
result = sanitize_description("Fix Bug 123")
assert result == "fix-bug-123"
def test_handles_empty_after_sanitization(self):
"""Should handle strings that become empty after sanitization."""
from animaltrack.migrations import sanitize_description
result = sanitize_description("!!!")
assert result == ""
class TestCreateMigration:
"""Test migration file creation."""
def test_creates_file_with_correct_name(self, temp_migrations_dir):
"""Should create migration with 4-digit index and description."""
from animaltrack.migrations import create_migration
path = create_migration(temp_migrations_dir, "add users table")
assert path.name == "0001-add-users-table.sql"
assert path.exists()
def test_includes_aboutme_header(self, temp_migrations_dir):
"""Created migration should have ABOUTME comments."""
from animaltrack.migrations import create_migration
path = create_migration(temp_migrations_dir, "add users table")
content = path.read_text()
assert "-- ABOUTME:" in content
assert "add users table" in content
def test_increments_index(self, temp_migrations_dir):
"""Second migration should have index 2."""
from animaltrack.migrations import create_migration
path1 = create_migration(temp_migrations_dir, "first migration")
path2 = create_migration(temp_migrations_dir, "second migration")
assert path1.name == "0001-first-migration.sql"
assert path2.name == "0002-second-migration.sql"
def test_returns_pathlib_path(self, temp_migrations_dir):
"""Should return pathlib.Path object."""
from pathlib import Path
from animaltrack.migrations import create_migration
result = create_migration(temp_migrations_dir, "test")
assert isinstance(result, Path)
def test_creates_directory_if_not_exists(self, tmp_path):
"""Should create migrations directory if it doesn't exist."""
from animaltrack.migrations import create_migration
nonexistent = tmp_path / "new_migrations"
path = create_migration(nonexistent, "test")
assert nonexistent.exists()
assert path.exists()
class TestGetDbVersion:
"""Test database version retrieval."""
def test_returns_zero_for_new_versioned_db(self, fresh_db_path):
"""New versioned database should be version 0."""
import fastmigrate
from animaltrack.migrations import get_db_version
# Initialize db with fastmigrate
fastmigrate.create_db(fresh_db_path)
result = get_db_version(fresh_db_path)
assert result == 0
def test_raises_for_nonexistent_db(self, tmp_path):
"""Should raise FileNotFoundError for non-existent database."""
from animaltrack.migrations import get_db_version
nonexistent = tmp_path / "does_not_exist.db"
with pytest.raises(FileNotFoundError):
get_db_version(nonexistent)
class TestRunMigrations:
"""Test migration execution."""
def test_initializes_unversioned_db(self, fresh_db_path, temp_migrations_dir):
"""Should initialize database if not versioned."""
from animaltrack.migrations import get_db_version, run_migrations
# Fresh db - run migrations should initialize it
success = run_migrations(fresh_db_path, temp_migrations_dir, verbose=False)
assert success is True
# Should now have version 0 (no migrations to run)
version = get_db_version(fresh_db_path)
assert version == 0
def test_runs_pending_migrations(self, fresh_db_path, temp_migrations_dir):
"""Should execute all pending migrations in order."""
from animaltrack.db import get_db
from animaltrack.migrations import get_db_version, run_migrations
# Create migrations
(temp_migrations_dir / "0001-create-table.sql").write_text(
"CREATE TABLE test1 (id INTEGER PRIMARY KEY);"
)
(temp_migrations_dir / "0002-add-column.sql").write_text(
"CREATE TABLE test2 (id INTEGER PRIMARY KEY);"
)
success = run_migrations(fresh_db_path, temp_migrations_dir, verbose=False)
assert success is True
# Version should be 2
assert get_db_version(fresh_db_path) == 2
# Tables should exist
db = get_db(fresh_db_path)
tables = db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'test%'"
).fetchall()
table_names = [t[0] for t in tables]
assert "test1" in table_names
assert "test2" in table_names
def test_skips_applied_migrations(self, fresh_db_path, temp_migrations_dir):
"""Should not re-run already applied migrations."""
from animaltrack.migrations import get_db_version, run_migrations
# Create migration and run
(temp_migrations_dir / "0001-create-table.sql").write_text(
"CREATE TABLE test1 (id INTEGER PRIMARY KEY);"
)
run_migrations(fresh_db_path, temp_migrations_dir, verbose=False)
assert get_db_version(fresh_db_path) == 1
# Add another migration and run again
(temp_migrations_dir / "0002-another-table.sql").write_text(
"CREATE TABLE test2 (id INTEGER PRIMARY KEY);"
)
success = run_migrations(fresh_db_path, temp_migrations_dir, verbose=False)
assert success is True
assert get_db_version(fresh_db_path) == 2
def test_stops_on_migration_failure(self, fresh_db_path, temp_migrations_dir):
"""Should stop at first failing migration."""
from animaltrack.migrations import get_db_version, run_migrations
# Create migrations - second one will fail
(temp_migrations_dir / "0001-good.sql").write_text(
"CREATE TABLE test1 (id INTEGER PRIMARY KEY);"
)
(temp_migrations_dir / "0002-bad.sql").write_text("THIS IS INVALID SQL;")
(temp_migrations_dir / "0003-never-runs.sql").write_text(
"CREATE TABLE test3 (id INTEGER PRIMARY KEY);"
)
success = run_migrations(fresh_db_path, temp_migrations_dir, verbose=False)
assert success is False
# Version should be 1 (stopped at failure)
assert get_db_version(fresh_db_path) == 1
def test_returns_true_when_no_migrations(self, fresh_db_path, temp_migrations_dir):
"""Should return True when no migrations to run."""
from animaltrack.migrations import run_migrations
success = run_migrations(fresh_db_path, temp_migrations_dir, verbose=False)
assert success is True
class TestMigrationIntegration:
"""Integration tests for full migration lifecycle."""
def test_create_and_run_migration(self, fresh_db_path, temp_migrations_dir):
"""Should create migration file and successfully apply it."""
from animaltrack.db import get_db
from animaltrack.migrations import (
create_migration,
get_db_version,
run_migrations,
)
# Create migration
path = create_migration(temp_migrations_dir, "create users")
# Edit it to have actual SQL
path.write_text(
"""-- ABOUTME: Migration 0001 - create users
-- ABOUTME: Creates the users table.
CREATE TABLE users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL
);
"""
)
# Run migrations
success = run_migrations(fresh_db_path, temp_migrations_dir, verbose=False)
assert success is True
assert get_db_version(fresh_db_path) == 1
# Verify table exists
db = get_db(fresh_db_path)
result = db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
).fetchone()
assert result is not None
def test_multiple_migrations_in_sequence(self, fresh_db_path, temp_migrations_dir):
"""Should handle creating and running multiple migrations."""
from animaltrack.db import get_db
from animaltrack.migrations import (
create_migration,
get_db_version,
run_migrations,
)
# Create and populate multiple migrations
path1 = create_migration(temp_migrations_dir, "create users")
path1.write_text("CREATE TABLE users (id TEXT PRIMARY KEY);")
path2 = create_migration(temp_migrations_dir, "create products")
path2.write_text("CREATE TABLE products (id TEXT PRIMARY KEY);")
path3 = create_migration(temp_migrations_dir, "create orders")
path3.write_text("CREATE TABLE orders (id TEXT PRIMARY KEY);")
# Run all
success = run_migrations(fresh_db_path, temp_migrations_dir, verbose=False)
assert success is True
assert get_db_version(fresh_db_path) == 3
# Verify all tables exist
db = get_db(fresh_db_path)
for table_name in ["users", "products", "orders"]:
result = db.execute(
f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table_name}'"
).fetchone()
assert result is not None, f"Table {table_name} should exist"