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:
10
PLAN.md
10
PLAN.md
@@ -24,11 +24,11 @@ Check off items as completed. Each phase builds on the previous.
|
|||||||
- [x] **Commit checkpoint** (d8910d6)
|
- [x] **Commit checkpoint** (d8910d6)
|
||||||
|
|
||||||
### Step 1.3: Migration Framework
|
### Step 1.3: Migration Framework
|
||||||
- [ ] Create `migrations.py` using FastMigrate patterns
|
- [x] Create `migrations.py` using FastMigrate patterns
|
||||||
- [ ] Create `cli.py` with `migrate` and `create-migration` commands
|
- [x] Create `cli.py` with `migrate` and `create-migration` commands
|
||||||
- [ ] Create initial migration for `schema_migrations` table
|
- [x] Create initial migration for `schema_migrations` table
|
||||||
- [ ] Write tests for migration discovery and application
|
- [x] Write tests for migration discovery and application
|
||||||
- [ ] **Commit checkpoint**
|
- [x] **Commit checkpoint**
|
||||||
|
|
||||||
### Step 1.4: Reference Tables Schema
|
### Step 1.4: Reference Tables Schema
|
||||||
- [ ] Create migration for species, locations, products, feed_types, users tables
|
- [ ] Create migration for species, locations, products, feed_types, users tables
|
||||||
|
|||||||
@@ -36,13 +36,32 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
if args.command == "migrate":
|
if args.command == "migrate":
|
||||||
print(f"Running migrations (to={args.to})...")
|
from animaltrack.config import Settings
|
||||||
# TODO: Implement migration runner
|
from animaltrack.migrations import run_migrations
|
||||||
print("Migration not yet implemented")
|
|
||||||
|
settings = Settings()
|
||||||
|
migrations_dir = "migrations"
|
||||||
|
|
||||||
|
print(f"Running migrations from {migrations_dir}...")
|
||||||
|
success = run_migrations(
|
||||||
|
db_path=settings.db_path,
|
||||||
|
migrations_dir=migrations_dir,
|
||||||
|
verbose=True,
|
||||||
|
)
|
||||||
|
if success:
|
||||||
|
print("Migrations completed successfully")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print("Migration failed", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
elif args.command == "create-migration":
|
elif args.command == "create-migration":
|
||||||
print(f"Creating migration: {args.description}")
|
from animaltrack.migrations import create_migration
|
||||||
# TODO: Implement migration creation
|
|
||||||
print("Migration creation not yet implemented")
|
migrations_dir = "migrations"
|
||||||
|
|
||||||
|
filepath = create_migration(migrations_dir, args.description)
|
||||||
|
print(f"Created migration: {filepath}")
|
||||||
|
print(" Edit the file to add your schema changes")
|
||||||
elif args.command == "seed":
|
elif args.command == "seed":
|
||||||
print("Loading seed data...")
|
print("Loading seed data...")
|
||||||
# TODO: Implement seeder
|
# TODO: Implement seeder
|
||||||
|
|||||||
152
src/animaltrack/migrations.py
Normal file
152
src/animaltrack/migrations.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# ABOUTME: Migration framework wrapper for FastMigrate.
|
||||||
|
# ABOUTME: Provides migration creation, discovery, and execution with version control.
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import fastmigrate
|
||||||
|
|
||||||
|
|
||||||
|
def get_next_migration_index(migrations_dir: str | Path) -> int:
|
||||||
|
"""
|
||||||
|
Discover the next migration index by scanning existing migrations.
|
||||||
|
|
||||||
|
Looks for files matching pattern: 0001-*.sql|py|sh
|
||||||
|
Returns highest index + 1, or 1 if directory is empty.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
migrations_dir: Path to migrations directory
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Next sequential migration index (1-based)
|
||||||
|
"""
|
||||||
|
migrations_path = Path(migrations_dir)
|
||||||
|
if not migrations_path.exists():
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Pattern: 0001-description.ext
|
||||||
|
pattern = re.compile(r"^(\d{4})-.*\.(sql|py|sh)$")
|
||||||
|
|
||||||
|
max_index = 0
|
||||||
|
for file in migrations_path.iterdir():
|
||||||
|
if file.is_file():
|
||||||
|
if match := pattern.match(file.name):
|
||||||
|
index = int(match.group(1))
|
||||||
|
max_index = max(max_index, index)
|
||||||
|
|
||||||
|
return max_index + 1
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_description(description: str) -> str:
|
||||||
|
"""
|
||||||
|
Convert migration description to valid filename component.
|
||||||
|
|
||||||
|
- Lowercase
|
||||||
|
- Replace spaces with hyphens
|
||||||
|
- Remove non-alphanumeric except hyphens
|
||||||
|
- Collapse multiple hyphens
|
||||||
|
|
||||||
|
Args:
|
||||||
|
description: Human-readable migration description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sanitized filename-safe string
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
"Add Users Table!" -> "add-users-table"
|
||||||
|
"Fix Bug #123" -> "fix-bug-123"
|
||||||
|
"""
|
||||||
|
# Lowercase and replace spaces with hyphens
|
||||||
|
sanitized = description.lower().replace(" ", "-")
|
||||||
|
# Keep only alphanumeric and hyphens
|
||||||
|
sanitized = re.sub(r"[^a-z0-9-]", "", sanitized)
|
||||||
|
# Collapse multiple hyphens
|
||||||
|
sanitized = re.sub(r"-+", "-", sanitized)
|
||||||
|
# Strip leading/trailing hyphens
|
||||||
|
return sanitized.strip("-")
|
||||||
|
|
||||||
|
|
||||||
|
def create_migration(migrations_dir: str | Path, description: str) -> Path:
|
||||||
|
"""
|
||||||
|
Create a new migration file with next sequential index.
|
||||||
|
|
||||||
|
Generates file: migrations/XXXX-description.sql where XXXX is 4-digit index.
|
||||||
|
Includes ABOUTME header comments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
migrations_dir: Path to migrations directory
|
||||||
|
description: Human-readable migration description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to created migration file
|
||||||
|
|
||||||
|
Example:
|
||||||
|
create_migration("migrations", "add users table")
|
||||||
|
-> Path("migrations/0001-add-users-table.sql")
|
||||||
|
"""
|
||||||
|
migrations_path = Path(migrations_dir)
|
||||||
|
migrations_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
index = get_next_migration_index(migrations_path)
|
||||||
|
sanitized = sanitize_description(description)
|
||||||
|
|
||||||
|
filename = f"{index:04d}-{sanitized}.sql"
|
||||||
|
filepath = migrations_path / filename
|
||||||
|
|
||||||
|
# SQL template with ABOUTME header
|
||||||
|
template = f"""-- ABOUTME: Migration {index:04d} - {description}
|
||||||
|
-- ABOUTME: Created for AnimalTrack database schema versioning.
|
||||||
|
|
||||||
|
-- Write your migration SQL here
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
filepath.write_text(template)
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_version(db_path: str | Path) -> int:
|
||||||
|
"""
|
||||||
|
Get current database migration version.
|
||||||
|
|
||||||
|
Wraps fastmigrate.get_db_version with path conversion.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to SQLite database
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Current migration version (0 for new versioned DB)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If database doesn't exist
|
||||||
|
sqlite3.Error: If database is not versioned
|
||||||
|
"""
|
||||||
|
return fastmigrate.get_db_version(Path(db_path))
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations(
|
||||||
|
db_path: str | Path,
|
||||||
|
migrations_dir: str | Path,
|
||||||
|
verbose: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Run pending database migrations.
|
||||||
|
|
||||||
|
Initializes database if not versioned. Runs migrations in sequential order.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to SQLite database
|
||||||
|
migrations_dir: Path to migrations directory
|
||||||
|
verbose: Print detailed progress messages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if all migrations succeeded, False otherwise
|
||||||
|
"""
|
||||||
|
db_path = Path(db_path)
|
||||||
|
migrations_path = Path(migrations_dir)
|
||||||
|
|
||||||
|
# Ensure database is versioned
|
||||||
|
fastmigrate.create_db(db_path)
|
||||||
|
|
||||||
|
# Run all pending migrations
|
||||||
|
return fastmigrate.run_migrations(db_path, migrations_path, verbose=verbose)
|
||||||
@@ -7,6 +7,33 @@ import tempfile
|
|||||||
import pytest
|
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
|
@pytest.fixture
|
||||||
def temp_db_path():
|
def temp_db_path():
|
||||||
"""Create a temporary database file path for testing."""
|
"""Create a temporary database file path for testing."""
|
||||||
|
|||||||
180
tests/test_cli_migrations.py
Normal file
180
tests/test_cli_migrations.py
Normal 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
367
tests/test_migrations.py
Normal 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"
|
||||||
Reference in New Issue
Block a user