Files
job-search/internal/cli/cli_test.go
T
bwilliams e19c8c2b69 Imports split into three groups: standard library, third-party, then this project's own packages
gci is configured with the sections standard, default and localmodule, and
goimports with the module path as its local prefix, so both formatters
agree.
2026-10-14 17:40:06 +01:00

210 lines
5.3 KiB
Go

package cli
import (
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"git.avsdev.uk/Go/job-search/internal/logging"
)
// 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?")
}