Files
go/migrate/lifecycle/lifecycle_test.go
T
end 66abdaf30c
Documentation / docs (pull_request) Successful in 16s
ci / check (pull_request) Successful in 35s
docs(migrate): qualify Goose zero-target behavior
2026-10-28 15:30:39 -07:00

290 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package lifecycle_test
import (
"bytes"
"context"
"database/sql"
"embed"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"reflect"
"strings"
"syscall"
"testing"
"time"
"github.com/pressly/goose/v3"
urfavecli "github.com/urfave/cli/v3"
"go.auvem.com/corestack/lifecycle"
"go.auvem.com/corestack/lifecycle/cli"
"go.auvem.com/corestack/migrate"
adapter "go.auvem.com/corestack/migrate/lifecycle"
_ "modernc.org/sqlite"
)
//go:embed testdata
var files embed.FS
func config() lifecycle.Config {
return lifecycle.Config{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
}
func database(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Error(err)
}
})
return db
}
func provider(t *testing.T, db *sql.DB) *goose.Provider {
t.Helper()
p, err := migrate.New(db, goose.DialectSQLite3, files, "testdata/migrations", config().Logger)
if err != nil {
t.Fatal(err)
}
return p
}
func TestStartupPolicies(t *testing.T) {
for _, policy := range []adapter.Policy{adapter.Apply, adapter.Require} {
for _, applied := range []bool{false, true} {
t.Run(string(policy)+"/applied="+stringBool(applied), func(t *testing.T) {
db := database(t)
p := provider(t, db)
if applied {
if _, err := p.Up(t.Context()); err != nil {
t.Fatal(err)
}
}
var calls []string
var setupCtx context.Context
dbModule := lifecycle.Module{Name: "db", Setup: func(ctx context.Context) error { setupCtx = ctx; calls = append(calls, "db"); return nil }, Teardown: func(ctx context.Context) error {
if ctx.Err() != nil {
t.Error(ctx.Err())
}
calls = append(calls, "close")
return nil
}}
module, err := adapter.Module("migrations", policy, func(ctx context.Context) (*goose.Provider, error) {
if ctx != setupCtx {
t.Error("setup context changed")
}
calls = append(calls, "resolve")
return provider(t, db), nil
})
if err != nil {
t.Fatal(err)
}
if len(calls) != 0 || module.Run != nil {
t.Fatal("declaration started work")
}
app, err := lifecycle.New(config())
if err != nil {
t.Fatal(err)
}
later := lifecycle.Module{Name: "later", Setup: func(context.Context) error { calls = append(calls, "later"); return nil }, Run: func(context.Context) error { calls = append(calls, "service"); return nil }, Teardown: func(context.Context) error { calls = append(calls, "later-close"); return nil }}
for _, m := range []lifecycle.Module{dbModule, module, later} {
if err := app.Register(m); err != nil {
t.Fatal(err)
}
}
err = app.Run(t.Context())
if policy == adapter.Require && !applied {
if err == nil || !strings.Contains(err.Error(), "pending") {
t.Fatalf("pending Require: %v", err)
}
if !reflect.DeepEqual(calls, []string{"db", "resolve", "close"}) {
t.Fatal(calls)
}
} else {
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(calls, []string{"db", "resolve", "later", "service", "later-close", "close"}) {
t.Fatal(calls)
}
pending, err := p.HasPending(t.Context())
if err != nil || pending {
t.Fatalf("pending=%v err=%v", pending, err)
}
}
if err := db.PingContext(t.Context()); err != nil {
t.Fatalf("adapter closed caller DB: %v", err)
}
})
}
}
}
func stringBool(v bool) string {
if v {
return "true"
}
return "false"
}
func TestModuleValidationAndErrors(t *testing.T) {
sentinel := errors.New("resolve failure")
for _, tc := range []struct {
name string
policy adapter.Policy
resolver func(context.Context) (*goose.Provider, error)
}{{"", adapter.Apply, func(context.Context) (*goose.Provider, error) { return nil, nil }}, {"m", "", func(context.Context) (*goose.Provider, error) { return nil, nil }}, {"m", "invalid", func(context.Context) (*goose.Provider, error) { return nil, nil }}, {"m", adapter.Apply, nil}} {
if _, err := adapter.Module(tc.name, tc.policy, tc.resolver); err == nil {
t.Fatal("accepted invalid module")
}
}
for _, policy := range []adapter.Policy{adapter.Apply, adapter.Require} {
for _, resolver := range []func(context.Context) (*goose.Provider, error){func(context.Context) (*goose.Provider, error) { return nil, nil }, func(context.Context) (*goose.Provider, error) { return nil, sentinel }, func(context.Context) (*goose.Provider, error) {
db := database(t)
p := provider(t, db)
if err := db.Close(); err != nil {
t.Fatal(err)
}
return p, nil
}} {
m, err := adapter.Module("m", policy, resolver)
if err != nil {
t.Fatal(err)
}
if err := m.Setup(t.Context()); err == nil {
t.Fatal("accepted resolver/DB failure")
}
}
}
m, err := adapter.Module("m", adapter.Apply, func(context.Context) (*goose.Provider, error) { return nil, sentinel })
if err != nil {
t.Fatal(err)
}
if err := m.Setup(t.Context()); !errors.Is(err, sentinel) {
t.Fatal(err)
}
}
func run(t *testing.T, args []string, modules []lifecycle.Module, resolver func(context.Context) (*goose.Provider, error), writer io.Writer) (int, error) {
t.Helper()
root := &urfavecli.Command{Name: "app", Writer: writer, ErrWriter: io.Discard, Commands: []*urfavecli.Command{adapter.Commands(config(), modules, resolver)}}
return cli.Run(t.Context(), root, append([]string{"app", "migrate"}, args...))
}
func TestCommandsSQLite(t *testing.T) {
db := database(t)
var out bytes.Buffer
var calls []string
module := lifecycle.Module{Name: "db", Setup: func(context.Context) error { calls = append(calls, "setup"); return nil }, Run: func(context.Context) error { t.Error("one-off ran service"); return nil }, Teardown: func(context.Context) error { calls = append(calls, "cleanup"); return nil }}
resolver := func(context.Context) (*goose.Provider, error) {
calls = append(calls, "resolve")
return provider(t, db), nil
}
for _, step := range []struct {
args []string
version int64
}{{[]string{"up-to", "1"}, 1}, {[]string{"up"}, 2}, {[]string{"status"}, 2}, {[]string{"down"}, 1}, {[]string{"up-to", "2"}, 2}, {[]string{"down-to", "0"}, 0}} {
calls = nil
code, err := run(t, step.args, []lifecycle.Module{module}, resolver, &out)
if code != 0 || err != nil {
t.Fatalf("%v code=%d err=%v", step.args, code, err)
}
version, err := provider(t, db).GetDBVersion(t.Context())
if err != nil || version != step.version {
t.Fatalf("%v version=%d err=%v", step.args, version, err)
}
if !reflect.DeepEqual(calls, []string{"setup", "resolve", "cleanup"}) {
t.Fatal(calls)
}
}
// Zero is valid syntax; Goose UpTo rejects it after setup when migrations are pending.
code, err := run(t, []string{"up-to", "0"}, []lifecycle.Module{module}, resolver, &out)
if code != 1 || err == nil || !strings.Contains(err.Error(), "greater than 0") {
t.Fatalf("zero UpTo: code=%d err=%v", code, err)
}
if !strings.Contains(out.String(), "VERSION") || !strings.Contains(out.String(), "applied") || !strings.Contains(out.String(), "00002_second.sql") {
t.Fatal(out.String())
}
}
func TestHelpAndUsageWithoutSetup(t *testing.T) {
for _, leaf := range []string{"up", "up-to", "down", "down-to", "status"} {
code, err := run(t, []string{leaf, "--help"}, nil, nil, io.Discard)
if code != 0 || err != nil {
t.Fatalf("%s help: %d %v", leaf, code, err)
}
}
module := lifecycle.Module{Name: "db", Setup: func(context.Context) error { t.Error("usage opened DB"); return nil }, Teardown: func(context.Context) error { return nil }}
for _, tc := range []struct {
args []string
code int
}{{nil, 0}, {[]string{"--help"}, 0}, {[]string{"help", "up-to"}, 0}, {[]string{"missing"}, 2}, {[]string{"up", "extra"}, 2}, {[]string{"down", "extra"}, 2}, {[]string{"status", "extra"}, 2}, {[]string{"up-to"}, 2}, {[]string{"down-to"}, 2}, {[]string{"up-to", "1", "2"}, 2}, {[]string{"down-to", "--", "-1"}, 2}, {[]string{"up-to", "+1"}, 2}, {[]string{"up-to", "9223372036854775808"}, 2}, {[]string{"down-to", "1x"}, 2}, {[]string{"up-to", "1"}, 2}} {
code, err := run(t, tc.args, []lifecycle.Module{module}, nil, io.Discard)
if code != tc.code || (code == 0 && err != nil) || (code != 0 && err == nil) {
t.Fatalf("args=%v code=%d err=%v", tc.args, code, err)
}
}
}
type brokenWriter struct{ err error }
func (w brokenWriter) Write([]byte) (int, error) { return 0, w.err }
func TestCommandFailureCleanup(t *testing.T) {
db := database(t)
writeErr := errors.New("write failed")
resolveErr := errors.New("resolve failed")
cleanupErr := errors.New("cleanup failed")
for _, tc := range []struct {
name string
resolver func(context.Context) (*goose.Provider, error)
writer io.Writer
want error
}{{"writer", func(context.Context) (*goose.Provider, error) { return provider(t, db), nil }, brokenWriter{writeErr}, writeErr}, {"resolver", func(context.Context) (*goose.Provider, error) { return nil, resolveErr }, io.Discard, resolveErr}, {"nil", nil, io.Discard, nil}, {"nil-provider", func(context.Context) (*goose.Provider, error) { return nil, nil }, io.Discard, nil}} {
t.Run(tc.name, func(t *testing.T) {
cleaned := false
module := lifecycle.Module{Name: "db", Setup: func(context.Context) error { return nil }, Teardown: func(context.Context) error { cleaned = true; return cleanupErr }}
code, err := run(t, []string{"status"}, []lifecycle.Module{module}, tc.resolver, tc.writer)
if code != 1 || err == nil || !cleaned || !errors.Is(err, cleanupErr) || (tc.want != nil && !errors.Is(err, tc.want)) {
t.Fatalf("code=%d err=%v cleaned=%v", code, err, cleaned)
}
})
}
}
func TestCommandSignalContext(t *testing.T) {
signals := make(chan os.Signal, 2)
cfg := config()
cfg.Signals = signals
entered := make(chan struct{})
done := make(chan error, 1)
cleaned := false
module := lifecycle.Module{Name: "db", Setup: func(context.Context) error { return nil }, Teardown: func(ctx context.Context) error {
if ctx.Err() != nil {
t.Error(ctx.Err())
}
cleaned = true
return nil
}}
root := &urfavecli.Command{Name: "app", Writer: io.Discard, ErrWriter: io.Discard, Commands: []*urfavecli.Command{adapter.Commands(cfg, []lifecycle.Module{module}, func(ctx context.Context) (*goose.Provider, error) {
close(entered)
<-ctx.Done()
return nil, ctx.Err()
})}}
go func() { _, err := cli.Run(t.Context(), root, []string{"app", "migrate", "up"}); done <- err }()
select {
case <-entered:
case <-time.After(5 * time.Second):
t.Fatal("resolver did not start")
}
signals <- syscall.SIGTERM
select {
case err := <-done:
if err != nil || !cleaned {
t.Fatalf("err=%v cleaned=%v", err, cleaned)
}
case <-time.After(5 * time.Second):
t.Fatal("signal did not cancel resolver")
}
}