Files
migrate/operations_test.go

335 lines
12 KiB
Go

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)
})
}
}