Added unit tests for CLI
This commit is contained in:
1 parent
169c6c6b64
commit
5ec4b65b91
7 files changed
+965
No files matched your search
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
Reference in new issue
Block a user