diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 39b6bf4..6d763a3 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -2,6 +2,7 @@ package database import ( "context" + "database/sql" "fmt" "path/filepath" "testing" @@ -100,3 +101,139 @@ func TestDatabaseConcurrentAccess(t *testing.T) { t.Errorf("expected 10 chunks, got %d", count) } } + +func TestParseMigrationVersion(t *testing.T) { + tests := []struct { + name string + filename string + wantVer int + wantError bool + }{ + {name: "valid 000.sql", filename: "000.sql", wantVer: 0, wantError: false}, + {name: "valid 001.sql", filename: "001.sql", wantVer: 1, wantError: false}, + {name: "valid 099.sql", filename: "099.sql", wantVer: 99, wantError: false}, + {name: "valid with description", filename: "001_initial_schema.sql", wantVer: 1, wantError: false}, + {name: "valid large version", filename: "123_big_migration.sql", wantVer: 123, wantError: false}, + {name: "invalid alpha version", filename: "abc.sql", wantVer: 0, wantError: true}, + {name: "invalid mixed chars", filename: "12a.sql", wantVer: 0, wantError: true}, + {name: "invalid no extension", filename: "schema.sql", wantVer: 0, wantError: true}, + {name: "empty string", filename: "", wantVer: 0, wantError: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := ParseMigrationVersion(tc.filename) + if tc.wantError { + if err == nil { + t.Errorf("ParseMigrationVersion(%q) = %d, nil; want error", tc.filename, got) + } + return + } + if err != nil { + t.Errorf("ParseMigrationVersion(%q) unexpected error: %v", tc.filename, err) + return + } + if got != tc.wantVer { + t.Errorf("ParseMigrationVersion(%q) = %d; want %d", tc.filename, got, tc.wantVer) + } + }) + } +} + +func TestApplyMigrations_Idempotent(t *testing.T) { + ctx := context.Background() + + conn, err := sql.Open("sqlite", ":memory:?_foreign_keys=ON") + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + defer func() { + if err := conn.Close(); err != nil { + t.Errorf("failed to close database: %v", err) + } + }() + + conn.SetMaxOpenConns(1) + conn.SetMaxIdleConns(1) + + // First run: apply all migrations. + if err := applyMigrations(ctx, conn); err != nil { + t.Fatalf("first applyMigrations failed: %v", err) + } + + // Count rows in schema_migrations after first run. + var countBefore int + if err := conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM schema_migrations").Scan(&countBefore); err != nil { + t.Fatalf("failed to count schema_migrations after first run: %v", err) + } + + // Second run: must be a no-op. + if err := applyMigrations(ctx, conn); err != nil { + t.Fatalf("second applyMigrations failed: %v", err) + } + + // Count rows in schema_migrations after second run — must be unchanged. + var countAfter int + if err := conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM schema_migrations").Scan(&countAfter); err != nil { + t.Fatalf("failed to count schema_migrations after second run: %v", err) + } + + if countBefore != countAfter { + t.Errorf("schema_migrations row count changed: before=%d, after=%d", countBefore, countAfter) + } +} + +func TestBootstrapMigrationsTable_FreshDatabase(t *testing.T) { + ctx := context.Background() + + conn, err := sql.Open("sqlite", ":memory:?_foreign_keys=ON") + if err != nil { + t.Fatalf("failed to open database: %v", err) + } + defer func() { + if err := conn.Close(); err != nil { + t.Errorf("failed to close database: %v", err) + } + }() + + conn.SetMaxOpenConns(1) + conn.SetMaxIdleConns(1) + + // Verify schema_migrations does NOT exist yet. + var tableBefore int + if err := conn.QueryRowContext(ctx, + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'", + ).Scan(&tableBefore); err != nil { + t.Fatalf("failed to check for table before bootstrap: %v", err) + } + if tableBefore != 0 { + t.Fatal("schema_migrations table should not exist before bootstrap") + } + + // Run bootstrap. + if err := bootstrapMigrationsTable(ctx, conn); err != nil { + t.Fatalf("bootstrapMigrationsTable failed: %v", err) + } + + // Verify schema_migrations now exists. + var tableAfter int + if err := conn.QueryRowContext(ctx, + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'", + ).Scan(&tableAfter); err != nil { + t.Fatalf("failed to check for table after bootstrap: %v", err) + } + if tableAfter != 1 { + t.Fatalf("schema_migrations table should exist after bootstrap, got count=%d", tableAfter) + } + + // Verify version 0 row exists. + var version int + if err := conn.QueryRowContext(ctx, + "SELECT version FROM schema_migrations WHERE version = 0", + ).Scan(&version); err != nil { + t.Fatalf("version 0 row not found in schema_migrations: %v", err) + } + if version != 0 { + t.Errorf("expected version 0, got %d", version) + } +}