diff --git a/cmd/jobsearch/main_test.go b/cmd/jobsearch/main_test.go new file mode 100644 index 0000000..f066e0d --- /dev/null +++ b/cmd/jobsearch/main_test.go @@ -0,0 +1,52 @@ +package main + +import ( + "slices" + "testing" + + "git.avsdev.uk/Go/job-search/internal/cli/cmdtest" +) + +// The commands are tested in their own packages under internal/cli. These +// tests check newRootCmd puts them together. + +func TestCommandTree(t *testing.T) { + root := newRootCmd() + var names []string + for _, c := range root.Commands() { + names = append(names, c.Name()) + } + for _, want := range []string{"config", "db", "fetch", "migrate", "version"} { + if !slices.Contains(names, want) { + t.Errorf("job-search has no %q command; has %v", want, names) + } + } + for _, flag := range []string{"config", "debug", "quiet"} { + if root.PersistentFlags().Lookup(flag) == nil { + t.Errorf("job-search has no --%s flag", flag) + } + } +} + +func TestVersion(t *testing.T) { + cmdtest.Setup(t, "") + cmdtest.Contains(t, cmdtest.MustRun(t, newRootCmd, "version"), "JobSearch - ") +} + +// A command that loads the configuration runs through the real root: its +// flags and hooks, and the subcommand beneath it. +func TestRootRunsCommands(t *testing.T) { + cmdtest.Setup(t, "logging:\n level: info\n") + cmdtest.Contains(t, cmdtest.MustRun(t, newRootCmd, "config", "get", "logging.level"), + "info ") + cmdtest.Contains(t, + cmdtest.MustRun(t, newRootCmd, "config", "get", "logging.level", "--debug"), + "debug ") +} + +func TestUnknownCommand(t *testing.T) { + cmdtest.Setup(t, "") + if _, err := cmdtest.Run(t, newRootCmd, "", "nope"); err == nil { + t.Error("an unknown command was accepted") + } +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..96db38b --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,208 @@ +package cli + +import ( + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "git.avsdev.uk/Go/job-search/internal/logging" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// The commands themselves are tested through the real command tree in +// cmd/jobsearch. These tests cover what that can't reach easily: switching +// to file logging, and Confirm's answers. + +// setupDir changes into a new temporary directory holding an empty .env and +// config.yaml with the given contents, and isolates Viper and logging. +func setupDir(t *testing.T, config string) { + t.Helper() + t.Chdir(t.TempDir()) + writeFile(t, ".env", "") + writeFile(t, "config.yaml", config) + + viper.Reset() + oldLog := slog.Default() + t.Cleanup(func() { + viper.Reset() + if err := logging.Shutdown(); err != nil { + t.Error(err) + } + slog.SetDefault(oldLog) + logging.SetLevel(slog.LevelInfo) + }) +} + +func writeFile(t *testing.T, path, contents string) { + t.Helper() + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } +} + +// newCommand returns a command with the shared flags bound to opts, as the +// root command has, after parsing args. +func newCommand(t *testing.T, opts *Options, args ...string) *cobra.Command { + t.Helper() + cmd := &cobra.Command{Use: "test"} + opts.Bind(cmd) + if err := cmd.ParseFlags(args); err != nil { + t.Fatal(err) + } + return cmd +} + +func TestRunSetupSwitchesToLogFile(t *testing.T) { + setupDir(t, "logging:\n level: info\n file: ./app.log\n") + opts := &Options{} + cmd := newCommand(t, opts) + + conf, err := opts.RunSetup(cmd, true, false) + if err != nil { + t.Fatal(err) + } + if conf.Logging.File != "./app.log" { + t.Errorf("Logging.File = %q", conf.Logging.File) + } + if !cmd.SilenceUsage { + t.Error("RunSetup did not silence usage for runtime errors") + } + if !logging.IsFileLogger() { + t.Fatal("logging was not switched to the log file") + } + + slog.Info("written to the log file") + if err := opts.Finish(); err != nil { + t.Fatal(err) + } + if logging.IsFileLogger() { + t.Error("Finish left the log file open") + } + b, err := os.ReadFile("app.log") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), "written to the log file") { + t.Errorf("app.log = %q", b) + } +} + +func TestRunSetupWithoutLogFileStaysOnTerminal(t *testing.T) { + setupDir(t, "logging:\n level: info\n") + opts := &Options{} + + if _, err := opts.RunSetup(newCommand(t, opts), true, false); err != nil { + t.Fatal(err) + } + if logging.IsFileLogger() { + t.Error("switched to file logging with no log file configured") + } + if err := opts.Finish(); err != nil { + t.Errorf("Finish without a log file: %v", err) + } +} + +func TestRunSetupTerminalLogIgnoresLogFile(t *testing.T) { + setupDir(t, "logging:\n file: ./app.log\n") + opts := &Options{} + + if _, err := opts.RunSetup(newCommand(t, opts), true, true); err != nil { + t.Fatal(err) + } + if logging.IsFileLogger() { + t.Error("switched to file logging when terminal logging was asked for") + } + if _, err := os.Stat("app.log"); err == nil { + t.Error("app.log was created") + } +} + +func TestRunSetupBadLogFile(t *testing.T) { + setupDir(t, "logging:\n file: ./missing-dir/app.log\n") + opts := &Options{} + + _, err := opts.RunSetup(newCommand(t, opts), true, false) + if err == nil || !strings.Contains(err.Error(), "opening log file") { + t.Errorf("err = %v, want opening log file", err) + } +} + +// Without loading, the configuration only reflects the flags. +func TestRunSetupWithoutLoadingConfig(t *testing.T) { + setupDir(t, "logging:\n level: info\n") + + for args, want := range map[string]string{ + "": "", + "--debug": "debug", + "--quiet": "error", + "-d -q": "debug", // --debug wins + } { + opts := &Options{} + conf, err := opts.RunSetup(newCommand(t, opts, strings.Fields(args)...), false, true) + if err != nil { + t.Fatal(err) + } + if conf.Logging.Level != want { + t.Errorf("%q: Logging.Level = %q, want %q", args, conf.Logging.Level, want) + } + } +} + +func TestSetupInitialLogging(t *testing.T) { + setupDir(t, "") + for args, wantDebug := range map[string]bool{ + "--quiet": false, + "--debug": true, + "-q -d": true, // --debug wins + } { + logging.SetLevel(slog.LevelInfo) + SetupInitialLogging(newCommand(t, &Options{}, strings.Fields(args)...)) + if got := logging.IsDebugEnabled(); got != wantDebug { + t.Errorf("%q: debug enabled = %v, want %v", args, got, wantDebug) + } + } +} + +func TestConfirm(t *testing.T) { + for input, want := range map[string]bool{ + "y\n": true, + "YES\n": true, + " Yes \n": true, + "n\n": false, + "No\n": false, + "maybe\n\ny\n": true, // asks again until it gets an answer + "what?\nno\nyes\n": false, + } { + if got := confirmWithInput(t, input); got != want { + t.Errorf("Confirm with input %q = %v, want %v", input, got, want) + } + } +} + +// confirmWithInput runs Confirm with input as standard input, discarding the +// prompt it prints. +func confirmWithInput(t *testing.T, input string) bool { + t.Helper() + dir := t.TempDir() + in := filepath.Join(dir, "stdin") + writeFile(t, in, input) + stdin, err := os.Open(filepath.Clean(in)) + if err != nil { + t.Fatal(err) + } + defer stdin.Close() + stdout, err := os.Create(filepath.Clean(filepath.Join(dir, "stdout"))) + if err != nil { + t.Fatal(err) + } + defer stdout.Close() + + oldIn, oldOut := os.Stdin, os.Stdout + os.Stdin, os.Stdout = stdin, stdout + defer func() { os.Stdin, os.Stdout = oldIn, oldOut }() + + return Confirm("Proceed?") +} diff --git a/internal/cli/cmdtest/cmdtest.go b/internal/cli/cmdtest/cmdtest.go new file mode 100644 index 0000000..1838590 --- /dev/null +++ b/internal/cli/cmdtest/cmdtest.go @@ -0,0 +1,151 @@ +// Package cmdtest runs job-search commands in tests, much as net/http/httptest +// serves handlers. Commands read .env and config.yaml from the working +// directory and print to os.Stdout, so Setup gives each test its own working +// directory and Run swaps os.Stdin and os.Stdout for files. +// +// Tests using it change global state (the working directory, os.Stdin, +// os.Stdout, Viper and the logging level), so they must not run in parallel. +package cmdtest + +import ( + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "git.avsdev.uk/Go/job-search/internal/cli" + "git.avsdev.uk/Go/job-search/internal/logging" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// Setup changes into a new temporary directory holding an empty .env and, +// unless config is empty, config.yaml with those contents. It returns the +// directory. +func Setup(t *testing.T, config string) string { + t.Helper() + dir := t.TempDir() + t.Chdir(dir) + WriteFile(t, ".env", "") + if config != "" { + WriteFile(t, "config.yaml", config) + } + return dir +} + +// WriteFile writes contents to path, failing the test on error. +func WriteFile(t *testing.T, path, contents string) { + t.Helper() + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } +} + +// ReadFile returns the contents of path, failing the test on error. +func ReadFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// Exists reports whether path exists. +func Exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// NewRoot returns a root command set up like job-search's: the shared flags +// bound to opts, logging set up before every command, opts.Finish run after, +// and cmds added beneath it. +func NewRoot(opts *cli.Options, cmds ...*cobra.Command) *cobra.Command { + root := &cobra.Command{ + Use: "job-search", + SilenceUsage: true, + SilenceErrors: true, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + cli.SetupInitialLogging(cmd) + return nil + }, + PersistentPostRunE: func(cmd *cobra.Command, args []string) error { + return opts.Finish() + }, + } + cobra.EnableTraverseRunHooks = true + opts.Bind(root) + root.AddCommand(cmds...) + return root +} + +// Run runs args on a command tree from newRoot, with stdin as standard +// input, and returns everything written to standard output (including log +// messages). Viper and the logging level are reset around each run, as they +// are global. +func Run( + t *testing.T, newRoot func() *cobra.Command, stdin string, args ...string, +) (string, error) { + t.Helper() + viper.Reset() + t.Cleanup(viper.Reset) + + dir := t.TempDir() + inPath, outPath := filepath.Join(dir, "stdin"), filepath.Join(dir, "stdout") + WriteFile(t, inPath, stdin) + in, err := os.Open(filepath.Clean(inPath)) + if err != nil { + t.Fatal(err) + } + defer in.Close() //nolint:errcheck // read only + out, err := os.Create(filepath.Clean(outPath)) + if err != nil { + t.Fatal(err) + } + + oldIn, oldOut, oldLog := os.Stdin, os.Stdout, slog.Default() + os.Stdin, os.Stdout = in, out + defer func() { + os.Stdin, os.Stdout = oldIn, oldOut + slog.SetDefault(oldLog) + logging.SetLevel(slog.LevelInfo) + }() + + root := newRoot() + root.SetArgs(args) + runErr := root.Execute() + + if err := out.Close(); err != nil { + t.Fatal(err) + } + return ReadFile(t, outPath), runErr +} + +// MustRun is Run with no input, for commands that must succeed. +func MustRun(t *testing.T, newRoot func() *cobra.Command, args ...string) string { + t.Helper() + out, err := Run(t, newRoot, "", args...) + if err != nil { + t.Fatalf("job-search %s: %v\n%s", strings.Join(args, " "), err, out) + } + return out +} + +// Contains fails the test unless out contains every string in want. +func Contains(t *testing.T, out string, want ...string) { + t.Helper() + for _, w := range want { + if !strings.Contains(out, w) { + t.Errorf("output does not contain %q:\n%s", w, out) + } + } +} + +// ErrContains fails the test unless err is an error containing want. +func ErrContains(t *testing.T, err error, want string) { + t.Helper() + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("err = %v; want an error containing %q", err, want) + } +} diff --git a/internal/cli/config/config_test.go b/internal/cli/config/config_test.go new file mode 100644 index 0000000..64ce7ec --- /dev/null +++ b/internal/cli/config/config_test.go @@ -0,0 +1,207 @@ +package config + +import ( + "strings" + "testing" + + "git.avsdev.uk/Go/job-search/internal/cli" + "git.avsdev.uk/Go/job-search/internal/cli/cmdtest" + "github.com/spf13/cobra" + "go.yaml.in/yaml/v3" +) + +// These tests run the config commands as a user would, through a root +// command like job-search's (see cmdtest). + +const appConfig = `logging: + level: info +sources: + sleeper: + interval: 2m +` + +func newRoot() *cobra.Command { + opts := &cli.Options{} + return cmdtest.NewRoot(opts, NewCommand(opts)) +} + +func run(t *testing.T, args ...string) (string, error) { + t.Helper() + return cmdtest.Run(t, newRoot, "", args...) +} + +func mustRun(t *testing.T, args ...string) string { + t.Helper() + return cmdtest.MustRun(t, newRoot, args...) +} + +// fileSettings returns the settings in the YAML file at path, flattened to +// dotted keys. +func fileSettings(t *testing.T, path string) map[string]any { + t.Helper() + var nested map[string]any + if err := yaml.Unmarshal([]byte(cmdtest.ReadFile(t, path)), &nested); err != nil { + t.Fatal(err) + } + flat := map[string]any{} + var walk func(prefix string, m map[string]any) + walk = func(prefix string, m map[string]any) { + for k, v := range m { + if sub, ok := v.(map[string]any); ok { + walk(prefix+k+".", sub) + } else { + flat[prefix+k] = v + } + } + } + walk("", nested) + return flat +} + +func TestConfigShow(t *testing.T) { + cmdtest.Setup(t, appConfig) + + out := mustRun(t, "config", "show") + cmdtest.Contains(t, out, + "logging.level = info ", "sources.sleeper.interval = 2m0s ") + // Keys are listed in order, the same every time. + if again := mustRun(t, "config", "show"); again != out { + t.Errorf("config show output changed between runs:\n%s\n---\n%s", out, again) + } + if i, j := strings.Index(out, "database.file"), strings.Index(out, "logging.level"); i > j { + t.Errorf("keys not sorted:\n%s", out) + } + + out = mustRun(t, "config", "show", "--json") + cmdtest.Contains(t, out, `"level": "info"`, `"interval": "2m0s"`) + + out = mustRun(t, "config", "show", "--yaml") + cmdtest.Contains(t, out, "level: info", "interval: 2m0s") +} + +func TestConfigGet(t *testing.T) { + cmdtest.Setup(t, appConfig) + + cmdtest.Contains(t, mustRun(t, "config", "get", "logging.level"), "info ") + cmdtest.Contains(t, mustRun(t, "config", "get", "sources.sleeper.interval"), "2m0s ") + cmdtest.Contains(t, mustRun(t, "config", "get", "sources.sleeper.enabled"), "false ") + + if _, err := run(t, "config", "get", "no.such.key"); err == nil { + t.Error("get of an unknown key succeeded") + } + if _, err := run(t, "config", "get"); err == nil { + t.Error("get without a key succeeded") + } +} + +func TestConfigSet(t *testing.T) { + cmdtest.Setup(t, appConfig) + + out := mustRun(t, "config", "set", "logging.level", "debug") + cmdtest.Contains(t, out, "logging.level = debug ") + cmdtest.Contains(t, mustRun(t, "config", "get", "logging.level"), "debug ") + + out = mustRun(t, "config", "set", "sources.sleeper.enabled", "true") + cmdtest.Contains(t, out, "sources.sleeper.enabled = true ") + + // Durations are stored as given out, e.g. 5m0s. + out = mustRun(t, "config", "set", "sources.sleeper.interval", "5m") + cmdtest.Contains(t, out, "sources.sleeper.interval = 5m0s ") + + // Only the settings the file had, plus those set, are written: no + // defaults. + want := map[string]any{ + "logging.level": "debug", + "sources.sleeper.enabled": true, + "sources.sleeper.interval": "5m0s", + } + if got := fileSettings(t, "config.yaml"); !equalSettings(got, want) { + t.Errorf("config.yaml settings = %v; want %v", got, want) + } + + for _, args := range [][]string{ + {"config", "set", "logging.level", "loud"}, // rejected by validation + {"config", "set", "sources.sleeper.enabled", "yes"}, // not a bool + {"config", "set", "sources.sleeper.interval", "soon"}, + {"config", "set", "no.such.key", "1"}, + {"config", "set", "logging.level"}, // no value, no --unset + } { + if _, err := run(t, args...); err == nil { + t.Errorf("%v succeeded", args) + } + } + if got := fileSettings(t, "config.yaml"); !equalSettings(got, want) { + t.Errorf("failed sets changed config.yaml: %v", got) + } +} + +func equalSettings(a, b map[string]any) bool { + if len(a) != len(b) { + return false + } + for k, v := range a { + if b[k] != v { + return false + } + } + return true +} + +func TestConfigSetUnset(t *testing.T) { + cmdtest.Setup(t, appConfig) + + mustRun(t, "config", "set", "--unset", "sources.sleeper.interval") + // Only the unset key is gone: no defaults are written in its place. + want := map[string]any{"logging.level": "info"} + if got := fileSettings(t, "config.yaml"); !equalSettings(got, want) { + t.Errorf("config.yaml settings = %v; want %v", got, want) + } + // Back to the default. + cmdtest.Contains(t, mustRun(t, "config", "get", "sources.sleeper.interval"), + "10m0s ") +} + +func TestConfigSetWithoutConfigFile(t *testing.T) { + cmdtest.Setup(t, "") + _, err := run(t, "config", "set", "logging.level", "debug") + cmdtest.ErrContains(t, err, "no configuration file exists") +} + +func TestConfigInit(t *testing.T) { + cmdtest.Setup(t, "") + + out := mustRun(t, "config", "init", "./new.yaml") + cmdtest.Contains(t, out, "writing default configuration to file: ./new.yaml") + cmdtest.Contains(t, cmdtest.ReadFile(t, "new.yaml"), "level: warn", "interval: 10m0s") + + _, err := run(t, "config", "init", "./new.yaml") + cmdtest.ErrContains(t, err, "already exists") + + if _, err := run(t, "config", "init", "a.yaml", "b.yaml"); err == nil { + t.Error("init with two paths succeeded") + } +} + +// With no path (or a blank one), init searches the default locations. The +// working directory is one, so its config.yaml is found. (Not finding one +// would write to /etc/job-search, which a test can't do.) +func TestConfigInitDefaultLocations(t *testing.T) { + cmdtest.Setup(t, appConfig) + for _, args := range [][]string{ + {"config", "init"}, + {"config", "init", " "}, + } { + _, err := run(t, args...) + cmdtest.ErrContains(t, err, "already exists") + } +} + +// init takes the path as an argument, so --config isn't shown in its help. +func TestConfigInitHelpHidesConfigFlag(t *testing.T) { + cmdtest.Setup(t, "") + out := mustRun(t, "config", "init", "--help") + cmdtest.Contains(t, out, "init [config_path]", "--debug") + if strings.Contains(out, "--config") { + t.Errorf("init help shows --config:\n%s", out) + } +} diff --git a/internal/cli/flags_test.go b/internal/cli/flags_test.go new file mode 100644 index 0000000..6ded695 --- /dev/null +++ b/internal/cli/flags_test.go @@ -0,0 +1,79 @@ +package cli_test + +import ( + "fmt" + "os" + "testing" + + "git.avsdev.uk/Go/job-search/internal/cli" + "git.avsdev.uk/Go/job-search/internal/cli/cmdtest" + "github.com/spf13/cobra" +) + +// The shared flags (--config, --debug and --quiet), run through a root +// command like job-search's and a command that loads the configuration, as +// every real command does. + +const appConfig = "logging:\n level: info\n" + +// newRoot returns a root command with a "level" command that loads the +// configuration and prints its log level. +func newRoot() *cobra.Command { + opts := &cli.Options{} + return cmdtest.NewRoot(opts, &cobra.Command{ + Use: "level", + RunE: func(cmd *cobra.Command, args []string) error { + conf, err := opts.RunSetup(cmd, true, true) + if err != nil { + return err + } + fmt.Println("level:", conf.Logging.Level) + return nil + }, + }) +} + +// --debug and --quiet override the configured log level; --debug wins. +func TestLogLevelFlags(t *testing.T) { + cmdtest.Setup(t, appConfig) + + for _, tt := range []struct { + args []string + want string + }{ + {nil, "level: info"}, + {[]string{"--debug"}, "level: debug"}, + {[]string{"-d"}, "level: debug"}, + {[]string{"--quiet"}, "level: error"}, + {[]string{"-q"}, "level: error"}, + {[]string{"-q", "-d"}, "level: debug"}, + } { + out := cmdtest.MustRun(t, newRoot, append([]string{"level"}, tt.args...)...) + cmdtest.Contains(t, out, tt.want) + } +} + +// --config points the command at another file. +func TestConfigFlag(t *testing.T) { + cmdtest.Setup(t, appConfig) + cmdtest.WriteFile(t, "other.yaml", "logging:\n level: error\n") + + for _, flag := range []string{"--config", "-c"} { + out := cmdtest.MustRun(t, newRoot, "level", flag, "other.yaml") + cmdtest.Contains(t, out, "level: error") + } + + _, err := cmdtest.Run(t, newRoot, "", "level", "--config", "missing.yaml") + if err == nil { + t.Error("a missing --config file was accepted") + } +} + +func TestMissingEnvFile(t *testing.T) { + cmdtest.Setup(t, appConfig) + if err := os.Remove(".env"); err != nil { + t.Fatal(err) + } + _, err := cmdtest.Run(t, newRoot, "", "level") + cmdtest.ErrContains(t, err, ".env") +} diff --git a/internal/cli/migrate/common_test.go b/internal/cli/migrate/common_test.go new file mode 100644 index 0000000..ef4daa4 --- /dev/null +++ b/internal/cli/migrate/common_test.go @@ -0,0 +1,101 @@ +package migrate + +import ( + "bytes" + "errors" + "strings" + "testing" + "time" + + "github.com/pressly/goose/v3" +) + +// The commands are tested through the real command tree in cmd/jobsearch. +// These tests cover reporting a failed migration, which a working set of +// migrations can't trigger. + +func result(path, direction string) *goose.MigrationResult { + return &goose.MigrationResult{ + Source: &goose.Source{Path: "migrations/" + path}, + Direction: direction, + Duration: 1500 * time.Microsecond, + } +} + +func TestFailurePartial(t *testing.T) { + cause := errors.New("no such table: missing") + var out bytes.Buffer + err := failure(&out, &goose.PartialError{ + Applied: []*goose.MigrationResult{result("00001_first.sql", "up")}, + Failed: result("00002_second.sql", "up"), + Err: cause, + }) + + if want := " applied 00001_first.sql (2ms)\n"; out.String() != want { + t.Errorf("output = %q, want %q", out.String(), want) + } + if !errors.Is(err, cause) { + t.Errorf("err = %v, want it to wrap the migration error", err) + } + for _, want := range []string{"00002_second.sql", "rolled back", "last version that succeeded"} { + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("err = %v, want it to mention %q", err, want) + } + } +} + +func TestFailureWithoutFailedSource(t *testing.T) { + err := failure(&bytes.Buffer{}, &goose.PartialError{Err: errors.New("boom")}) + if err == nil || !strings.Contains(err.Error(), "unknown migration") { + t.Errorf("err = %v, want unknown migration", err) + } +} + +func TestFailureOtherError(t *testing.T) { + cause := errors.New("database is locked") + var out bytes.Buffer + if err := failure(&out, cause); err != cause { + t.Errorf("err = %v, want the error unchanged", err) + } + if out.Len() != 0 { + t.Errorf("printed %q for a non-migration error", out.String()) + } +} + +func TestPrintResults(t *testing.T) { + var out bytes.Buffer + err := printResults(&out, []*goose.MigrationResult{ + result("00001_first.sql", "up"), + result("00002_second.sql", "down"), + }) + if err != nil { + t.Fatal(err) + } + want := " applied 00001_first.sql (2ms)\n reverted 00002_second.sql (2ms)\n" + if out.String() != want { + t.Errorf("output = %q, want %q", out.String(), want) + } +} + +// failingWriter fails every write. +type failingWriter struct{} + +var errWrite = errors.New("write failed") + +func (failingWriter) Write([]byte) (int, error) { return 0, errWrite } + +// A write error is returned, and the failed migration is still reported. +func TestWriteErrors(t *testing.T) { + results := []*goose.MigrationResult{result("00001_first.sql", "up")} + if err := printResults(failingWriter{}, results); !errors.Is(err, errWrite) { + t.Errorf("printResults: err = %v, want the write error", err) + } + + cause := errors.New("boom") + err := failure(failingWriter{}, &goose.PartialError{ + Applied: results, Failed: result("00002_second.sql", "up"), Err: cause, + }) + if !errors.Is(err, cause) || !errors.Is(err, errWrite) { + t.Errorf("failure: err = %v, want both the migration and write errors", err) + } +} diff --git a/internal/cli/migrate/migrate_test.go b/internal/cli/migrate/migrate_test.go new file mode 100644 index 0000000..0366423 --- /dev/null +++ b/internal/cli/migrate/migrate_test.go @@ -0,0 +1,167 @@ +package migrate + +import ( + "fmt" + "path/filepath" + "testing" + + "git.avsdev.uk/Go/job-search/internal/cli" + "git.avsdev.uk/Go/job-search/internal/cli/cmdtest" + "git.avsdev.uk/Go/job-search/internal/store" + "github.com/spf13/cobra" +) + +// Run with: go test -tags sqlite_fts5 ./... +// +// These tests run the migrate commands as a user would, through a root +// command like job-search's (see cmdtest). + +// dbConfig is a config.yaml with a database in the test's directory. +const dbConfig = "logging:\n level: info\ndatabase:\n file: ./test.db\n" + +func newRoot() *cobra.Command { + opts := &cli.Options{} + return cmdtest.NewRoot(opts, NewCommand(opts)) +} + +func run(t *testing.T, stdin string, args ...string) (string, error) { + t.Helper() + return cmdtest.Run(t, newRoot, stdin, args...) +} + +func mustRun(t *testing.T, args ...string) string { + t.Helper() + return cmdtest.MustRun(t, newRoot, args...) +} + +// latestVersion is the schema version of this build's newest migration. +func latestVersion(t *testing.T) int64 { + t.Helper() + db, err := store.OpenDB(filepath.Join(t.TempDir(), "version.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + m, err := store.NewMigrator(db) + if err != nil { + t.Fatal(err) + } + return store.LatestVersion(m) +} + +func TestMigrateUpCreateThenStatus(t *testing.T) { + cmdtest.Setup(t, dbConfig) + latest := latestVersion(t) + + out := mustRun(t, "migrate", "up", "--create") + cmdtest.Contains(t, out, + "applied 00001_create_opportunities_table.sql", + fmt.Sprintf("Database migrated to version %d", latest), + ) + if !cmdtest.Exists("test.db") { + t.Fatal("test.db was not created") + } + + out = mustRun(t, "migrate", "status") + cmdtest.Contains(t, out, + fmt.Sprintf("Database version %d of %d (up-to-date)", latest, latest), + "00001_create_opportunities_table.sql", + ) + + out = mustRun(t, "migrate", "up") + cmdtest.Contains(t, out, "No changes made to database (up-to-date)") +} + +// Plain "migrate" is "migrate up" with every pending migration. +func TestMigrateWithoutSubcommand(t *testing.T) { + cmdtest.Setup(t, dbConfig) + cmdtest.WriteFile(t, "test.db", "") // exists, so no question is asked + + out := mustRun(t, "migrate") + cmdtest.Contains(t, out, fmt.Sprintf("Database migrated to version %d", latestVersion(t))) +} + +func TestMigrateUpAsksBeforeCreating(t *testing.T) { + cmdtest.Setup(t, dbConfig) + + out, err := run(t, "n\n", "migrate", "up") + cmdtest.ErrContains(t, err, "no database file") + cmdtest.Contains(t, out, + "Database file './test.db' does not exist. Would you like to create it?") + if cmdtest.Exists("test.db") { + t.Error("test.db was created after answering no") + } + + out, err = run(t, "y\n", "migrate", "up") + if err != nil { + t.Fatalf("answering yes: %v\n%s", err, out) + } + if !cmdtest.Exists("test.db") { + t.Error("test.db was not created after answering yes") + } +} + +func TestMigrateUpBySteps(t *testing.T) { + cmdtest.Setup(t, dbConfig) + latest := latestVersion(t) + + out := mustRun(t, "migrate", "up", "--create", "--steps", "2") + cmdtest.Contains(t, out, "Database migrated to version 2") + + out = mustRun(t, "migrate", "status") + cmdtest.Contains(t, out, + fmt.Sprintf("Database version 2 of %d (%d pending)", latest, latest-2)) +} + +func TestMigrateDown(t *testing.T) { + cmdtest.Setup(t, dbConfig) + latest := latestVersion(t) + mustRun(t, "migrate", "up", "--create") + + out := mustRun(t, "migrate", "down", "--steps", "1") + cmdtest.Contains(t, out, "reverted", fmt.Sprintf("Database migrated to version %d", latest-1)) + + // Rolling back everything deletes all data, so it must be confirmed. + for _, args := range [][]string{ + {"migrate", "down", "--steps", "0"}, + {"migrate", "down", "--steps", "99"}, + } { + _, err := run(t, "", args...) + cmdtest.ErrContains(t, err, "pass the --yes flag") + } + cmdtest.Contains(t, mustRun(t, "migrate", "status"), + fmt.Sprintf("Database version %d of %d", latest-1, latest)) + + out = mustRun(t, "migrate", "down", "--steps", "0", "--yes") + cmdtest.Contains(t, out, "Database cleared") + + out = mustRun(t, "migrate", "down", "--steps", "1") + cmdtest.Contains(t, out, "No changes made to database (empty)") +} + +func TestMigrateDownRequiresSteps(t *testing.T) { + cmdtest.Setup(t, dbConfig) + mustRun(t, "migrate", "up", "--create") + _, err := run(t, "", "migrate", "down") + cmdtest.ErrContains(t, err, "steps") +} + +func TestMigrateMissingDatabase(t *testing.T) { + cmdtest.Setup(t, dbConfig) + for _, args := range [][]string{ + {"migrate", "status"}, + {"migrate", "down", "--steps", "1"}, + } { + _, err := run(t, "", args...) + cmdtest.ErrContains(t, err, "does not exist") + } + if cmdtest.Exists("test.db") { + t.Error("status or down created the database") + } +} + +func TestMigrateWithoutDatabaseConfigured(t *testing.T) { + cmdtest.Setup(t, "logging:\n level: info\n") + _, err := run(t, "", "migrate", "status") + cmdtest.ErrContains(t, err, "no database file specified") +}