package cli import ( "bytes" "context" "errors" "fmt" "strings" "testing" "banger/internal/api" "banger/internal/model" "github.com/spf13/cobra" ) // stubPruneSeams installs list + delete fakes onto the caller's *deps // and returns a pointer to a slice that records every ID passed to the // delete fake. func stubPruneSeams(t *testing.T, d *deps, vms []model.VMRecord, listErr error, deleteErr map[string]error) *[]string { t.Helper() var deleted []string d.vmList = func(ctx context.Context, socketPath string) (api.VMListResult, error) { return api.VMListResult{VMs: vms}, listErr } d.vmDelete = func(ctx context.Context, socketPath, idOrName string) error { if err, ok := deleteErr[idOrName]; ok { return err } deleted = append(deleted, idOrName) return nil } return &deleted } func newPruneTestCmd(stdin string) (*cobra.Command, *bytes.Buffer, *bytes.Buffer) { cmd := &cobra.Command{Use: "prune"} cmd.SetContext(context.Background()) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} cmd.SetIn(strings.NewReader(stdin)) cmd.SetOut(stdout) cmd.SetErr(stderr) return cmd, stdout, stderr } func TestPromptYesNo(t *testing.T) { cases := map[string]bool{ "y\n": true, "Y\n": true, "yes\n": true, "YES\n": true, " y \n": true, "n\n": false, "no\n": false, "\n": false, "anything\n": false, } for input, want := range cases { out := &bytes.Buffer{} got, err := promptYesNo(strings.NewReader(input), out, "go? ") if err != nil { t.Errorf("input %q: error %v", input, err) continue } if got != want { t.Errorf("input %q: got %v, want %v", input, got, want) } if !strings.Contains(out.String(), "go?") { t.Errorf("input %q: prompt not written; got %q", input, out.String()) } } } func TestPromptYesNoEOF(t *testing.T) { got, err := promptYesNo(strings.NewReader(""), &bytes.Buffer{}, "? ") if err != nil { t.Fatalf("EOF should not error: %v", err) } if got { t.Fatal("EOF should be treated as no") } } func TestRunVMPruneNoVictims(t *testing.T) { d := defaultDeps() stubPruneSeams(t, d, []model.VMRecord{ {ID: "id-1", Name: "running-vm", State: model.VMStateRunning}, }, nil, nil) cmd, stdout, _ := newPruneTestCmd("") if err := d.runVMPrune(cmd, "sock", false); err != nil { t.Fatalf("d.runVMPrune: %v", err) } if !strings.Contains(stdout.String(), "no non-running VMs") { t.Errorf("expected no-op message, got %q", stdout.String()) } } func TestRunVMPruneAbortedByUser(t *testing.T) { d := defaultDeps() deleted := stubPruneSeams(t, d, []model.VMRecord{ {ID: "id-1", Name: "stale", State: model.VMStateStopped}, }, nil, nil) cmd, stdout, _ := newPruneTestCmd("n\n") if err := d.runVMPrune(cmd, "sock", false); err != nil { t.Fatalf("d.runVMPrune: %v", err) } if !strings.Contains(stdout.String(), "aborted") { t.Errorf("expected 'aborted' output, got %q", stdout.String()) } if len(*deleted) != 0 { t.Errorf("should not have deleted anything, got %v", *deleted) } } func TestRunVMPruneConfirmedDeletesNonRunning(t *testing.T) { d := defaultDeps() deleted := stubPruneSeams(t, d, []model.VMRecord{ {ID: "id-run", Name: "keeper", State: model.VMStateRunning}, {ID: "id-stop", Name: "stale", State: model.VMStateStopped}, {ID: "id-err", Name: "broken", State: model.VMStateError}, {ID: "id-created", Name: "fresh", State: model.VMStateCreated}, }, nil, nil) cmd, stdout, _ := newPruneTestCmd("y\n") if err := d.runVMPrune(cmd, "sock", false); err != nil { t.Fatalf("d.runVMPrune: %v", err) } // Deleted must be exactly the three non-running IDs, in list order. want := []string{"id-stop", "id-err", "id-created"} if len(*deleted) != len(want) { t.Fatalf("deleted = %v, want %v", *deleted, want) } for i, id := range want { if (*deleted)[i] != id { t.Errorf("deleted[%d] = %q, want %q", i, (*deleted)[i], id) } } for _, want := range []string{"stale", "broken", "fresh"} { if !strings.Contains(stdout.String(), "deleted "+want) { t.Errorf("output missing 'deleted %s':\n%s", want, stdout.String()) } } if strings.Contains(stdout.String(), "deleted keeper") { t.Errorf("running VM should not be deleted:\n%s", stdout.String()) } } func TestRunVMPruneForceSkipsPrompt(t *testing.T) { d := defaultDeps() deleted := stubPruneSeams(t, d, []model.VMRecord{ {ID: "id-1", Name: "stale", State: model.VMStateStopped}, }, nil, nil) // Empty stdin + force=true: must not block on prompt. cmd, stdout, _ := newPruneTestCmd("") if err := d.runVMPrune(cmd, "sock", true); err != nil { t.Fatalf("d.runVMPrune: %v", err) } if len(*deleted) != 1 || (*deleted)[0] != "id-1" { t.Errorf("deleted = %v, want [id-1]", *deleted) } // Prompt should not appear in output. if strings.Contains(stdout.String(), "Delete these VMs?") { t.Errorf("force=true should skip prompt:\n%s", stdout.String()) } } func TestRunVMPruneReportsPartialFailure(t *testing.T) { d := defaultDeps() stubPruneSeams(t, d, []model.VMRecord{ {ID: "id-a", Name: "a", State: model.VMStateStopped}, {ID: "id-b", Name: "b", State: model.VMStateStopped}, }, nil, map[string]error{"id-a": errors.New("simulated")}, ) cmd, _, stderr := newPruneTestCmd("") err := d.runVMPrune(cmd, "sock", true) if err == nil { t.Fatal("expected non-zero exit when any delete fails") } if !strings.Contains(err.Error(), "1 VM(s) failed") { t.Errorf("unexpected error: %v", err) } if !strings.Contains(stderr.String(), "delete a:") { t.Errorf("stderr missing failure log: %q", stderr.String()) } } func TestRunVMPruneListErrorPropagates(t *testing.T) { d := defaultDeps() stubPruneSeams(t, d, nil, fmt.Errorf("rpc failed"), nil) cmd, _, _ := newPruneTestCmd("") err := d.runVMPrune(cmd, "sock", true) if err == nil || !strings.Contains(err.Error(), "rpc failed") { t.Fatalf("expected rpc error to propagate, got %v", err) } }