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