Files
Felis/cmd/felis/run_test.go
T
flyemoji 05cb8f6320 feat(cli): report component updates and make the router a data table
Add `felis update`, which reports which platform components have newer
versions available, and route `felis version`, which shipped implemented but
unreachable.

That bug is why the subcommand router is now a map rather than a switch.
cmdVersion existed with nothing dispatching to it and no usage line, so
`felis version` fell through to "unknown command" and no test noticed — a
switch offers no way to enumerate what it routes, so the usage text and the
router could not be compared. As data, they can: a test now walks the
Commands: block and the table in both directions, failing an entry added to
one without the other. bootstrap-assets stays deliberately undocumented and
is listed as such, which makes its absence a decision rather than an
oversight.

The host gatherer answers the two seams NewSysGatherer leaves nil, for the
one caller that can satisfy them without a cluster client. felis-api is
answered from the running binary's own build stamp rather than the
Deployment's image tag: deploy/bootstrap.sh builds the image from the same
checkout it installs /usr/local/bin/felis from and stamps both with one git
describe, so it is the same artifact, and it is the identity `felis version`
reports. Reading the Deployment answers a slightly different question — what
is rolled out — and stays the right seam for the in-cluster path.

Velocity is read from the jar's own META-INF/MANIFEST.MF
Implementation-Version, which is what the proxy reports about itself at
runtime, because bootstrap installs the jar under a fixed name with no
version in it. The filename extractor remains only as a fallback for a
hand-placed velocity-3.5.1.jar.

An unstamped local build reports "dev" and is refused with an actionable
message rather than being treated as 0.0.0, which would make every release
upstream look like an upgrade. The panel and the plugin jars have no version
of their own on purpose: they are embedded in or built alongside the felis
binary, so the felis version is theirs.
2026-07-20 14:32:58 +09:00

193 lines
6.9 KiB
Go

