banger/internal/cli/prune_test.go
Thales Maciel c42fcbe012
cli + daemon: move test seams off package globals onto injected structs
CLI: introduce internal/cli.deps which owns every RPC/SSH/host-command
seam the tree used to reach through mutable package vars. Command
builders, orchestrators, and the completion helpers become methods on
*deps. Tests construct their own deps per case, so fakes no longer leak
across cases and tests are free to run in parallel.

Daemon: move workspaceInspectRepoFunc + workspaceImportFunc onto the
Daemon struct (workspaceInspectRepo / workspaceImport), mirroring the
existing guestWaitForSSH / guestDial pattern. Workspace-prepare tests
drop t.Parallel() guards now that they no longer mutate process-wide
state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 19:03:55 -03:00

205 lines
5.8 KiB
Go

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