All checks were successful
Check / check (push) Successful in 5s
Closes #171 Refactors the migration system to follow the [pixa pattern](sneak/pixa#36): ## Changes ### New file: `internal/database/migrations/000.sql` - Bootstrap migration that creates `schema_migrations` with `version INTEGER PRIMARY KEY` - Self-contained: includes both `CREATE TABLE IF NOT EXISTS` and `INSERT OR IGNORE INTO schema_migrations (version) VALUES (0)` ### Refactored: `internal/database/migrations.go` - **Go code no longer creates the migrations table inline** — it reads and executes `000.sql` as a bootstrap step before the normal migration loop - **`ParseMigrationVersion(filename)`** — exported function that extracts integer version from filenames like `001_initial.sql` → `1` - **`ApplyMigrations(ctx, db, log)`** — exported function for tests to apply schema without full fx lifecycle - **`bootstrapMigrationsTable`** — checks if table exists; if missing, runs `000.sql`; if present, checks for legacy format - **`convertLegacyMigrations`** — one-time conversion for existing databases that stored versions as TEXT filenames (e.g. `"001_initial.sql"`) to INTEGER versions (e.g. `1`) - **Transaction-wrapped migration application** — each migration runs in a transaction for atomicity - Sentinel error `ErrInvalidMigrationFilename` for static error wrapping ### New file: `internal/database/migrations_test.go` - `TestParseMigrationVersion` — valid/invalid filename parsing - `TestApplyMigrationsFreshDatabase` — verifies bootstrap creates table, all migrations apply, application tables exist - `TestApplyMigrationsIdempotent` — double-apply is a no-op - `TestApplyMigrationsLegacyConversion` — simulates old TEXT-based table with filename entries, verifies conversion to integer format ## Backward Compatibility Existing databases with the old TEXT-based `schema_migrations` table are automatically converted on first run. The conversion: 1. Detects legacy entries (versions containing `_`) 2. Parses integer version from each legacy filename 3. Drops the old table 4. Creates the new INTEGER-based table via `000.sql` 5. Re-inserts all parsed integer versions All existing migrations (001-007) continue to work unchanged. Co-authored-by: user <user@Mac.lan guest wan> Co-authored-by: clawbot <clawbot@sneak.berlin> Reviewed-on: #172 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
118 lines
2.7 KiB
Go
118 lines
2.7 KiB
Go
package database_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"testing"
|
|
|
|
_ "github.com/mattn/go-sqlite3"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"sneak.berlin/go/upaas/internal/database"
|
|
)
|
|
|
|
func TestParseMigrationVersion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
filename string
|
|
wantVersion int
|
|
wantErr bool
|
|
}{
|
|
{filename: "000_migration.sql", wantVersion: 0},
|
|
{filename: "001_initial.sql", wantVersion: 1},
|
|
{filename: "002_remove_container_id.sql", wantVersion: 2},
|
|
{filename: "007_add_resource_limits.sql", wantVersion: 7},
|
|
{filename: "100_large_version.sql", wantVersion: 100},
|
|
{filename: ".sql", wantErr: true},
|
|
{filename: "_foo.sql", wantErr: true},
|
|
{filename: "abc_foo.sql", wantErr: true},
|
|
{filename: "1a2_bad.sql", wantErr: true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.filename, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
version, err := database.ParseMigrationVersion(tt.filename)
|
|
if tt.wantErr {
|
|
assert.Error(t, err)
|
|
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.wantVersion, version)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestApplyMigrationsFreshDatabase(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=on")
|
|
require.NoError(t, err)
|
|
|
|
defer func() { _ = db.Close() }()
|
|
|
|
ctx := context.Background()
|
|
|
|
err = database.ApplyMigrations(ctx, db, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Verify schema_migrations table exists with INTEGER version column.
|
|
var version int
|
|
|
|
err = db.QueryRowContext(ctx,
|
|
"SELECT version FROM schema_migrations WHERE version = 0",
|
|
).Scan(&version)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, version)
|
|
|
|
// Verify that all migrations were recorded.
|
|
var count int
|
|
|
|
err = db.QueryRowContext(ctx,
|
|
"SELECT COUNT(*) FROM schema_migrations",
|
|
).Scan(&count)
|
|
require.NoError(t, err)
|
|
// 000 bootstrap + 001 through 007 = 8 entries.
|
|
assert.Equal(t, 8, count)
|
|
|
|
// Verify application tables were created by the migrations.
|
|
var tableCount int
|
|
|
|
err = db.QueryRowContext(ctx,
|
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='users'",
|
|
).Scan(&tableCount)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, tableCount)
|
|
}
|
|
|
|
func TestApplyMigrationsIdempotent(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
db, err := sql.Open("sqlite3", ":memory:?_foreign_keys=on")
|
|
require.NoError(t, err)
|
|
|
|
defer func() { _ = db.Close() }()
|
|
|
|
ctx := context.Background()
|
|
|
|
// Apply twice — second run should be a no-op.
|
|
err = database.ApplyMigrations(ctx, db, nil)
|
|
require.NoError(t, err)
|
|
|
|
err = database.ApplyMigrations(ctx, db, nil)
|
|
require.NoError(t, err)
|
|
|
|
var count int
|
|
|
|
err = db.QueryRowContext(ctx,
|
|
"SELECT COUNT(*) FROM schema_migrations",
|
|
).Scan(&count)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 8, count)
|
|
}
|