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?") }