root_test.go

  1package cmd
  2
  3import (
  4	"bytes"
  5	"io"
  6	"os"
  7	"path/filepath"
  8	"reflect"
  9	"strings"
 10	"testing"
 11
 12	"github.com/spf13/cobra"
 13)
 14
 15func TestParsePassthrough(t *testing.T) {
 16	t.Parallel()
 17
 18	tests := []struct {
 19		name string
 20		args []string
 21		want map[string][]string
 22	}{
 23		{
 24			name: "no passthrough",
 25			args: nil,
 26			want: nil,
 27		},
 28		{
 29			name: "flag with value",
 30			args: []string{"--repo", "/repo"},
 31			want: map[string][]string{
 32				"repo": {"/repo"},
 33			},
 34		},
 35		{
 36			name: "repeated and equals flags",
 37			args: []string{"--tag=daily", "--tag", "weekly"},
 38			want: map[string][]string{
 39				"tag": {"daily", "weekly"},
 40			},
 41		},
 42		{
 43			name: "boolean flags",
 44			args: []string{"-v", "--json"},
 45			want: map[string][]string{
 46				"v":    nil,
 47				"json": nil,
 48			},
 49		},
 50		{
 51			name: "positional arguments become _arguments",
 52			args: []string{"/src", "/dst"},
 53			want: map[string][]string{
 54				overrideArgumentsKey: {"/src", "/dst"},
 55			},
 56		},
 57		{
 58			name: "mixed flags and positional arguments",
 59			args: []string{"--repo", "/repo", "/src", "/dst"},
 60			want: map[string][]string{
 61				"repo":               {"/repo"},
 62				overrideArgumentsKey: {"/src", "/dst"},
 63			},
 64		},
 65		{
 66			name: "double-dash preserves literal arguments",
 67			args: []string{"--repo", "/repo", "--", "--literal", "path"},
 68			want: map[string][]string{
 69				"repo":               {"/repo"},
 70				overrideArgumentsKey: {"--literal", "path"},
 71			},
 72		},
 73		{
 74			name: "single dash is positional argument",
 75			args: []string{"-"},
 76			want: map[string][]string{
 77				overrideArgumentsKey: {"-"},
 78			},
 79		},
 80		{
 81			name: "flag without value before another flag is boolean",
 82			args: []string{"--host", "--json"},
 83			want: map[string][]string{
 84				"host": nil,
 85				"json": nil,
 86			},
 87		},
 88	}
 89
 90	for _, tt := range tests {
 91		tt := tt
 92		t.Run(tt.name, func(t *testing.T) {
 93			t.Parallel()
 94
 95			got := parsePassthrough(tt.args)
 96			if !reflect.DeepEqual(got, tt.want) {
 97				t.Fatalf("overrides mismatch: got %#v, want %#v", got, tt.want)
 98			}
 99		})
100	}
101}
102
103func TestRunCommandAppliesRootFlagsAndPassthrough(t *testing.T) {
104	configFile := setupCommandConfig(t)
105	setRootFlagValuesForTest(t, "home@cloud", true, configFile)
106	t.Setenv("KELD_CONFIG_FILE", "")
107	t.Setenv("KELD_DRYRUN", "")
108
109	backup := lookupSubcommand(t, "backup")
110	out, err := captureStdout(t, func() error {
111		return runCommand("backup", backup, []string{"--tag", "daily", "/src"}, nil, nil)
112	})
113	if err != nil {
114		t.Fatalf("runCommand returned error: %v", err)
115	}
116
117	if got := os.Getenv("KELD_CONFIG_FILE"); got != configFile {
118		t.Fatalf("KELD_CONFIG_FILE mismatch: got %q, want %q", got, configFile)
119	}
120	if got := os.Getenv("KELD_DRYRUN"); got != "1" {
121		t.Fatalf("KELD_DRYRUN mismatch: got %q, want %q", got, "1")
122	}
123
124	for _, want := range []string{"\"backup\"", "\"--tag\" \"daily\"", "\"/src\""} {
125		if !strings.Contains(out, want) {
126			t.Fatalf("dry-run output missing %q:\n%s", want, out)
127		}
128	}
129}
130
131func TestRunCommandRejectsUnknownPreset(t *testing.T) {
132	configFile := setupCommandConfig(t)
133	setRootFlagValuesForTest(t, "missing", true, configFile)
134
135	backup := lookupSubcommand(t, "backup")
136	err := runCommand("backup", backup, nil, nil, nil)
137	if err == nil {
138		t.Fatal("expected unknown preset error")
139	}
140	if !strings.Contains(err.Error(), "unknown preset") {
141		t.Fatalf("expected unknown preset error, got: %v", err)
142	}
143}
144
145func TestSubcommandHelpShowsCobraHelp(t *testing.T) {
146	backup := lookupSubcommand(t, "backup")
147	buf := &bytes.Buffer{}
148	backup.SetOut(buf)
149	backup.SetErr(buf)
150	t.Cleanup(func() {
151		backup.SetOut(os.Stdout)
152		backup.SetErr(os.Stderr)
153	})
154
155	err := backup.RunE(backup, []string{"--help"})
156	if err != nil {
157		t.Fatalf("backup --help returned error: %v", err)
158	}
159
160	helpText := buf.String()
161	if !strings.Contains(helpText, "Usage:") {
162		t.Fatalf("help output missing Usage section:\n%s", helpText)
163	}
164	if !strings.Contains(helpText, "--exclude") {
165		t.Fatalf("help output missing backup flag listing:\n%s", helpText)
166	}
167	if !strings.Contains(helpText, "--repo") {
168		t.Fatalf("help output missing inherited global flags:\n%s", helpText)
169	}
170}
171
172func setupCommandConfig(t *testing.T) string {
173	t.Helper()
174
175	dir := t.TempDir()
176	cfg := filepath.Join(dir, "config.toml")
177	err := os.WriteFile(cfg, []byte(`
178[global]
179repo = "/repos/default"
180
181["home@"]
182tag = "home"
183
184["@cloud"]
185repo = "/repos/cloud"
186
187[archive]
188json = true
189`), 0o600)
190	if err != nil {
191		t.Fatalf("writing fixture: %v", err)
192	}
193	t.Setenv("HOME", dir)
194
195	return cfg
196}
197
198func setRootFlagValuesForTest(t *testing.T, preset string, showCommand bool, configFile string) {
199	t.Helper()
200
201	prevPreset, prevShowCommand, prevConfigFile := flagPreset, flagShowCmd, flagConfigFile
202	flagPreset = preset
203	flagShowCmd = showCommand
204	flagConfigFile = configFile
205	t.Cleanup(func() {
206		flagPreset = prevPreset
207		flagShowCmd = prevShowCommand
208		flagConfigFile = prevConfigFile
209	})
210}
211
212func lookupSubcommand(t *testing.T, name string) *cobra.Command {
213	t.Helper()
214
215	for _, cmd := range rootCmd.Commands() {
216		if cmd.Name() == name {
217			return cmd
218		}
219	}
220	t.Fatalf("subcommand %q not found", name)
221	return nil
222}
223
224func captureStdout(t *testing.T, run func() error) (string, error) {
225	t.Helper()
226
227	oldStdout := os.Stdout
228	reader, writer, err := os.Pipe()
229	if err != nil {
230		t.Fatalf("creating stdout pipe: %v", err)
231	}
232	os.Stdout = writer
233	t.Cleanup(func() {
234		os.Stdout = oldStdout
235	})
236
237	runErr := run()
238	_ = writer.Close()
239
240	out, readErr := io.ReadAll(reader)
241	if readErr != nil {
242		t.Fatalf("reading stdout: %v", readErr)
243	}
244	_ = reader.Close()
245
246	return string(out), runErr
247}
248
249func TestValidatePreset(t *testing.T) {
250	// Set up a config fixture with known presets.
251	dir := t.TempDir()
252	cfg := filepath.Join(dir, "config.toml")
253	err := os.WriteFile(cfg, []byte(`
254[global]
255verbose = true
256
257["music@"]
258tag = "music"
259
260["@hetzner"]
261repo = "sftp:hetzner"
262
263["@b2"]
264repo = "s3:b2"
265
266[archive]
267json = true
268`), 0o600)
269	if err != nil {
270		t.Fatalf("writing fixture: %v", err)
271	}
272	t.Setenv("KELD_CONFIG_FILE", cfg)
273	t.Setenv("HOME", dir)
274
275	tests := []struct {
276		name    string
277		preset  string
278		wantErr bool
279	}{
280		{name: "valid plain preset", preset: "archive", wantErr: false},
281		{name: "valid composite preset", preset: "music@hetzner", wantErr: false},
282		{name: "valid bare suffix", preset: "@b2", wantErr: false},
283		{name: "unknown preset", preset: "nope", wantErr: true},
284		{name: "unknown composite", preset: "photos@hetzner", wantErr: true},
285	}
286
287	for _, tt := range tests {
288		t.Run(tt.name, func(t *testing.T) {
289			err := validatePreset(tt.preset)
290			if (err != nil) != tt.wantErr {
291				t.Fatalf("validatePreset(%q): got err=%v, wantErr=%v", tt.preset, err, tt.wantErr)
292			}
293		})
294	}
295}
296
297func TestValidatePresetNoConfig(t *testing.T) {
298	// Point at an empty config so there are no presets at all.
299	dir := t.TempDir()
300	cfg := filepath.Join(dir, "config.toml")
301	if err := os.WriteFile(cfg, []byte("[global]\n"), 0o600); err != nil {
302		t.Fatalf("writing fixture: %v", err)
303	}
304	t.Setenv("KELD_CONFIG_FILE", cfg)
305	t.Setenv("HOME", dir)
306
307	err := validatePreset("anything")
308	if err == nil {
309		t.Fatal("expected error for unknown preset with empty config")
310	}
311	if !strings.Contains(err.Error(), "no presets defined") {
312		t.Fatalf("expected 'no presets defined' message, got: %v", err)
313	}
314}