package config import ( "strings" "testing" "github.com/spf13/cobra" "go.yaml.in/yaml/v3" "git.avsdev.uk/Go/job-search/internal/cli" "git.avsdev.uk/Go/job-search/internal/cli/cmdtest" ) // 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, ) } database := strings.Index(out, "database.file") logging := strings.Index(out, "logging.level") if database > logging { 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) for _, tt := range []struct { key, want string }{ {"logging.level", "info "}, {"sources.sleeper.interval", "2m0s "}, {"sources.sleeper.enabled", "false "}, } { t.Run(tt.key, func(t *testing.T) { cmdtest.Contains(t, mustRun(t, "config", "get", tt.key), tt.want) }) } 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", " "}, } { t.Run(strings.Join(args, " "), func(t *testing.T) { _, 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) } }