package main
import (
"bytes"
"os"
"strings"
"testing"
)
func TestRunNoArgsPrintsUsage(t *testing.T) {
var out, errBuf bytes.Buffer
if code := run(nil, &out, &errBuf); code != 2 {
t.Errorf("exit code = %d, want 2", code)
}
if !strings.Contains(errBuf.String(), "Usage:") {
t.Errorf("expected usage on stderr, got %q", errBuf.String())
}
}
func TestRunHelp(t *testing.T) {
var out, errBuf bytes.Buffer
if code := run([]string{"help"}, &out, &errBuf); code != 0 {
t.Errorf("exit code = %d, want 0", code)
}
if !strings.Contains(out.String(), "felis") {
t.Errorf("expected usage on stdout, got %q", out.String())
}
}
func TestRunUnknownCommand(t *testing.T) {
var out, errBuf bytes.Buffer
if code := run([]string{"frobnicate"}, &out, &errBuf); code != 2 {
t.Errorf("exit code = %d, want 2", code)
}
if !strings.Contains(errBuf.String(), "unknown command") {
t.Errorf("expected unknown-command error, got %q", errBuf.String())
}
}
// undocumentedCommands are routable on purpose but kept out of the usage text: they
// are called by deploy/bootstrap.sh, not by a human at a prompt. Listing them here is
// what makes their absence from usage a deliberate decision rather than an oversight.
var undocumentedCommands = map[string]bool{"bootstrap-assets": true}
// The usage text and the dispatch table must describe the same set of commands.
//
// This exists because the failure it catches already happened: `version` shipped
// implemented but unreachable — cmdVersion existed with nothing routing to it and no
// usage line — so `felis version` fell through to "unknown command", and no test
// noticed. Comparing the two lists is only possible because the router is a map; a
// switch cannot be enumerated.
//
// It compares names WITHOUT invoking anything. Running each command to see whether it
// is routed would start servers, dial clusters, and (for `update`) hit the network —
// a slow, flaky test of the wrong thing.
func TestUsageAndDispatchTableAgree(t *testing.T) {
documented := map[string]bool{}
var inCommands bool
for line := range strings.SplitSeq(usage, "\n") {
// Only the block under "Commands:" lists commands. The "Usage:" block above it
// has the same two-space indent but its entry is the "felis <command> [flags]"
// synopsis, which is not a subcommand.
if strings.HasPrefix(line, "Commands:") {
inCommands = true
continue
}
if !inCommands {
continue
}
// Command lines are the " <name> <description>" entries; the two-space indent
// distinguishes them from wrapped continuation lines.
if !strings.HasPrefix(line, " ") || strings.HasPrefix(line, " ") {
continue
}
name, _, ok := strings.Cut(strings.TrimSpace(line), " ")
if !ok || name == "" {
continue
}
documented[name] = true
if _, routed := commands[name]; !routed {
t.Errorf("usage advertises %q but the dispatch table has no entry for it", name)
}
}
for name := range commands {
if !documented[name] && !undocumentedCommands[name] {
t.Errorf("%q is routable but undocumented; add it to usage, or to undocumentedCommands if it is an internal entrypoint", name)
}
}
}
func TestRunApplyRequiresFileFlag(t *testing.T) {
// Without -f the command must fail with usage (2), not try to contact a
// cluster. It can't return 0 because no CRD was created, and it can't return 1
// because that would be ambiguous with a real server-side failure.
var out, errBuf bytes.Buffer
code := run([]string{"apply"}, &out, &errBuf)
if code != 2 {
t.Errorf("exit code = %d, want 2", code)
}
if !strings.Contains(errBuf.String(), "missing required flag -f") {
t.Errorf("expected -f usage, got %q", errBuf.String())
}
}
func TestRunApplyRejectsInvalidJSON(t *testing.T) {
// Sending garbage via a temp file must exit 1 (input error), not panic or hang.
empty := t.TempDir() + "/empty.json"
if err := os.WriteFile(empty, nil, 0o644); err != nil {
t.Fatal(err)
}
var out, errBuf bytes.Buffer
code := run([]string{"apply", "-f", empty}, &out, &errBuf)
// The empty file must fail JSON parsing or validation; either way it exits 1.
if code != 1 {
t.Errorf("exit code = %d, want 1", code)
}
if !strings.Contains(errBuf.String(), "invalid") && !strings.Contains(errBuf.String(), "required") {
t.Errorf("expected input error, got %q", errBuf.String())
}
}
func TestRunSetupAndBreakGlassCommands(t *testing.T) {
t.Run("setup help", func(t *testing.T) {
var out, errBuf bytes.Buffer
if code := run([]string{"setup", "-h"}, &out, &errBuf); code != 0 {
t.Errorf("exit code = %d, want 0", code)
}
if !strings.Contains(errBuf.String(), "Usage of setup") {
t.Errorf("expected setup help, got stderr=%q stdout=%q", errBuf.String(), out.String())
}
if strings.Contains(errBuf.String(), "Usage of breakGlass") {
t.Errorf("setup must not route to breakGlass help, got %q", errBuf.String())
}
})
t.Run("breakGlass help", func(t *testing.T) {
var out, errBuf bytes.Buffer
if code := run([]string{"breakGlass", "-h"}, &out, &errBuf); code != 0 {
t.Errorf("exit code = %d, want 0", code)
}
if !strings.Contains(errBuf.String(), "Usage of breakGlass") {
t.Errorf("expected breakGlass help, got stderr=%q stdout=%q", errBuf.String(), out.String())
}
})
t.Run("lowercase breakglass is intentionally rejected", func(t *testing.T) {
var out, errBuf bytes.Buffer
if code := run([]string{"breakglass", "-h"}, &out, &errBuf); code != 2 {
t.Errorf("exit code = %d, want 2", code)
}
if !strings.Contains(errBuf.String(), "unknown command") {
t.Errorf("expected lowercase alias rejection, got stderr=%q stdout=%q", errBuf.String(), out.String())
}
})
}
func TestRunReaperValidatesConfigBeforeDialing(t *testing.T) {
var out, errBuf bytes.Buffer
// Like api, reaper must fail fast (exit 1) at config load, before any
// database or cluster contact.
code := run([]string{"reaper", "-config", "this-file-does-not-exist.toml"}, &out, &errBuf)
if code != 1 {
t.Errorf("exit code = %d, want 1", code)
}
if !strings.Contains(errBuf.String(), "felis reaper:") {
t.Errorf("expected reaper error on stderr, got %q", errBuf.String())
}
}
func TestRunAPIValidatesConfigBeforeDialing(t *testing.T) {
var out, errBuf bytes.Buffer
// A non-existent config must fail fast (exit 1) at config load, before any
// database or cluster contact.
code := run([]string{"api", "-config", "this-file-does-not-exist.toml"}, &out, &errBuf)
if code != 1 {
t.Errorf("exit code = %d, want 1", code)
}
if !strings.Contains(errBuf.String(), "felis api:") {
t.Errorf("expected api error on stderr, got %q", errBuf.String())
}
}
func TestRunMigrateRequiresUpVerb(t *testing.T) {
var out, errBuf bytes.Buffer
// "migrate" with no verb should fail fast on usage, not touch a database.
if code := run([]string{"migrate"}, &out, &errBuf); code != 2 {
t.Errorf("exit code = %d, want 2", code)
}
if !strings.Contains(errBuf.String(), "felis migrate up") {
t.Errorf("expected migrate usage, got %q", errBuf.String())
}
}