Added unit tests for CLI

This commit is contained in:
bwilliams committed 2026-10-13 13:22:22 +01:00
1 parent 169c6c6b64
commit 5ec4b65b91
7 files changed
+965

No files matched your search

+52
View File
@@ -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 <string>")
cmdtest.Contains(t,
cmdtest.MustRun(t, newRootCmd, "config", "get", "logging.level", "--debug"),
"debug <string>")
}
func TestUnknownCommand(t *testing.T) {
cmdtest.Setup(t, "")
if _, err := cmdtest.Run(t, newRootCmd, "", "nope"); err == nil {
t.Error("an unknown command was accepted")
}
}
+208
View File
@@ -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?")
}
+151
View File
@@ -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)
}
}
+207
View File
@@ -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 <string>", "sources.sleeper.interval = 2m0s <string>")
// 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 <string>")
cmdtest.Contains(t, mustRun(t, "config", "get", "sources.sleeper.interval"), "2m0s <string>")
cmdtest.Contains(t, mustRun(t, "config", "get", "sources.sleeper.enabled"), "false <bool>")
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 <string>")
cmdtest.Contains(t, mustRun(t, "config", "get", "logging.level"), "debug <string>")
out = mustRun(t, "config", "set", "sources.sleeper.enabled", "true")
cmdtest.Contains(t, out, "sources.sleeper.enabled = true <bool>")
// 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 <string>")
// 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 <string>")
}
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)
}
}
+79
View File
@@ -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")
}
+101
View File
@@ -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)
}
}
+167
View File
@@ -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")
}