banger/internal/daemon/dispatch_test.go
Thales Maciel 366e1560c9
daemon: replace RPC switch with generic method-to-handler table
The dispatch method was a single ~240-line switch of 34 cases, each
following the same pattern: decode params into some type P, call a
service method returning (R, error), wrap R in a result struct and
either marshalResultOrError-encode or return a raw rpc.NewError.
Adding a method was a 4-line ceremony per site, and grepping for
"methods banger speaks" meant reading the full switch.

New shape, in internal/daemon/dispatch.go:

  - handler is the uniform `func(ctx, d, req) rpc.Response` type
    every method dispatches through.
  - paramHandler[P, R] is the generic wrapper that absorbs 28 of
    the 34 cases (decode, call, marshal). No reflection — P and R
    are deduced from the service-call literal, so each map entry
    is a one-liner referencing a small adapter func.
  - noParamHandler[R] is the decode-free variant for 6 methods
    that don't carry params.
  - rpcHandlers is the single source of truth for which methods
    exist and which adapter they dispatch to.
  - Four specials (ping, shutdown, vm.logs, vm.ssh) stay as named
    `handler`-typed functions: ping/shutdown encode with raw
    rpc.NewResult, vm.logs/vm.ssh need pre-service validation to
    emit distinct error codes (not_found, not_running) that the
    generic wrapper maps uniformly to operation_failed.

Daemon.dispatch shrinks from a 240-line switch to 11 lines:
version check, test-only handler short-circuit, table lookup,
invoke-or-unknown.

Tests:

  - TestRPCHandlersMatchDocumentedMethods — keyset guard. Adding
    or removing a method without updating the expected slice is a
    red flag the test surfaces.
  - TestRPCHandlersAllNonNil — catches nil-function registrations.

All pre-existing dispatch tests (param decode, error codes, etc.)
keep passing unchanged — the handler contract for any given
method is byte-identical from the RPC client's perspective. Smoke
(all 21 scenarios) exercises every code path end-to-end.

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

84 lines
1.9 KiB
Go

package daemon
import (
"sort"
"testing"
)
// TestRPCHandlersMatchDocumentedMethods pins the surface of the RPC
// table: adding or removing a method should be an explicit, reviewable
// change. If the keyset drifts and this test isn't updated alongside,
// that's a red flag — either the documented list is stale, or a
// method sneaked in without being discussed.
//
// The expected list is the single source of truth for "methods
// banger speaks." Any production code consulting it (CLI completions,
// docs generator) can grep this test.
func TestRPCHandlersMatchDocumentedMethods(t *testing.T) {
expected := []string{
"image.delete",
"image.list",
"image.promote",
"image.pull",
"image.register",
"image.show",
"kernel.catalog",
"kernel.delete",
"kernel.import",
"kernel.list",
"kernel.pull",
"kernel.show",
"ping",
"shutdown",
"vm.create",
"vm.create.begin",
"vm.create.cancel",
"vm.create.status",
"vm.delete",
"vm.health",
"vm.kill",
"vm.list",
"vm.logs",
"vm.ping",
"vm.ports",
"vm.restart",
"vm.set",
"vm.show",
"vm.ssh",
"vm.start",
"vm.stats",
"vm.stop",
"vm.workspace.export",
"vm.workspace.prepare",
}
got := make([]string, 0, len(rpcHandlers))
for name := range rpcHandlers {
got = append(got, name)
}
sort.Strings(got)
sort.Strings(expected)
if len(got) != len(expected) {
t.Fatalf("method count: got %d, want %d\n got: %v\n want: %v", len(got), len(expected), got, expected)
}
for i := range expected {
if got[i] != expected[i] {
t.Fatalf("method[%d]: got %q, want %q\n full got: %v\n full want: %v", i, got[i], expected[i], got, expected)
}
}
}
// TestRPCHandlersAllNonNil catches a silly-but-possible footgun:
// registering a method with a nil function literal.
func TestRPCHandlersAllNonNil(t *testing.T) {
for name, h := range rpcHandlers {
if h == nil {
t.Errorf("rpcHandlers[%q] = nil", name)
}
}
}