banger/internal/cli/errors_test.go
Thales Maciel 71a332a6a1
cli: maturity polish — color, error translation, tabwriter consistency
Adds three small but high-leverage presentation tweaks for v0.1:

1. internal/cli/style is a new ~70 LOC package with Pass/Fail/Warn/
   Dim/Bold helpers. Each is TTY-gated and obeys NO_COLOR. No
   external dep. Wired into the doctor PASS/FAIL/WARN status, the
   "banger:" error prefix on stderr, and the dim 'ready in <elapsed>'
   line.
2. internal/cli/errors translates rpc.ErrorResponse into user-facing
   text. operation_failed becomes invisible (the message wins);
   not_found, already_exists, bad_request, bad_version, unauthorized,
   unknown_method get short labels; unknown codes pass through. The
   daemon-attached op_id lands in dim parens — paste into
   journalctl --grep to find the daemon log line that produced the
   failure.
3. Tabwriter config converges on (0, 8, 2, ' ', 0) across every
   list/table command. The vm prune confirmation table picked up the
   right config; system install + system status switched from bare
   "key: value\n" lines to tabular form. printVMSpecLine drops its
   Unicode middle dot for an ASCII '|' so terminals without UTF-8
   render cleanly.

Tests cover translateRPCError for every code, style helpers no-op
on non-TTY and under NO_COLOR. Smoke status greps switch from
"key: value" to "key   value" to match the new format.

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

60 lines
2.4 KiB
Go

package cli
import (
"bytes"
"errors"
"strings"
"testing"
"banger/internal/rpc"
)
// TestTranslateRPCError pins the user-facing error rendering for
// every code the daemon emits today plus the catch-all unknown-code
// path. Buffer is non-TTY so style helpers no-op and assertions
// stay readable.
func TestTranslateRPCError(t *testing.T) {
var buf bytes.Buffer
cases := []struct {
name string
code string
msg string
opID string
expect string
}{
{"operation_failed strips code", "operation_failed", "vm running", "", "vm running"},
{"empty code drops prefix", "", "raw boom", "", "raw boom"},
{"not_found", "not_found", `vm "x" not found`, "", `not found: vm "x" not found`},
{"not_running", "not_running", "vm is not running", "", "not running: vm is not running"},
{"already_exists", "already_exists", "image foo", "", "already exists: image foo"},
{"bad_request", "bad_request", "missing rootfs", "", "bad request: missing rootfs"},
{"bad_params", "bad_params", "invalid tap name", "", "bad request: invalid tap name"},
{"bad_version", "bad_version", "unsupported version 99", "", "version mismatch: unsupported version 99"},
{"unauthorized", "unauthorized", "uid 1000 not allowed", "", "unauthorized: uid 1000 not allowed"},
{"unknown_method", "unknown_method", "no.such.method", "", "unknown method: no.such.method"},
{"unknown code falls through", "weird_new_code", "boom", "", "weird_new_code: boom"},
{"op_id appended in parens", "operation_failed", "boom", "op-deadbeef00ff", "boom (op-deadbeef00ff)"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := &rpc.ErrorResponse{Code: tc.code, Message: tc.msg, OpID: tc.opID}
got := translateRPCError(&buf, err)
if got != tc.expect {
t.Errorf("got %q, want %q", got, tc.expect)
}
})
}
}
// TestTranslateRPCErrorPassesThroughNonRPCErrors covers the dial
// failure / decode failure paths where rpc.Call returns a plain Go
// error rather than *rpc.ErrorResponse. The translator must not
// hide the original message — that's the only signal an operator
// has when the daemon is down.
func TestTranslateRPCErrorPassesThroughNonRPCErrors(t *testing.T) {
var buf bytes.Buffer
got := translateRPCError(&buf, errors.New("dial unix /run/banger/bangerd.sock: connect: no such file or directory"))
if !strings.Contains(got, "no such file or directory") {
t.Fatalf("plain error lost: got %q", got)
}
}