feat(migrate): add lifecycle commands and standalone authoring
This commit is contained in:
1 parent
3cd3f49c11
commit
2727ac83db
12 files changed
+1553
-31
No files matched your search
@@ -0,0 +1,289 @@
|
||||
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, unlike DownTo(0).
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user