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