package diagnostics
import (
"errors"
"net/http"
"strings"
"testing"
"gitcode-mcp/internal/cache"
)
type codedError struct {
code string
msg string
}
func (e codedError) Error() string { return e.msg }
func (e codedError) DiagnosticCode() string { return e.code }
func TestClassifierLivePrecedenceAndHTTPInvariants(t *testing.T) {
tests := []struct {
name string
err error
ctx CommandContext
want Code
http bool
retryable bool
exitClass string
}{
{name: "SCN-DIAG-PRECEDENCE-01 configuration before missing credential", err: codedError{code: "missing_credential", msg: "missing"}, ctx: CommandContext{ProviderMode: "live-http", BroaderConfigurationInvalid: true, MissingCredential: true}, want: CodeConfigCredential, exitClass: "configuration"},
{name: "SCN-DIAG-PRECEDENCE-02 missing credential before invalid base", err: codedError{code: "invalid_api_base_url", msg: "bad"}, ctx: CommandContext{ProviderMode: "live-http", MissingCredential: true, InvalidSelectedAPIBaseURL: true}, want: CodeConfigCredential, exitClass: "configuration"},
{name: "SCN-DIAG-PRECEDENCE-03 invalid base after credential present", err: codedError{code: "invalid_api_base_url", msg: "bad"}, ctx: CommandContext{ProviderMode: "live-http", InvalidSelectedAPIBaseURL: true}, want: CodeConfigCredential, exitClass: "configuration"},
{name: "SCN-DIAG-PRECEDENCE-04 remote 401 attempted is api validation", err: codedError{code: "auth_expired", msg: "auth"}, ctx: CommandContext{ProviderMode: "live-http", HTTPStatus: http.StatusUnauthorized, HTTPAttempted: true}, want: CodeAPIFailure, http: true, exitClass: "provider"},
{name: "SCN-DIAG-PRECEDENCE-05 remote 400 is api validation", err: codedError{code: "api_validation", msg: "bad request"}, ctx: CommandContext{ProviderMode: "live-http", HTTPStatus: http.StatusBadRequest, HTTPAttempted: true}, want: CodeAPIFailure, http: true, exitClass: "provider"},
{name: "SCN-DIAG-PRECEDENCE-06 malformed 200 json is schema decode", err: codedError{code: "schema_decode", msg: "malformed"}, ctx: CommandContext{ProviderMode: "live-http", HTTPStatus: http.StatusOK, HTTPAttempted: true, MalformedSuccess: true}, want: CodeSchemaDecode, http: true, exitClass: "schema"},
{name: "SCN-DIAG-PRECEDENCE-07 schema mismatch is schema decode", err: codedError{code: "schema_decode", msg: "shape"}, ctx: CommandContext{ProviderMode: "live-http", HTTPStatus: http.StatusOK, HTTPAttempted: true, SchemaDecodeFailure: true}, want: CodeSchemaDecode, http: true, exitClass: "schema"},
{name: "SCN-DIAG-PRECEDENCE-08 local body limit is schema decode", err: codedError{code: "payload_too_large", msg: "large"}, ctx: CommandContext{ProviderMode: "live-http", HTTPAttempted: true, LocalPayloadTooLarge: true, FailureSource: "local_body_limit"}, want: CodeSchemaDecode, http: true, exitClass: "schema"},
{name: "SCN-DIAG-PRECEDENCE-09 remote 413 is api validation", err: codedError{code: "payload_too_large", msg: "large"}, ctx: CommandContext{ProviderMode: "live-http", HTTPStatus: http.StatusRequestEntityTooLarge, HTTPAttempted: true, FailureSource: "remote_status"}, want: CodeAPIFailure, http: true, exitClass: "provider"},
{name: "SCN-DIAG-PRECEDENCE-10 partial response is schema decode", err: codedError{code: "partial_response", msg: "partial"}, ctx: CommandContext{ProviderMode: "live-http", HTTPAttempted: true, FailureSource: "partial_response"}, want: CodeSchemaDecode, http: true, exitClass: "schema"},
{name: "SCN-DIAG-PRECEDENCE-11 timeout is transport", err: codedError{code: "network_unavailable", msg: "timeout"}, ctx: CommandContext{ProviderMode: "live-http", HTTPAttempted: true, TransportFailure: true}, want: CodeLiveTransportFailure, http: true, retryable: true, exitClass: "transport"},
{name: "SCN-DIAG-PRECEDENCE-12 http 500 is transport", err: codedError{code: "network_unavailable", msg: "server error"}, ctx: CommandContext{ProviderMode: "live-http", HTTPStatus: http.StatusInternalServerError, HTTPAttempted: true}, want: CodeLiveTransportFailure, http: true, retryable: true, exitClass: "transport"},
{name: "SCN-DIAG-PRECEDENCE-13 401 without attempted is config credential", err: codedError{code: "auth_expired", msg: "auth"}, ctx: CommandContext{ProviderMode: "live-http", HTTPStatus: http.StatusUnauthorized}, want: CodeConfigCredential, exitClass: "configuration"},
{name: "SCN-DIAG-PRECEDENCE-14 live fixture sentinel", err: codedError{code: "write_fixture_fallback_detected", msg: "fixture client is read-only"}, ctx: CommandContext{ProviderMode: "live-http"}, want: CodeFixtureFallbackDetected, exitClass: "fixture"},
{name: "SCN-DIAG-PRECEDENCE-15 non-live fixture read only", err: codedError{code: "fixture_read_only", msg: "fixture client is read-only"}, ctx: CommandContext{ProviderMode: "offline-fixture"}, want: CodeFixtureReadOnly, exitClass: "fixture"},
{name: "SCN-DIAG-PRECEDENCE-16 push mirror cooldown is retryable provider response", err: codedError{code: "push_mirror_sync_in_progress", msg: "cooldown"}, ctx: CommandContext{ProviderMode: "live-http", HTTPStatus: http.StatusForbidden, HTTPAttempted: true, RetryableProviderFailure: true}, want: CodePushMirrorSyncInProgress, http: true, retryable: true, exitClass: "provider"},
{name: "SCN-DIAG-PRECEDENCE-17 invalid query remains caller input", err: codedError{code: "invalid_query", msg: "line and position differ"}, ctx: CommandContext{ProviderMode: "live-http"}, want: CodeInvalidQuery, exitClass: "input"},
{name: "SCN-DIAG-PRECEDENCE-18 anchor mismatch is provider confirmation failure", err: codedError{code: "pr_review_anchor_mismatch", msg: "wrong line"}, ctx: CommandContext{ProviderMode: "live-http", HTTPAttempted: true}, want: CodePRReviewAnchorMismatch, http: true, exitClass: "provider"},
{name: "SCN-DIAG-PRECEDENCE-19 missing write confirmation is provider failure", err: codedError{code: "write_confirmation_incomplete", msg: "readback missing"}, ctx: CommandContext{ProviderMode: "live-http", HTTPAttempted: true}, want: CodeWriteConfirmationMissing, http: true, exitClass: "provider"},
{name: "SCN-DIAG-PRECEDENCE-20 unavailable discussion reply is typed capability", err: codedError{code: "discussion_reply_unavailable", msg: "no provider id"}, ctx: CommandContext{ProviderMode: "live-http", HTTPAttempted: true}, want: CodeDiscussionReplyUnavailable, http: true, exitClass: "capability"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Classify(tt.err, tt.ctx)
if got.Code != tt.want || got.HTTPAttempted != tt.http || got.Retryable != tt.retryable || got.ExitClass != tt.exitClass {
t.Fatalf("got code=%s http=%t retryable=%t exit=%s want code=%s http=%t retryable=%t exit=%s", got.Code, got.HTTPAttempted, got.Retryable, got.ExitClass, tt.want, tt.http, tt.retryable, tt.exitClass)
}
})
}
}
func TestClassifierUnsupportedPayloadContextRedacted(t *testing.T) {
d := Classify(errors.New("payload token=secret-token missing id"), CommandContext{ProviderMode: "live-http", Command: "sync", PayloadKind: "issue", PayloadSource: "mock token=secret-token", RequiredField: "id", UnsupportedPayload: true, SensitiveTerms: []string{"secret-token"}})
if d.Code != CodeSchemaDecode {
t.Fatalf("unexpected code: %s", d.Code)
}
if d.Context["payload_kind"] != "issue" || d.Context["payload_source"] == "" || d.Context["required_field"] != "id" || d.Context["command"] != "sync" || d.Context["provider_mode"] != "live-http" {
t.Fatalf("missing required context: %#v", d.Context)
}
joined := d.Message + strings.Join(mapValues(d.Context), " ")
if strings.Contains(joined, "secret-token") {
t.Fatalf("diagnostic leaked secret: %#v message=%s", d.Context, d.Message)
}
}
func TestClassifierLegacyCodeNormalization(t *testing.T) {
tests := []struct {
name string
err error
ctx CommandContext
want Code
}{
{name: "SCN-DIAG-LEGACY-NORMALIZATION-01 live api failure", err: codedError{code: "live_api_failure", msg: "api"}, ctx: CommandContext{ProviderMode: "live-http", HTTPAttempted: true}, want: CodeAPIFailure},
{name: "SCN-DIAG-LEGACY-NORMALIZATION-02 unsupported mock payload", err: codedError{code: "unsupported_mock_payload", msg: "payload"}, ctx: CommandContext{ProviderMode: "live-http"}, want: CodeSchemaDecode},
{name: "SCN-DIAG-LEGACY-NORMALIZATION-03 live auth with http", err: codedError{code: "live_auth_failure", msg: "auth"}, ctx: CommandContext{ProviderMode: "live-http", HTTPAttempted: true}, want: CodeAPIFailure},
{name: "SCN-DIAG-LEGACY-NORMALIZATION-04 non-live legacy api", err: codedError{code: "live_api_failure", msg: "api"}, ctx: CommandContext{ProviderMode: "offline-fixture"}, want: CodeAPIFailure},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Classify(tt.err, tt.ctx); got.Code != tt.want {
t.Fatalf("got %s want %s", got.Code, tt.want)
}
})
}
}
func TestClassifierLiveDecommissionInvariant(t *testing.T) {
badCodes := map[Code]bool{CodeLiveTransportFailure: true, CodeConfigurationError: true, CodeLiveAPIFailure: true, CodeLiveAuthFailure: true, CodeUnsupportedMockPayload: true, CodeMissingCredential: true, CodeInvalidAPIBaseURL: true}
tests := []struct {
name string
err error
ctx CommandContext
}{
{name: "SCN-DIAG-DECOM-01 400", err: codedError{code: "api_validation", msg: "bad request"}, ctx: CommandContext{ProviderMode: "live-http", HTTPStatus: http.StatusBadRequest, HTTPAttempted: true}},
{name: "SCN-DIAG-DECOM-02 malformed json", err: codedError{code: "schema_decode", msg: "malformed"}, ctx: CommandContext{ProviderMode: "live-http", HTTPStatus: http.StatusOK, HTTPAttempted: true, MalformedSuccess: true}},
{name: "SCN-DIAG-DECOM-03 schema mismatch", err: codedError{code: "schema_decode", msg: "shape"}, ctx: CommandContext{ProviderMode: "live-http", HTTPStatus: http.StatusOK, HTTPAttempted: true, SchemaDecodeFailure: true}},
{name: "SCN-DIAG-DECOM-04 partial response", err: codedError{code: "partial_response", msg: "partial"}, ctx: CommandContext{ProviderMode: "live-http", HTTPAttempted: true, FailureSource: "partial_response"}},
{name: "SCN-DIAG-DECOM-05 local body limit", err: codedError{code: "payload_too_large", msg: "large"}, ctx: CommandContext{ProviderMode: "live-http", HTTPAttempted: true, FailureSource: "local_body_limit"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Classify(tt.err, tt.ctx)
if badCodes[got.Code] {
t.Fatalf("decommissioned visible class returned: %s", got.Code)
}
if got.Code != CodeAPIFailure && got.Code != CodeSchemaDecode {
t.Fatalf("got %s want api_validation or schema_decode", got.Code)
}
})
}
}
func mapValues(m map[string]string) []string {
out := make([]string, 0, len(m))
for _, value := range m {
out = append(out, value)
}
return out
}
func TestClassifierFailureSourceMapping(t *testing.T) {
tests := []struct {
name string
err error
ctx CommandContext
want Code
}{
{name: "SCN-DIAG-FAILURE-SOURCE-01 remote status payload size api validation", err: codedError{code: "payload_too_large", msg: "too large"}, ctx: CommandContext{ProviderMode: "live-http", HTTPStatus: http.StatusRequestEntityTooLarge, HTTPAttempted: true, FailureSource: "remote_status"}, want: CodeAPIFailure},
{name: "SCN-DIAG-FAILURE-SOURCE-02 local body limit schema decode", err: codedError{code: "payload_too_large", msg: "too large"}, ctx: CommandContext{ProviderMode: "live-http", HTTPAttempted: true, FailureSource: "local_body_limit"}, want: CodeSchemaDecode},
{name: "SCN-DIAG-FAILURE-SOURCE-03 local decode boundary schema decode", err: codedError{code: "partial_response", msg: "partial"}, ctx: CommandContext{ProviderMode: "live-http", HTTPAttempted: true, FailureSource: "local_decode_boundary"}, want: CodeSchemaDecode},
{name: "SCN-DIAG-FAILURE-SOURCE-04 write wrapper local body limit beats provider fallback", err: codedError{code: "write_provider_error", msg: "provider"}, ctx: CommandContext{ProviderMode: "live-http", HTTPAttempted: true, FailureSource: "local_body_limit"}, want: CodeSchemaDecode},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Classify(tt.err, tt.ctx)
if got.Code != tt.want {
t.Fatalf("got %s want %s", got.Code, tt.want)
}
})
}
}
func TestClassifierUnsupportedCapability(t *testing.T) {
err := codedError{code: "unsupported_capability", msg: "gitcode: unsupported capability \"comments_read\": deferred for iteration 5"}
d := Classify(err, CommandContext{ProviderMode: "live-http"})
if d.Code != CodeUnsupportedCapability {
t.Fatalf("expected CodeUnsupportedCapability, got %s", d.Code)
}
if d.ExitClass != "capability" {
t.Fatalf("expected exitClass capability, got %s", d.ExitClass)
}
if d.HTTPAttempted {
t.Fatal("expected HTTPAttempted false")
}
if d.Retryable {
t.Fatal("expected Retryable false")
}
if !strings.Contains(d.Message, "unsupported_capability") {
t.Fatalf("expected message to contain unsupported_capability: %s", d.Message)
}
}
func TestClassifierCacheBusy(t *testing.T) {
t.Run("live-http-mode", func(t *testing.T) {
err := codedError{code: "cache_busy", msg: "cache: lock contention"}
d := Classify(err, CommandContext{ProviderMode: "live-http"})
if d.Code != CodeCacheBusy {
t.Fatalf("expected CodeCacheBusy, got %s", d.Code)
}
if d.ExitClass != "cache" {
t.Fatalf("expected exitClass cache, got %s", d.ExitClass)
}
if d.HTTPAttempted {
t.Fatal("expected HTTPAttempted false")
}
if !d.Retryable {
t.Fatal("expected Retryable true")
}
if !strings.Contains(d.Message, "cache_busy") {
t.Fatalf("expected message to contain cache_busy: %s", d.Message)
}
})
t.Run("non-live-mode-code-from-error", func(t *testing.T) {
err := codedError{code: "cache_busy", msg: "cache: lock contention"}
d := Classify(err, CommandContext{ProviderMode: "offline-fixture"})
if d.Code != CodeCacheBusy {
t.Fatalf("expected CodeCacheBusy, got %s", d.Code)
}
if d.ExitClass != "cache" {
t.Fatalf("expected exitClass cache, got %s", d.ExitClass)
}
if !d.Retryable {
t.Fatal("expected Retryable true")
}
})
t.Run("live-http-no-false-classification-as-configuration-error", func(t *testing.T) {
err := codedError{code: "cache_busy", msg: "cache: lock contention"}
d := Classify(err, CommandContext{ProviderMode: "live-http"})
if d.Code == CodeConfigurationError {
t.Fatal("cache_busy must not classify as CodeConfigurationError")
}
})
t.Run("ErrLockContention-has-diagnostic-code", func(t *testing.T) {
err := cache.ErrLockContention{Path: "test.lock", Operation: "write", RepoID: "fixture-a"}
if err.DiagnosticCode() != "cache_busy" {
t.Fatalf("DiagnosticCode() = %q, want cache_busy", err.DiagnosticCode())
}
})
t.Run("ErrLockContention-classifies-as-cache-busy", func(t *testing.T) {
err := cache.ErrLockContention{Path: "test.lock", Operation: "write", RepoID: "fixture-a"}
d := Classify(err, CommandContext{ProviderMode: "live-http"})
if d.Code != CodeCacheBusy {
t.Fatalf("expected CodeCacheBusy, got %s", d.Code)
}
if !d.Retryable {
t.Fatal("expected Retryable true")
}
if d.ExitClass != "cache" {
t.Fatalf("expected exitClass cache, got %s", d.ExitClass)
}
})
}