Files
kandev/apps/backend/internal/agent/runtime/routingerr/classify_test.go
T
2026-05-16 20:07:57 +01:00

180 lines
5.9 KiB
Go

package routingerr
import (
"net/http"
"sync"
"testing"
)
func resetInjection() {
injectOnce = sync.Once{}
injectMap = nil
}
func TestClassify_HTTPStatusMapping(t *testing.T) {
resetInjection()
cases := []struct {
status int
want Code
}{
{http.StatusUnauthorized, CodeAuthRequired},
{http.StatusForbidden, CodePermissionDeniedByUser},
{http.StatusPaymentRequired, CodeSubscriptionRequired},
{http.StatusTooManyRequests, CodeRateLimited},
{http.StatusServiceUnavailable, CodeProviderUnavailable},
}
for _, c := range cases {
got := Classify(Input{Phase: PhaseSessionInit, ProviderID: "claude-acp", HTTPStatus: c.status})
if got.Code != c.want {
t.Fatalf("status %d → %s, want %s", c.status, got.Code, c.want)
}
if got.Confidence != ConfHigh {
t.Fatalf("status %d confidence %s want high", c.status, got.Confidence)
}
}
}
func TestClassify_ExitCode127(t *testing.T) {
resetInjection()
exit := 127
e := Classify(Input{Phase: PhaseProcessStart, ProviderID: "claude-acp", ExitCode: &exit})
if e.Code != CodeProviderNotConfigured {
t.Fatalf("got %s, want provider_not_configured", e.Code)
}
if !e.UserAction || !e.FallbackAllowed || e.AutoRetryable {
t.Fatalf("invariants violated: %+v", e)
}
}
func TestClassify_ProviderRules(t *testing.T) {
resetInjection()
cases := []struct {
name string
providerID string
stderr string
wantCode Code
}{
{"claude quota", "claude-acp", "Error: anthropic_quota_exceeded for user", CodeQuotaLimited},
{"claude rate", "claude-acp", "you hit the rate-limit", CodeRateLimited},
{"claude auth", "claude-acp", "you are not authenticated", CodeAuthRequired},
{"claude model", "claude-acp", "model claude-foo not found here", CodeModelUnavailable},
{"codex quota", "codex-acp", "insufficient_quota for project", CodeQuotaLimited},
{"codex rate", "codex-acp", "rate_limit_exceeded", CodeRateLimited},
{"codex apikey", "codex-acp", "invalid api key provided", CodeMissingCredentials},
{"opencode auth", "opencode-acp", "Unauthorized request", CodeAuthRequired},
{"copilot subscription", "copilot-acp", "user is not entitled to Copilot", CodeSubscriptionRequired},
{"copilot signin", "copilot-acp", "please sign in via gh auth login", CodeAuthRequired},
{"amp auth", "amp-acp", "Invalid Token returned by server", CodeAuthRequired},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := Classify(Input{Phase: PhaseSessionInit, ProviderID: c.providerID, Stderr: c.stderr})
if e.Code != c.wantCode {
t.Fatalf("%s → %s, want %s (rule=%s)", c.name, e.Code, c.wantCode, e.ClassifierRule)
}
})
}
}
func TestClassify_PrestartPhaseFallback(t *testing.T) {
resetInjection()
for _, p := range []Phase{PhaseAuthCheck, PhaseProcessStart, PhaseSessionInit} {
e := Classify(Input{Phase: p, ProviderID: "unknown-provider", Stderr: "weird gibberish"})
if e.Code != CodeUnknownProvider {
t.Fatalf("phase %s → %s, want unknown_provider_error", p, e.Code)
}
if !e.FallbackAllowed || !e.AutoRetryable {
t.Fatalf("phase %s invariants violated: %+v", p, e)
}
if e.Confidence != ConfLow {
t.Fatalf("phase %s confidence %s want low", p, e.Confidence)
}
}
}
func TestClassify_PoststartAmbiguousNoFallback(t *testing.T) {
resetInjection()
for _, p := range []Phase{PhasePromptSend, PhaseStreaming, PhaseToolExecution, PhaseShutdown} {
e := Classify(Input{Phase: p, ProviderID: "claude-acp", Stderr: "unexpected glitch"})
if e.Code != CodeAgentRuntime {
t.Fatalf("phase %s → %s, want agent_runtime_error", p, e.Code)
}
if e.FallbackAllowed {
t.Fatalf("phase %s must not fall back", p)
}
}
}
func TestClassify_InvariantsAuthRequired(t *testing.T) {
resetInjection()
e := Classify(Input{Phase: PhaseSessionInit, ProviderID: "claude-acp", Stderr: "not authenticated"})
if e.Code != CodeAuthRequired {
t.Fatalf("got %s", e.Code)
}
if !e.FallbackAllowed || e.AutoRetryable || !e.UserAction {
t.Fatalf("auth_required invariants violated: %+v", e)
}
}
func TestClassify_InvariantsModelUnavailable(t *testing.T) {
resetInjection()
e := Classify(Input{Phase: PhaseSessionInit, ProviderID: "claude-acp", Stderr: "model claude-x not found"})
if e.Code != CodeModelUnavailable {
t.Fatalf("got %s", e.Code)
}
if !e.FallbackAllowed {
t.Fatalf("model_unavailable must allow fallback: %+v", e)
}
}
func TestClassify_InvariantsQuotaLimitedAutoRetryable(t *testing.T) {
resetInjection()
e := Classify(Input{Phase: PhaseSessionInit, ProviderID: "claude-acp", Stderr: "anthropic_quota_exceeded"})
if e.Code != CodeQuotaLimited {
t.Fatalf("got %s", e.Code)
}
if !e.AutoRetryable || !e.FallbackAllowed {
t.Fatalf("quota_limited invariants violated: %+v", e)
}
}
func TestClassify_InjectionShortCircuit(t *testing.T) {
resetInjection()
t.Setenv("KANDEV_PROVIDER_FAILURES", "claude-acp:quota_limited,codex-acp:auth_required")
resetInjection()
e := Classify(Input{Phase: PhaseSessionInit, ProviderID: "claude-acp", Stderr: "completely unrelated text"})
if e.Code != CodeQuotaLimited {
t.Fatalf("injection ignored: got %s", e.Code)
}
if e.ClassifierRule != "inject.env" {
t.Fatalf("expected inject.env rule, got %s", e.ClassifierRule)
}
}
func TestClassify_SanitizesRawExcerpt(t *testing.T) {
resetInjection()
e := Classify(Input{Phase: PhaseSessionInit, ProviderID: "claude-acp", Stderr: "Bearer abcdefghijklmnopqrstuvwxyz1234567890"})
if e.RawExcerpt == "" {
t.Fatal("expected raw excerpt set")
}
if containsSubstring(e.RawExcerpt, "abcdefghijklmnopqrstuvwxyz") {
t.Fatalf("raw excerpt not sanitized: %q", e.RawExcerpt)
}
}
func TestError_String(t *testing.T) {
e := &Error{Code: CodeAuthRequired, ClassifierRule: "claude.stderr.auth.v1"}
if got := e.Error(); got != "auth_required: claude.stderr.auth.v1" {
t.Fatalf("got %q", got)
}
}
func containsSubstring(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}