335 lines
12 KiB
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)
|
|
})
|
|
}
|
|
}
|