diff --git a/PLAN.md b/PLAN.md index a53b266..45ffe12 100644 --- a/PLAN.md +++ b/PLAN.md @@ -24,11 +24,11 @@ Check off items as completed. Each phase builds on the previous. - [x] **Commit checkpoint** (d8910d6) ### Step 1.3: Migration Framework -- [ ] Create `migrations.py` using FastMigrate patterns -- [ ] Create `cli.py` with `migrate` and `create-migration` commands -- [ ] Create initial migration for `schema_migrations` table -- [ ] Write tests for migration discovery and application -- [ ] **Commit checkpoint** +- [x] Create `migrations.py` using FastMigrate patterns +- [x] Create `cli.py` with `migrate` and `create-migration` commands +- [x] Create initial migration for `schema_migrations` table +- [x] Write tests for migration discovery and application +- [x] **Commit checkpoint** ### Step 1.4: Reference Tables Schema - [ ] Create migration for species, locations, products, feed_types, users tables diff --git a/src/animaltrack/cli.py b/src/animaltrack/cli.py index b9965d3..fa17ed7 100644 --- a/src/animaltrack/cli.py +++ b/src/animaltrack/cli.py @@ -36,13 +36,32 @@ def main(): sys.exit(1) if args.command == "migrate": - print(f"Running migrations (to={args.to})...") - # TODO: Implement migration runner - print("Migration not yet implemented") + from animaltrack.config import Settings + from animaltrack.migrations import run_migrations + + 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": - print(f"Creating migration: {args.description}") - # TODO: Implement migration creation - print("Migration creation not yet implemented") + from animaltrack.migrations import create_migration + + 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": print("Loading seed data...") # TODO: Implement seeder diff --git a/src/animaltrack/migrations.py b/src/animaltrack/migrations.py new file mode 100644 index 0000000..5fec307 --- /dev/null +++ b/src/animaltrack/migrations.py @@ -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) diff --git a/tests/conftest.py b/tests/conftest.py index 97aa702..e75eb29 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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.""" diff --git a/tests/test_cli_migrations.py b/tests/test_cli_migrations.py new file mode 100644 index 0000000..03eacae --- /dev/null +++ b/tests/test_cli_migrations.py @@ -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 diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..c1c38ca --- /dev/null +++ b/tests/test_migrations.py @@ -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"