package migrate import ( "bytes" "context" "database/sql" "encoding/json" "errors" "fmt" "io" "log/slog" "strconv" "testing" "testing/fstest" "time" "gitea.auvem.com/go-toolkit/app" "github.com/pressly/goose/v3" "github.com/stretchr/testify/require" _ "modernc.org/sqlite" ) // These tests own the package globals sequentially and use a fresh in-memory DB. func operationsFixture(t *testing.T, versions []int64) (*goose.Provider, *sql.DB, *bytes.Buffer) { t.Helper() db, err := sql.Open("sqlite", ":memory:") require.NoError(t, err) db.SetMaxOpenConns(1) t.Cleanup(func() { require.NoError(t, db.Close()) }) files := fstest.MapFS{} for _, version := range versions { name := fmt.Sprintf("%05d_migration.sql", version) files[name] = &fstest.MapFile{Data: []byte(fmt.Sprintf("-- +goose Up\nCREATE TABLE migration_%d (id INTEGER);\n-- +goose Down\nDROP TABLE migration_%d;\n", version, version))} } p, err := goose.NewProvider(goose.DialectSQLite3, db, files) require.NoError(t, err) logs := installOperationsProvider(t, p) return p, db, logs } func installOperationsProvider(t *testing.T, p *goose.Provider) *bytes.Buffer { t.Helper() oldProvider, oldModule, oldEnabled := migrationProvider, migrationsModule, autoMigrateEnabled t.Cleanup(func() { migrationProvider, migrationsModule, autoMigrateEnabled = oldProvider, oldModule, oldEnabled }) logs := new(bytes.Buffer) mod := app.NewModule(ModuleMigrationsName, app.ModuleOpts{}) lifecycle := app.NewLifecycle(mod).WithLogger(slog.New(slog.NewJSONHandler(logs, nil))) require.NoError(t, lifecycle.Setup()) migrationProvider, migrationsModule, autoMigrateEnabled = p, mod, true logs.Reset() return logs } var identifierHistories = []struct { name string versions []int64 }{ {"contiguous", []int64{1, 2, 3}}, {"small_gaps", []int64{1, 4, 8}}, {"sequential_to_timestamp", []int64{28, 29, 20260929235340}}, {"timestamps", []int64{20260929235340, 20261001010203, 20261005120000}}, {"mixed", []int64{1, 2, 29, 20260929235340, 20261001010203}}, } func seedHistory(t *testing.T, p *goose.Provider, count int) { t.Helper() for range count { _, err := p.UpByOne(t.Context()) require.NoError(t, err) } } func assertHistory(t *testing.T, p *goose.Provider, db *sql.DB, versions []int64, applied int) { t.Helper() current, target, err := p.GetVersions(t.Context()) require.NoError(t, err) var expected int64 if applied > 0 { expected = versions[applied-1] } require.Equal(t, expected, current) require.Equal(t, versions[len(versions)-1], target) var actual int require.NoError(t, db.QueryRow("SELECT COUNT(*) FROM goose_db_version WHERE version_id > 0 AND is_applied = 1").Scan(&actual)) require.Equal(t, applied, actual) } func TestApplyPendingMigrationsIdentifiersAndCaps(t *testing.T) { for _, history := range identifierHistories { for _, initial := range []int{0, 1, len(history.versions)} { for _, cap := range []int64{0, -1, -7, 1, 2, 100} { t.Run(fmt.Sprintf("%s/initial_%d/cap_%d", history.name, initial, cap), func(t *testing.T) { p, db, _ := operationsFixture(t, history.versions) seedHistory(t, p, initial) want := len(history.versions) - initial if cap >= 0 && cap < int64(want) { want = int(cap) } count, err := ApplyPendingMigrations(t.Context(), cap) require.NoError(t, err) require.Equal(t, int64(want), count) assertHistory(t, p, db, history.versions, initial+want) }) } } } } func TestAutoMigrateIdentifiersAndEnabled(t *testing.T) { for _, history := range identifierHistories { for _, initial := range []int{0, 1, len(history.versions)} { for _, enabled := range []bool{false, true} { t.Run(fmt.Sprintf("%s/initial_%d/enabled_%t", history.name, initial, enabled), func(t *testing.T) { p, db, logs := operationsFixture(t, history.versions) seedHistory(t, p, initial) autoMigrateEnabled = enabled err := AutoMigrate(t.Context()) if !enabled && initial < len(history.versions) { require.ErrorIs(t, err, ErrPendingMigrations) assertHistory(t, p, db, history.versions, initial) record := logWithMessage(t, logs, "Pending migrations detected, but auto-migration is disabled. Run `migrate up` to apply them.") require.Equal(t, "ERROR", record["level"]) } else { require.NoError(t, err) assertHistory(t, p, db, history.versions, len(history.versions)) if initial == len(history.versions) { record := logWithMessage(t, logs, "No pending migrations") require.Equal(t, strconv.FormatInt(history.versions[initial-1], 10), record["version"].(json.Number).String()) } else { record := logWithMessage(t, logs, "Applied pending migrations") require.Equal(t, strconv.Itoa(len(history.versions)-initial), record["appliedCount"].(json.Number).String()) require.Equal(t, strconv.FormatInt(history.versions[len(history.versions)-1], 10), record["current"].(json.Number).String()) } } }) } } } } func logRecords(t *testing.T, logs *bytes.Buffer) []map[string]any { t.Helper() decoder := json.NewDecoder(bytes.NewReader(logs.Bytes())) decoder.UseNumber() var records []map[string]any for { var record map[string]any err := decoder.Decode(&record) if errors.Is(err, io.EOF) { return records } require.NoError(t, err) records = append(records, record) } } func logWithMessage(t *testing.T, logs *bytes.Buffer, message string) map[string]any { t.Helper() for _, record := range logRecords(t, logs) { if record["msg"] == message { return record } } t.Fatalf("missing log %q in %s", message, logs) return nil } func TestAutoMigrateLogsRealVersionsAndEmptyMigration(t *testing.T) { p, db, logs := operationsFixture(t, []int64{29}) seedHistory(t, p, 1) files := fstest.MapFS{ "00029_migration.sql": {Data: []byte("-- +goose Up\nSELECT 1;\n-- +goose Down\nSELECT 1;\n")}, "20260929235340_migration.sql": {Data: []byte("-- +goose Up\nSELECT 1;\n-- +goose Down\nSELECT 1;\n")}, "20261001010203_empty.sql": {Data: []byte("-- +goose Up\n-- +goose Down\n")}, } var err error migrationProvider, err = goose.NewProvider(goose.DialectSQLite3, db, files) require.NoError(t, err) require.NoError(t, AutoMigrate(t.Context())) status := logWithMessage(t, logs, "Pending migrations detected, applying them...") require.Equal(t, json.Number("29"), status["current"]) require.Equal(t, json.Number("20261001010203"), status["target"]) for _, expected := range []struct{ message, version, source, level string }{ {"Applied migration", "20260929235340", "20260929235340_migration.sql", "INFO"}, {"Applied empty migration", "20261001010203", "20261001010203_empty.sql", "WARN"}, } { record := logWithMessage(t, logs, expected.message) require.Equal(t, json.Number(expected.version), record["version"]) require.Equal(t, expected.source, record["source"]) require.Equal(t, "up", record["dir"]) require.Equal(t, expected.level, record["level"]) require.Contains(t, record, "duration") } summary := logWithMessage(t, logs, "Applied pending migrations") require.Equal(t, json.Number("20261001010203"), summary["current"]) require.Equal(t, json.Number("2"), summary["appliedCount"]) require.Contains(t, summary, "duration") } func TestMigrationFailureCountsAndLogs(t *testing.T) { for _, auto := range []bool{false, true} { for _, cause := range []error{errors.New("migration failed"), fmt.Errorf("migration failed: %w", goose.ErrNoNextVersion)} { t.Run(fmt.Sprintf("auto_%t/%s", auto, cause), func(t *testing.T) { _, db, logs := operationsFixture(t, []int64{29}) versions := []int64{29, 20260929235340, 20261001010203} p, err := goose.NewProvider(goose.DialectSQLite3, db, nil, goose.WithGoMigrations( goose.NewGoMigration(versions[0], &goose.GoFunc{}, nil), goose.NewGoMigration(versions[1], &goose.GoFunc{}, nil), goose.NewGoMigration(versions[2], &goose.GoFunc{RunTx: func(context.Context, *sql.Tx) error { return cause }}, nil), )) require.NoError(t, err) migrationProvider = p seedHistory(t, p, 1) if auto { err = AutoMigrate(t.Context()) } else { var count int64 count, err = ApplyPendingMigrations(t.Context(), -1) require.Equal(t, int64(1), count) } require.ErrorIs(t, err, cause) var partial *goose.PartialError require.ErrorAs(t, err, &partial) assertHistory(t, p, db, versions, 2) failure := logWithMessage(t, logs, "Couldn't apply migration") require.Equal(t, "ERROR", failure["level"]) require.Equal(t, json.Number("20261001010203"), failure["version"]) require.Equal(t, "up", failure["dir"]) require.Contains(t, failure, "source") require.Contains(t, failure, "duration") require.Contains(t, failure, "err") if auto { summary := logWithMessage(t, logs, "Couldn't apply pending migrations") require.Equal(t, json.Number("20260929235340"), summary["current"]) require.Equal(t, json.Number("20261001010203"), summary["target"]) require.Equal(t, json.Number("1"), summary["appliedCount"]) } }) } } } func TestOperationsProviderAndLookupErrors(t *testing.T) { p, db, logs := operationsFixture(t, []int64{29, 20260929235340}) migrationProvider = nil count, err := ApplyPendingMigrations(t.Context(), -1) require.Zero(t, count) require.ErrorIs(t, err, ErrNotInitialized) require.ErrorIs(t, AutoMigrate(t.Context()), ErrNotInitialized) migrationProvider = p require.NoError(t, db.Close()) count, err = ApplyPendingMigrations(t.Context(), 0) require.NoError(t, err) require.Zero(t, count) for _, cap := range []int64{-1, 1} { count, err = ApplyPendingMigrations(t.Context(), cap) require.Error(t, err) require.Zero(t, count) } require.Error(t, AutoMigrate(t.Context())) failure := logWithMessage(t, logs, "Couldn't check for pending migrations") require.Equal(t, "ERROR", failure["level"]) require.Contains(t, failure, "err") } func TestOperationsPropagateCancellation(t *testing.T) { p, db, _ := operationsFixture(t, []int64{29, 20260929235340}) ctx, cancel := context.WithCancel(t.Context()) cancel() count, err := ApplyPendingMigrations(ctx, -1) require.ErrorIs(t, err, context.Canceled) require.Zero(t, count) require.ErrorIs(t, AutoMigrate(ctx), context.Canceled) assertHistory(t, p, db, []int64{29, 20260929235340}, 0) } type stepResult struct { result *goose.MigrationResult err error } type scriptedStepper struct { steps []stepResult calls int } func (s *scriptedStepper) UpByOne(context.Context) (*goose.MigrationResult, error) { if s.calls >= len(s.steps) { panic("unexpected UpByOne call") } step := s.steps[s.calls] s.calls++ return step.result, step.err } func TestPendingApplicationExhaustionAndErrors(t *testing.T) { failure := errors.New("provider failed") resultFailure := errors.New("result failed") good := &goose.MigrationResult{Source: &goose.Source{Version: 20260929235340, Path: "migration.sql"}, Direction: "up", Duration: time.Millisecond} bad := &goose.MigrationResult{Source: &goose.Source{Version: 20261001010203, Path: "bad.sql"}, Direction: "up", Error: resultFailure} for _, test := range []struct { name string cap int64 steps []stepResult wantCount, wantVersion int64 wantErr error }{ {"zero", 0, nil, 0, 0, nil}, {"positive_cap", 1, []stepResult{{good, nil}}, 1, good.Source.Version, nil}, {"wrapped_exhaustion_unlimited", -1, []stepResult{{good, nil}, {nil, fmt.Errorf("no pending: %w", goose.ErrNoNextVersion)}}, 1, good.Source.Version, nil}, {"wrapped_exhaustion_cap", 100, []stepResult{{good, nil}, {nil, fmt.Errorf("no pending: %w", goose.ErrNoNextVersion)}}, 1, good.Source.Version, nil}, {"already_current", -1, []stepResult{{nil, goose.ErrNoNextVersion}}, 0, 0, nil}, {"provider_error", -1, []stepResult{{good, nil}, {nil, failure}}, 1, good.Source.Version, failure}, {"result_error", -1, []stepResult{{good, nil}, {bad, nil}}, 1, good.Source.Version, resultFailure}, {"error_with_result", -1, []stepResult{{good, failure}}, 0, 0, failure}, {"nil_result", -1, []stepResult{{nil, nil}}, 0, 0, nil}, } { t.Run(test.name, func(t *testing.T) { stepper := &scriptedStepper{steps: test.steps} logger := slog.New(slog.NewJSONHandler(io.Discard, nil)) count, version, err := applyPendingMigrations(t.Context(), stepper, logger, test.cap) if test.wantErr != nil { require.ErrorIs(t, err, test.wantErr) } else { require.NoError(t, err) } require.Equal(t, test.wantCount, count) require.Equal(t, test.wantVersion, version) require.Equal(t, len(test.steps), stepper.calls) }) } }