From 8af4af806e1f76a8cac7d2f4a49ed8a96ff3c86f Mon Sep 17 00:00:00 2001 From: Brandur Leach Date: Sun, 17 Mar 2024 13:44:38 -0700 Subject: [PATCH] General clean up of `testdbman` program + drop Cobra dependency (#266) This one's mainly in pursuit of dropping the Cobra dependency in the main River package. It's still nice to have Cobra for the River CLI, but the only reason that top-level River needs it is that `testdbman` (the command used to drop and raise test databases needs it). Since `testdbman`'s CLI API is so simple anyway, here we move it off of Cobra and over to a small internal CLI framework, maintaining an identical interface as before (with the small change that `--help` is now `-help` since we're using Go's internal flag module). This would normally be a dumb thing to do, but I don't expect the `testdbman` interface to have to get anymore complicated (it hasn't changed at all since River's inception), the internal framework isn't a huge amount of code, and I put some test coverage on it to make sure that everything works as expected. In addition to that, we do a little housekeeping on the internal code for `testdbman`: * Reduce visual noise by stripping extra lines and functions that were doing very little, and removing some specific contexts in favor of one top-level one for the program. * Move the core functions and command definitions into one file so that quick file find works a little better and it's easier to get a holistic view of what everything is doing. Especially after trimming code, there's so little code that it all fits together without feeling crowded, and previously some of the files like `reset.go` were doing practically nothing. * A single unified error handling schema. Previously some code was printing to stderr and aborting the program and some code was returning errors. Now everything returns an error with a single top-level error handler. * Make output and error formatting more consistent across commands. * Tighten up some documentation and comments that'd fallen out of date since the program was originally written. * Remove some helper functions like `dbURL` that were doing more to obscure than to help (it had a special path for the management database URL and then did a string concatenation otherwise, but it's much more clear to just use a constant for the management database URL when we know that we want it). Add helpers where appropriate like `generateTestDBNames` that let us put a little (very modest amount for the time being) test coverage onto the program. --- .golangci.yaml | 1 + go.mod | 3 - go.sum | 8 - internal/cmd/testdbman/create.go | 97 ------- internal/cmd/testdbman/drop.go | 102 ------- internal/cmd/testdbman/main.go | 403 +++++++++++++++++++++++++++- internal/cmd/testdbman/main_test.go | 268 ++++++++++++++++++ internal/cmd/testdbman/reset.go | 22 -- 8 files changed, 659 insertions(+), 245 deletions(-) delete mode 100644 internal/cmd/testdbman/create.go delete mode 100644 internal/cmd/testdbman/drop.go create mode 100644 internal/cmd/testdbman/main_test.go delete mode 100644 internal/cmd/testdbman/reset.go diff --git a/.golangci.yaml b/.golangci.yaml index c1bf07e9..f4af2bcb 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -95,6 +95,7 @@ linters-settings: - id - j - mu + - sb # common convention for string builder - t - tt # common convention for table tests - tx diff --git a/go.mod b/go.mod index d6f0c0e1..a4e92135 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,6 @@ require ( github.com/riverqueue/river/riverdriver/riverpgxv5 v0.0.25 github.com/riverqueue/river/rivertype v0.0.25 github.com/robfig/cron/v3 v3.0.1 - github.com/spf13/cobra v1.8.0 github.com/stretchr/testify v1.9.0 go.uber.org/goleak v1.3.0 golang.org/x/mod v0.16.0 @@ -28,12 +27,10 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/lib/pq v1.10.9 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect golang.org/x/crypto v0.17.0 // indirect golang.org/x/text v0.14.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 71fd1082..9c61bbab 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,6 @@ -github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -26,11 +23,6 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= -github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/internal/cmd/testdbman/create.go b/internal/cmd/testdbman/create.go deleted file mode 100644 index 37620437..00000000 --- a/internal/cmd/testdbman/create.go +++ /dev/null @@ -1,97 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "runtime" - "time" - - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/spf13/cobra" - - "github.com/riverqueue/river/riverdriver/riverpgxv5" - "github.com/riverqueue/river/rivermigrate" -) - -func init() { //nolint:gochecknoinits - rootCmd.AddCommand(createCmd) -} - -var createCmd = &cobra.Command{ //nolint:gochecknoglobals - Use: "create", - Short: "Create the test databases", - Long: ` -Creates the test databases used by parallel tests and the sample applications. -Each is loaded with the schema from river.Schema. - -The sample application DB is named river_testdb, while the DBs for parallel -tests are named river_testdb_0, river_testdb_1, etc. up to the larger of 4 or -runtime.NumCPU() (a choice that comes from pgx's default connection pool size). - `, - Run: func(cmd *cobra.Command, args []string) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - mgmtConn, err := pgx.Connect(ctx, dbURL("")) - if err != nil { - fmt.Printf("Unable to create connection: %v\n", err) - os.Exit(1) - } - defer mgmtConn.Close(ctx) - - createDBAndLoadSchema := func(ctx context.Context, dbName string) error { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - if _, err := mgmtConn.Exec(ctx, "CREATE DATABASE "+dbName); err != nil { - return fmt.Errorf("error running `CREATE DATABASE %s`: %w", dbName, err) - } - fmt.Printf("Created database %s.\n", dbName) - - dbPool, err := pgxpool.New(ctx, dbURL(dbName)) - if err != nil { - return fmt.Errorf("error creating connection pool to %q: %w", dbURL(dbName), err) - } - defer dbPool.Close() - - migrator := rivermigrate.New(riverpgxv5.New(dbPool), nil) - - if _, err = migrator.Migrate(ctx, rivermigrate.DirectionUp, &rivermigrate.MigrateOpts{}); err != nil { - return err - } - fmt.Printf("Loaded schema in %s.\n", dbName) - - return nil - } - - mustCreateDBAndLoadSchema := func(ctx context.Context, dbName string) { - if err := createDBAndLoadSchema(ctx, dbName); err != nil { - fmt.Fprintf(os.Stderr, "%s\n", err) - os.Exit(1) - } - } - - mustCreateDBAndLoadSchema(ctx, "river_testdb") - mustCreateDBAndLoadSchema(ctx, "river_testdb_example") - - // This is the same default as pgxpool's maximum number of connections - // when not specified -- either 4 or the number of CPUs, whichever is - // greater. If changing this number, also change the similar value in - // `riverinternaltest` where it's duplicated. - numDBs := max(4, runtime.NumCPU()) - - for i := 0; i < numDBs; i++ { - mustCreateDBAndLoadSchema(ctx, fmt.Sprintf("river_testdb_%d", i)) - } - }, -} - -// TODO: smarter way to figure out db url, dynamic, etc. -func dbURL(dbName string) string { - if dbName == "" { - return "postgres:///postgres" - } - return "postgres:///" + dbName -} diff --git a/internal/cmd/testdbman/drop.go b/internal/cmd/testdbman/drop.go deleted file mode 100644 index 440f8c9e..00000000 --- a/internal/cmd/testdbman/drop.go +++ /dev/null @@ -1,102 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "strings" - "time" - - "github.com/jackc/pgx/v5" - "github.com/spf13/cobra" -) - -func init() { //nolint:gochecknoinits - rootCmd.AddCommand(dropCmd) -} - -var ignoreNonExistent bool //nolint:gochecknoglobals - -var dropCmd = &cobra.Command{ //nolint:gochecknoglobals - Use: "drop", - Short: "Drop the test database", - Long: ` -Drops all test databases. Any test database matching the base name -(river_testdb) or the base name with an underscore and a number (river_testdb_0, -river_testdb_1, etc.) will be dropped. - `, - Run: func(cmd *cobra.Command, args []string) { - if err := drop(context.Background()); err != nil { - fmt.Fprintf(os.Stderr, "failed: %s", err.Error()) - os.Exit(1) - } - }, -} - -func attemptDropDB(ctx context.Context, mgmtConn *pgx.Conn, dbNameToDelete string) error { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - if _, err := mgmtConn.Exec(ctx, "DROP DATABASE "+dbNameToDelete); err != nil { - if ignoreNonExistent && strings.Contains(err.Error(), fmt.Sprintf("database %q does not exist", dbNameToDelete)) { - fmt.Printf("Database %s does not exist, ignoring reset\n", dbNameToDelete) - return nil - } - - return fmt.Errorf("dropping database %q failed: %w", dbNameToDelete, err) - } - fmt.Printf("Dropped database %s\n", dbNameToDelete) - - return nil -} - -func drop(ctx context.Context) error { - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - // List all databases: - mgmtConn, err := getMgmtConn(ctx) - if err != nil { - return fmt.Errorf("error getting management connection: %w", err) - } - defer mgmtConn.Close(ctx) - - rows, err := mgmtConn.Query(ctx, "SELECT datname FROM pg_database") - if err != nil { - return fmt.Errorf("error listing databases: %w", err) - } - defer rows.Close() - - allDBs := make([]string, 0) - for rows.Next() { - var dbName string - err := rows.Scan(&dbName) - if err != nil { - return fmt.Errorf("error scanning DB name: %w", err) - } - allDBs = append(allDBs, dbName) - } - rows.Close() - - for _, dbName := range allDBs { - if strings.HasPrefix(dbName, "river_testdb") { - if err := attemptDropDB(ctx, mgmtConn, dbName); err != nil { - return err - } - } - } - - return nil -} - -func getMgmtConn(ctx context.Context) (*pgx.Conn, error) { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - mgmtConn, err := pgx.Connect(ctx, dbURL("")) - if err != nil { - return nil, fmt.Errorf("unable to connect to management DB: %w", err) - } - - return mgmtConn, nil -} diff --git a/internal/cmd/testdbman/main.go b/internal/cmd/testdbman/main.go index 7f4e24c0..e4e4770b 100644 --- a/internal/cmd/testdbman/main.go +++ b/internal/cmd/testdbman/main.go @@ -1,26 +1,403 @@ +// testdbman is a command-line tool for managing the test databases used by +// parallel tests and the sample applications. package main import ( + "context" + "errors" + "flag" "fmt" + "io" "os" + "runtime" + "slices" + "strings" + "time" - "github.com/spf13/cobra" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/riverqueue/river/internal/util/maputil" + "github.com/riverqueue/river/riverdriver/riverpgxv5" + "github.com/riverqueue/river/rivermigrate" ) -// testdbman is a command-line tool for managing the test databases used by -// parallel tests and the sample applications. +func main() { + commandBundle := NewCommandBundle( + "testdbman", + "testdbman manages test databases", + ` +A small program to create and manage test databases. River currently requires a +number of different of test databases loaded with its schema for it to be able +to run the full test suite in parallel. -var rootCmd = &cobra.Command{ //nolint:gochecknoglobals - Use: "testdbman", - Short: "testdbman manages test databases", - Run: func(cmd *cobra.Command, args []string) { - _ = cmd.Usage() - }, -} +Run "testdbman create" to raise all required test databases and prepare for a +test run. + `, + ) -func main() { - if err := rootCmd.Execute(); err != nil { - fmt.Printf("failed: %s\n", err) + // create + { + commandBundle.AddCommand( + "create", + "Create test databases", + ` +Creates the test databases used by parallel tests and the sample applications. +Each is migrated with River's current schema. + +The sample application DB is named river_testdb, while the DBs for parallel +tests are named river_testdb_0, river_testdb_1, etc. up to the larger of 4 or +runtime.NumCPU() (a choice that comes from pgx's default connection pool size). +`, + createTestDatabases, + ) + } + + // drop + { + commandBundle.AddCommand( + "drop", + "Drop test databases", + ` +Drops all test databases. Any test database matching the base name +(river_testdb) or the base name with an underscore followed by any other token +(river_testdb_example, river_testdb_0, river_testdb_1, etc.) will be dropped. +`, + dropTestDatabases, + ) + } + + // reset + { + commandBundle.AddCommand( + "reset", + "Drop and recreate test databases", + ` +Reset the test databases, dropping the existing database(s) if they exist, and +recreating them with the most up to date schema. Equivalent to running "drop" +followed by "create". +`, + resetTestDatabases, + ) + } + + ctx := context.Background() + + if err := commandBundle.Exec(ctx, os.Args); err != nil { + fmt.Fprintf(os.Stderr, "failed: "+err.Error()+"\n") os.Exit(1) } } + +// +// Commands +// + +const managementDatabaseURL = "postgres:///postgres" + +// +// Helpers +// + +func createTestDatabases(ctx context.Context, out io.Writer) error { + mgmtConn, err := pgx.Connect(ctx, managementDatabaseURL) + if err != nil { + return fmt.Errorf("error opening management connection: %w", err) + } + defer mgmtConn.Close(ctx) + + createDBAndMigrate := func(dbName string) error { + if _, err := mgmtConn.Exec(ctx, "CREATE DATABASE "+dbName); err != nil { + return fmt.Errorf("error crating database %q: %w", dbName, err) + } + fmt.Fprintf(out, "created: %-20s", dbName) + + // Defer printing a newline, which will be either added to the end of a + // successful invocation of this command (after the string "[and + // migrated]" has been printed to the current line), or printed before + // returning an error so that in either case output looks right. + defer fmt.Fprintf(out, "\n") + + dbURL := "postgres:///" + dbName + + dbPool, err := pgxpool.New(ctx, dbURL) + if err != nil { + return fmt.Errorf("error creating connection pool to %q: %w", dbURL, err) + } + defer dbPool.Close() + + migrator := rivermigrate.New(riverpgxv5.New(dbPool), nil) + if _, err = migrator.Migrate(ctx, rivermigrate.DirectionUp, &rivermigrate.MigrateOpts{}); err != nil { + return err + } + fmt.Fprintf(out, " [and migrated]") + + return nil + } + + // This is the same default as pgxpool's maximum number of connections + // when not specified -- either 4 or the number of CPUs, whichever is + // greater. If changing this number, also change the similar value in + // `riverinternaltest` where it's duplicated. + dbNames := generateTestDBNames(max(4, runtime.NumCPU())) + + for _, dbName := range dbNames { + if err := createDBAndMigrate(dbName); err != nil { + return err + } + } + + return nil +} + +func generateTestDBNames(numDBs int) []string { + dbNames := []string{ + "river_testdb", + "river_testdb_example", + } + + for i := 0; i < numDBs; i++ { + dbNames = append(dbNames, fmt.Sprintf("river_testdb_%d", i)) + } + + return dbNames +} + +func dropTestDatabases(ctx context.Context, out io.Writer) error { + mgmtConn, err := pgx.Connect(ctx, managementDatabaseURL) + if err != nil { + return fmt.Errorf("error opening management connection: %w", err) + } + defer mgmtConn.Close(ctx) + + rows, err := mgmtConn.Query(ctx, "SELECT datname FROM pg_database") + if err != nil { + return fmt.Errorf("error listing databases: %w", err) + } + defer rows.Close() + + allDBNames := make([]string, 0) + for rows.Next() { + var dbName string + err := rows.Scan(&dbName) + if err != nil { + return fmt.Errorf("error scanning database name: %w", err) + } + allDBNames = append(allDBNames, dbName) + } + rows.Close() + + for _, dbName := range allDBNames { + if strings.HasPrefix(dbName, "river_testdb") { + if _, err := mgmtConn.Exec(ctx, "DROP DATABASE "+dbName); err != nil { + if err != nil { + return fmt.Errorf("error dropping database %q: %w", dbName, err) + } + } + fmt.Fprintf(out, "dropped: %s\n", dbName) + } + } + + return nil +} + +func resetTestDatabases(ctx context.Context, out io.Writer) error { + if err := dropTestDatabases(ctx, out); err != nil { + return err + } + + if err := createTestDatabases(ctx, out); err != nil { + return err + } + + return nil +} + +// +// Command bundle framework +// + +// CommandBundle is a basic CLI command framework similar to Cobra, but with far +// reduced capabilities. I know it seems crazy to write one when Cobra is +// available, but the test manager's interface is quite simple, and not using +// Cobra lets us drop its dependency in the main River package. +type CommandBundle struct { + commands map[string]*commandBundleCommand + long string + out io.Writer + short string + use string +} + +func NewCommandBundle(use, short, long string) *CommandBundle { + if use == "" { + panic("use is required") + } + if short == "" { + panic("short is required") + } + if short == "" { + panic("long is required") + } + + return &CommandBundle{ + commands: make(map[string]*commandBundleCommand), + long: long, + out: os.Stdout, + short: short, + use: use, + } +} + +func (b *CommandBundle) AddCommand(use, short, long string, execFunc func(ctx context.Context, out io.Writer) error) { + if use == "" { + panic("use is required") + } + if short == "" { + panic("short is required") + } + if long == "" { + panic("long is required") + } + if execFunc == nil { + panic("execFunc is required") + } + + if _, ok := b.commands[use]; ok { + panic("command already registered: " + use) + } + + b.commands[use] = &commandBundleCommand{ + execFunc: execFunc, + long: long, + short: short, + use: use, + } +} + +const helpUse = "help" + +func (b *CommandBundle) Exec(ctx context.Context, args []string) error { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + var ( + flagSet flag.FlagSet + help bool + ) + flagSet.BoolVar(&help, "help", false, "help for program or command") + + args = args[1:] // drop program name + + var commandUse string + if len(args) > 0 && args[0][0] != '-' { + commandUse = args[0] + args = args[1:] + } + + if err := flagSet.Parse(args); err != nil { + return err + } + + args = flagSet.Args() + + // Try extracting a command again after flags are parsed and we didn't get + // one on the first pass. + if commandUse == "" && len(args) > 0 { + commandUse = args[0] + args = args[1:] + } + + if commandUse != "" && commandUse != helpUse && len(args) > 0 || len(args) > 1 { + return errors.New("expected exactly one command") + } + + if commandUse == "" || commandUse == helpUse && len(args) < 1 { + fmt.Fprintf(b.out, b.printUsage(&flagSet)+"\n") + return nil + } + + if commandUse == "help" { + commandUse = args[0] + help = true + } + + command, ok := b.commands[commandUse] + if !ok { + return errors.New("unknown command: " + commandUse) + } + + if help { + fmt.Fprintf(b.out, command.printUsage(b.use, &flagSet)+"\n") + return nil + } + + return command.execFunc(ctx, b.out) +} + +func (b *CommandBundle) printUsage(flagSet *flag.FlagSet) string { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf(` +%s + +Usage: + %s [command] [flags] + +Available Commands: +`, strings.TrimSpace(b.long), b.use)) + + var longestUse int + for use := range b.commands { + if len(use) > longestUse { + longestUse = len(use) + } + } + + // Go's maps are unordered of course. Alphabetize. + sortedCommandUses := maputil.Keys(b.commands) + slices.Sort(sortedCommandUses) + + for _, use := range sortedCommandUses { + command := b.commands[use] + sb.WriteString(fmt.Sprintf(" %-*s %s\n", longestUse, use, command.short)) + } + + sb.WriteString("\nFlags:\n") + flagSet.SetOutput(&sb) + flagSet.PrintDefaults() + + sb.WriteString(fmt.Sprintf(` +Use "%s [command] -help" for more information about a command. + `, b.use)) + + // Go's flag module loves tabs of course. Kill them in favor of spaces, + // which are easier to test against. + return strings.TrimSpace(strings.ReplaceAll(sb.String(), "\t", " ")) +} + +type commandBundleCommand struct { + execFunc func(ctx context.Context, out io.Writer) error + long string + short string + use string +} + +func (b *commandBundleCommand) printUsage(bundleUse string, flagSet *flag.FlagSet) string { + var sb strings.Builder + + sb.WriteString(fmt.Sprintf(` +%s + +Usage: + %s %s [flags] +`, strings.TrimSpace(b.long), bundleUse, b.use)) + + sb.WriteString("\nFlags:\n") + flagSet.SetOutput(&sb) + flagSet.PrintDefaults() + + // Go's flag module loves tabs of course. Kill them in favor of spaces, + // which are easier to test against. + return strings.TrimSpace(strings.ReplaceAll(sb.String(), "\t", " ")) +} diff --git a/internal/cmd/testdbman/main_test.go b/internal/cmd/testdbman/main_test.go new file mode 100644 index 00000000..030ae1d0 --- /dev/null +++ b/internal/cmd/testdbman/main_test.go @@ -0,0 +1,268 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGenerateTestDBNames(t *testing.T) { + t.Parallel() + + require.Equal(t, []string{ + "river_testdb", + "river_testdb_example", + "river_testdb_0", + "river_testdb_1", + "river_testdb_2", + "river_testdb_3", + }, generateTestDBNames(4)) +} + +// +// Command bundle framework +// + +func TestCommandBundle(t *testing.T) { + t.Parallel() + + var ( + baseArgs = []string{"fake-program-name"} // os args always include a program name + ctx = context.Background() + ) + + type testBundle struct { + buf *bytes.Buffer + command1Invoked *bool + command2Invoked *bool + } + + setup := func() (*CommandBundle, *testBundle) { + commandBundle := NewCommandBundle( + "testcom", + "testcom is a test bundle for use tests", + ` +A test only program for testing the command bundle framework, especially some +complexities around how it emits output. This is the long description and is +meant to be wrapped at 80 characters in your editor. + +It may be multiple paragraphs. This is a second paragraph with additional +information and context. + `, + ) + + var command1Invoked bool + { + commandBundle.AddCommand( + "command1", + "The program's first command", + ` +This is a long description for the program's first command. It acts somewhat +like a mock, setting a boolean to true that we can easily check in tests in case +the program makes the decision to invoke it. +`, + func(ctx context.Context, out io.Writer) error { + fmt.Fprintf(out, "command1 executed\n") + command1Invoked = true + return nil + }, + ) + } + + var command2Invoked bool + { + commandBundle.AddCommand( + "command2", + "The program's second command", + ` +This is a long description for the program's second command. It's the same as +the first command, and acts somewhat like a mock, setting a boolean to true that +we can easily check in tests in case the program makes the decision to invoke +it. +`, + func(ctx context.Context, out io.Writer) error { + fmt.Fprintf(out, "command2 executed\n") + command2Invoked = true + return nil + }, + ) + } + { + commandBundle.AddCommand( + "makeerror", + "A command that returns an error", + ` +The long description for a command that returns an error that we can check +against in tests to make sure that piece of the puzzle works as expected. +`, + func(ctx context.Context, out io.Writer) error { + fmt.Fprintf(out, "makeerror executed\n") + return errors.New("command error!") + }, + ) + } + + var buf bytes.Buffer + commandBundle.out = &buf + + return commandBundle, &testBundle{ + buf: &buf, + command1Invoked: &command1Invoked, + command2Invoked: &command2Invoked, + } + } + + expectedCommandBundleUsage := strings.TrimSpace(` +A test only program for testing the command bundle framework, especially some +complexities around how it emits output. This is the long description and is +meant to be wrapped at 80 characters in your editor. + +It may be multiple paragraphs. This is a second paragraph with additional +information and context. + +Usage: + testcom [command] [flags] + +Available Commands: + command1 The program's first command + command2 The program's second command + makeerror A command that returns an error + +Flags: + -help + help for program or command + +Use "testcom [command] -help" for more information about a command. + `) + "\n" + + t.Run("ShowsUsageWithHelpArgument", func(t *testing.T) { + t.Parallel() + + commandBundle, bundle := setup() + + require.NoError(t, commandBundle.Exec(ctx, append(baseArgs, "-help"))) + + require.Equal(t, expectedCommandBundleUsage, bundle.buf.String()) + }) + + t.Run("ShowsUsageWithHelpCommand", func(t *testing.T) { + t.Parallel() + + commandBundle, bundle := setup() + + require.NoError(t, commandBundle.Exec(ctx, append(baseArgs, "help"))) + + require.Equal(t, expectedCommandBundleUsage, bundle.buf.String()) + }) + + t.Run("ShowsUsageWithNoArguments", func(t *testing.T) { + t.Parallel() + + commandBundle, bundle := setup() + + require.NoError(t, commandBundle.Exec(ctx, baseArgs)) + + require.Equal(t, expectedCommandBundleUsage, bundle.buf.String()) + }) + + expectedCommand1Usage := strings.TrimSpace(` +This is a long description for the program's first command. It acts somewhat +like a mock, setting a boolean to true that we can easily check in tests in case +the program makes the decision to invoke it. + +Usage: + testcom command1 [flags] + +Flags: + -help + help for program or command + `) + "\n" + + t.Run("ShowsCommandUsageWithHelpArgument", func(t *testing.T) { + t.Parallel() + + commandBundle, bundle := setup() + + require.NoError(t, commandBundle.Exec(ctx, append(baseArgs, "command1", "-help"))) + + require.Equal(t, expectedCommand1Usage, bundle.buf.String()) + }) + + t.Run("ShowsCommandUsageWithHelpCommand", func(t *testing.T) { + t.Parallel() + + commandBundle, bundle := setup() + + require.NoError(t, commandBundle.Exec(ctx, append(baseArgs, "help", "command1"))) + + require.Equal(t, expectedCommand1Usage, bundle.buf.String()) + }) + + t.Run("ShowsCommandUsageWithMisorderedHelpArgument", func(t *testing.T) { + t.Parallel() + + commandBundle, bundle := setup() + + require.NoError(t, commandBundle.Exec(ctx, append(baseArgs, "-help", "command1"))) + + require.Equal(t, expectedCommand1Usage, bundle.buf.String()) + }) + + t.Run("ErrorsOnTooManyArguments", func(t *testing.T) { + t.Parallel() + + commandBundle, _ := setup() + + require.EqualError(t, commandBundle.Exec(ctx, append(baseArgs, "command1", "command2")), + "expected exactly one command") + }) + + t.Run("ErrorsOnUnknownCommand", func(t *testing.T) { + t.Parallel() + + commandBundle, _ := setup() + + require.EqualError(t, commandBundle.Exec(ctx, append(baseArgs, "command3")), + "unknown command: command3") + }) + + t.Run("RunsCommand", func(t *testing.T) { + t.Parallel() + + commandBundle, bundle := setup() + + require.NoError(t, commandBundle.Exec(ctx, append(baseArgs, "command1"))) + require.True(t, *bundle.command1Invoked) + require.False(t, *bundle.command2Invoked) + + require.Equal(t, "command1 executed\n", bundle.buf.String()) + }) + + t.Run("DisambiguatesCommands", func(t *testing.T) { + t.Parallel() + + commandBundle, bundle := setup() + + require.NoError(t, commandBundle.Exec(ctx, append(baseArgs, "command2"))) + require.False(t, *bundle.command1Invoked) + require.True(t, *bundle.command2Invoked) + + require.Equal(t, "command2 executed\n", bundle.buf.String()) + }) + + t.Run("ReturnsErrorFromCommand", func(t *testing.T) { + t.Parallel() + + commandBundle, bundle := setup() + + require.EqualError(t, commandBundle.Exec(ctx, append(baseArgs, "makeerror")), "command error!") + + require.Equal(t, "makeerror executed\n", bundle.buf.String()) + }) +} diff --git a/internal/cmd/testdbman/reset.go b/internal/cmd/testdbman/reset.go deleted file mode 100644 index cebf853d..00000000 --- a/internal/cmd/testdbman/reset.go +++ /dev/null @@ -1,22 +0,0 @@ -package main - -import ( - "github.com/spf13/cobra" -) - -func init() { //nolint:gochecknoinits - rootCmd.AddCommand(resetCmd) -} - -var resetCmd = &cobra.Command{ //nolint:gochecknoglobals - Use: "reset", - Short: "Drop and recreate test databases", - Long: ` -Reset the test databases, dropping the existing database(s) if they exist and -recreating them with the correct schema -`, - Run: func(cmd *cobra.Command, args []string) { - dropCmd.Run(cmd, args) - createCmd.Run(cmd, args) - }, -}