|
@@ -0,0 +1,249 @@
|
|
|
|
|
+package main
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "bytes"
|
|
|
|
|
+ "net/http"
|
|
|
|
|
+ "net/http/httptest"
|
|
|
|
|
+ "os"
|
|
|
|
|
+ "path/filepath"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+ "testing"
|
|
|
|
|
+ "time"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// newTestServer returns an httptest server that maps API keys to status codes.
|
|
|
|
|
+// Keys ending with "-401" return 401; "-422" return 422; everything else returns 200 with the sample body.
|
|
|
|
|
+func newTestServer(t *testing.T) *httptest.Server {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ sample, err := os.ReadFile(filepath.Join("..", "..", "internal", "api", "testdata", "sample.json"))
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ t.Fatalf("read sample: %v", err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
+ auth := r.Header.Get("Authorization")
|
|
|
|
|
+ switch {
|
|
|
|
|
+ case strings.HasSuffix(auth, "-401"):
|
|
|
|
|
+ w.WriteHeader(http.StatusUnauthorized)
|
|
|
|
|
+ case strings.HasSuffix(auth, "-422"):
|
|
|
|
|
+ w.WriteHeader(http.StatusUnprocessableEntity)
|
|
|
|
|
+ default:
|
|
|
|
|
+ w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
+ _, _ = w.Write(sample)
|
|
|
|
|
+ }
|
|
|
|
|
+ }))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func writeConfig(t *testing.T, body string) string {
|
|
|
|
|
+ t.Helper()
|
|
|
|
|
+ dir := t.TempDir()
|
|
|
|
|
+ p := filepath.Join(dir, "config.yaml")
|
|
|
|
|
+ if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
|
|
|
|
+ t.Fatal(err)
|
|
|
|
|
+ }
|
|
|
|
|
+ return p
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestRun_Version(t *testing.T) {
|
|
|
|
|
+ var out, errb bytes.Buffer
|
|
|
|
|
+ code := run([]string{"--version"}, &out, &errb)
|
|
|
|
|
+ if code != exitOK {
|
|
|
|
|
+ t.Errorf("exit = %d want %d", code, exitOK)
|
|
|
|
|
+ }
|
|
|
|
|
+ if !strings.Contains(out.String(), "zenmux-usage") {
|
|
|
|
|
+ t.Errorf("version output missing program name: %q", out.String())
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestRun_UnknownAccountExits2(t *testing.T) {
|
|
|
|
|
+ cfg := writeConfig(t, `
|
|
|
|
|
+accounts:
|
|
|
|
|
+ - name: personal
|
|
|
|
|
+ api_key: sk-p
|
|
|
|
|
+`)
|
|
|
|
|
+ var out, errb bytes.Buffer
|
|
|
|
|
+ code := run([]string{"--config", cfg, "--account", "missing"}, &out, &errb)
|
|
|
|
|
+ if code != exitUsage {
|
|
|
|
|
+ t.Errorf("exit = %d want %d, stderr=%q", code, exitUsage, errb.String())
|
|
|
|
|
+ }
|
|
|
|
|
+ if !strings.Contains(errb.String(), "account not found") {
|
|
|
|
|
+ t.Errorf("stderr missing 'account not found': %q", errb.String())
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestRun_NoConfigNoEnvExits3(t *testing.T) {
|
|
|
|
|
+ t.Setenv("ZENMUX_MANAGEMENT_API_KEY", "")
|
|
|
|
|
+ t.Setenv("XDG_CONFIG_HOME", t.TempDir()) // point default path at an empty dir
|
|
|
|
|
+ var out, errb bytes.Buffer
|
|
|
|
|
+ code := run(nil, &out, &errb)
|
|
|
|
|
+ if code != exitNoAccount {
|
|
|
|
|
+ t.Errorf("exit = %d want %d, stderr=%q", code, exitNoAccount, errb.String())
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestRun_ExplicitConfigMissingExits3(t *testing.T) {
|
|
|
|
|
+ var out, errb bytes.Buffer
|
|
|
|
|
+ code := run([]string{"--config", "/nonexistent/does/not/exist.yaml"}, &out, &errb)
|
|
|
|
|
+ if code != exitNoAccount {
|
|
|
|
|
+ t.Errorf("exit = %d want %d, stderr=%q", code, exitNoAccount, errb.String())
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestRun_MalformedConfigExits7(t *testing.T) {
|
|
|
|
|
+ cfg := writeConfig(t, "accounts:\n - name: a\n api_key: [")
|
|
|
|
|
+ var out, errb bytes.Buffer
|
|
|
|
|
+ code := run([]string{"--config", cfg}, &out, &errb)
|
|
|
|
|
+ if code != exitConfigParse {
|
|
|
|
|
+ t.Errorf("exit = %d want %d, stderr=%q", code, exitConfigParse, errb.String())
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestRun_InvalidTimeoutExits2(t *testing.T) {
|
|
|
|
|
+ var out, errb bytes.Buffer
|
|
|
|
|
+ code := run([]string{"--timeout", "0s"}, &out, &errb)
|
|
|
|
|
+ if code != exitUsage {
|
|
|
|
|
+ t.Errorf("exit = %d want %d", code, exitUsage)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestRun_UnexpectedPositionalExits2(t *testing.T) {
|
|
|
|
|
+ var out, errb bytes.Buffer
|
|
|
|
|
+ code := run([]string{"extra"}, &out, &errb)
|
|
|
|
|
+ if code != exitUsage {
|
|
|
|
|
+ t.Errorf("exit = %d want %d", code, exitUsage)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestRun_HelpExits0(t *testing.T) {
|
|
|
|
|
+ var out, errb bytes.Buffer
|
|
|
|
|
+ code := run([]string{"--help"}, &out, &errb)
|
|
|
|
|
+ if code != exitOK {
|
|
|
|
|
+ t.Errorf("exit = %d want %d", code, exitOK)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestRun_SingleAccountSuccess(t *testing.T) {
|
|
|
|
|
+ srv := newTestServer(t)
|
|
|
|
|
+ defer srv.Close()
|
|
|
|
|
+ t.Setenv("ZENMUX_BASE_URL", srv.URL)
|
|
|
|
|
+
|
|
|
|
|
+ var out, errb bytes.Buffer
|
|
|
|
|
+ code := run([]string{"--api-key", "sk-ok", "--no-color"}, &out, &errb)
|
|
|
|
|
+ if code != exitOK {
|
|
|
|
|
+ t.Fatalf("exit = %d want %d; stderr=%q", code, exitOK, errb.String())
|
|
|
|
|
+ }
|
|
|
|
|
+ if !strings.Contains(out.String(), "Ultra plan") {
|
|
|
|
|
+ t.Errorf("expected Ultra plan in output, got %q", out.String())
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestRun_SingleAccountAuthFailExits4(t *testing.T) {
|
|
|
|
|
+ srv := newTestServer(t)
|
|
|
|
|
+ defer srv.Close()
|
|
|
|
|
+ t.Setenv("ZENMUX_BASE_URL", srv.URL)
|
|
|
|
|
+
|
|
|
|
|
+ var out, errb bytes.Buffer
|
|
|
|
|
+ code := run([]string{"--api-key", "sk-401", "--no-color"}, &out, &errb)
|
|
|
|
|
+ if code != exitAuth {
|
|
|
|
|
+ t.Fatalf("exit = %d want %d", code, exitAuth)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestRun_MultiAccountMixedExits1(t *testing.T) {
|
|
|
|
|
+ srv := newTestServer(t)
|
|
|
|
|
+ defer srv.Close()
|
|
|
|
|
+ t.Setenv("ZENMUX_BASE_URL", srv.URL)
|
|
|
|
|
+ cfg := writeConfig(t, `
|
|
|
|
|
+accounts:
|
|
|
|
|
+ - name: ok
|
|
|
|
|
+ api_key: sk-ok
|
|
|
|
|
+ - name: bad
|
|
|
|
|
+ api_key: sk-401
|
|
|
|
|
+`)
|
|
|
|
|
+
|
|
|
|
|
+ var out, errb bytes.Buffer
|
|
|
|
|
+ code := run([]string{"--config", cfg, "--no-color"}, &out, &errb)
|
|
|
|
|
+ if code != exitGeneric {
|
|
|
|
|
+ t.Fatalf("exit = %d want %d (mixed)", code, exitGeneric)
|
|
|
|
|
+ }
|
|
|
|
|
+ if !strings.Contains(out.String(), "━━━ ok ━━━") || !strings.Contains(out.String(), "━━━ bad ━━━") {
|
|
|
|
|
+ t.Errorf("both account headers should appear; got %q", out.String())
|
|
|
|
|
+ }
|
|
|
|
|
+ if !strings.Contains(out.String(), "error:") {
|
|
|
|
|
+ t.Errorf("failed account should have error line; got %q", out.String())
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestRun_AllRateLimitedExits5(t *testing.T) {
|
|
|
|
|
+ srv := newTestServer(t)
|
|
|
|
|
+ defer srv.Close()
|
|
|
|
|
+ t.Setenv("ZENMUX_BASE_URL", srv.URL)
|
|
|
|
|
+ cfg := writeConfig(t, `
|
|
|
|
|
+accounts:
|
|
|
|
|
+ - name: a
|
|
|
|
|
+ api_key: sk-422
|
|
|
|
|
+ - name: b
|
|
|
|
|
+ api_key: sk-422
|
|
|
|
|
+`)
|
|
|
|
|
+ var out, errb bytes.Buffer
|
|
|
|
|
+ code := run([]string{"--config", cfg, "--no-color"}, &out, &errb)
|
|
|
|
|
+ if code != exitRateLimited {
|
|
|
|
|
+ t.Fatalf("exit = %d want %d", code, exitRateLimited)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestRun_JSONSingleAccountPassthrough(t *testing.T) {
|
|
|
|
|
+ srv := newTestServer(t)
|
|
|
|
|
+ defer srv.Close()
|
|
|
|
|
+ t.Setenv("ZENMUX_BASE_URL", srv.URL)
|
|
|
|
|
+
|
|
|
|
|
+ var out, errb bytes.Buffer
|
|
|
|
|
+ code := run([]string{"--api-key", "sk-ok", "--json"}, &out, &errb)
|
|
|
|
|
+ if code != exitOK {
|
|
|
|
|
+ t.Fatalf("exit = %d want %d", code, exitOK)
|
|
|
|
|
+ }
|
|
|
|
|
+ if !strings.Contains(out.String(), `"tier": "ultra"`) {
|
|
|
|
|
+ t.Errorf("expected raw passthrough including plan tier; got %q", out.String())
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestRun_AllNetworkTimeoutExits6(t *testing.T) {
|
|
|
|
|
+ // Slow server forces a timeout.
|
|
|
|
|
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
+ time.Sleep(200 * time.Millisecond)
|
|
|
|
|
+ }))
|
|
|
|
|
+ defer srv.Close()
|
|
|
|
|
+ t.Setenv("ZENMUX_BASE_URL", srv.URL)
|
|
|
|
|
+
|
|
|
|
|
+ var out, errb bytes.Buffer
|
|
|
|
|
+ code := run([]string{"--api-key", "sk-slow", "--timeout", "10ms", "--no-color"}, &out, &errb)
|
|
|
|
|
+ if code != exitNetwork {
|
|
|
|
|
+ t.Fatalf("exit = %d want %d", code, exitNetwork)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func TestRun_JSONMultiAccount(t *testing.T) {
|
|
|
|
|
+ srv := newTestServer(t)
|
|
|
|
|
+ defer srv.Close()
|
|
|
|
|
+ t.Setenv("ZENMUX_BASE_URL", srv.URL)
|
|
|
|
|
+ cfg := writeConfig(t, `
|
|
|
|
|
+accounts:
|
|
|
|
|
+ - name: ok
|
|
|
|
|
+ api_key: sk-ok
|
|
|
|
|
+ - name: bad
|
|
|
|
|
+ api_key: sk-401
|
|
|
|
|
+`)
|
|
|
|
|
+ var out, errb bytes.Buffer
|
|
|
|
|
+ code := run([]string{"--config", cfg, "--json"}, &out, &errb)
|
|
|
|
|
+ // Mixed outcomes → 1
|
|
|
|
|
+ if code != exitGeneric {
|
|
|
|
|
+ t.Fatalf("exit = %d want %d", code, exitGeneric)
|
|
|
|
|
+ }
|
|
|
|
|
+ // Array shape.
|
|
|
|
|
+ o := strings.TrimSpace(out.String())
|
|
|
|
|
+ if !strings.HasPrefix(o, "[") || !strings.HasSuffix(o, "]") {
|
|
|
|
|
+ t.Errorf("expected JSON array, got %q", o)
|
|
|
|
|
+ }
|
|
|
|
|
+ if !strings.Contains(o, `"account": "ok"`) || !strings.Contains(o, `"account": "bad"`) {
|
|
|
|
|
+ t.Errorf("missing account keys in multi JSON: %q", o)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|