internal/checker: error classification + RU hints + tests
Build / test (push) Failing after 38s
Build / build-windows (push) Has been skipped

Adds:
- retry.go: classifyError() splits errors into Permanent vs Transient
  (used to gate auto-retry); isContextErr() detects ctx cancellation
  through wrapping (OpError, errors.Join).
- hints.go: hintFor(testID, err) returns short Russian explanation per
  failure step, with dedicated branches for SOCKS5 sentinels, every
  documented REP code (0x01..0x08), STUN sentinels, timeouts, and a
  friendly-name fallback.

Coverage: retry.go 100%, hints.go 100%; package total 94.2%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 15:58:56 +03:00
parent 36e788402a
commit acd5291604
4 changed files with 577 additions and 0 deletions
+163
View File
@@ -0,0 +1,163 @@
package checker
import (
"context"
"errors"
"syscall"
"testing"
"github.com/stretchr/testify/assert"
)
func TestHintFor(t *testing.T) {
t.Run("nil_err_returns_empty", func(t *testing.T) {
assert.Equal(t, "", hintFor("tcp", nil))
assert.Equal(t, "", hintFor("anything", nil))
})
t.Run("context_canceled_uniform", func(t *testing.T) {
// Cancellation is always reported as «Проверка отменена.» across
// all testIDs.
for _, id := range []string{"tcp", "greet", "auth", "connect", "udp", "stun", "api", "unknown"} {
assert.Equal(t, "Проверка отменена.", hintFor(id, context.Canceled), "id=%s", id)
assert.Equal(t, "Проверка отменена.", hintFor(id, context.DeadlineExceeded), "id=%s", id)
}
})
cases := []struct {
name string
testID string
err error
substring string
}{
{"tcp_timeout", "tcp", &timeoutOnlyError{}, "таймаут"},
{"greet_bad_version_mentions_socks5", "greet", ErrSocks5BadVersion, "SOCKS5"},
{"greet_bad_version_mentions_negation", "greet", ErrSocks5BadVersion, "не"},
{"greet_rejected_all_auth_mentions_auth_or_methods", "greet", ErrSocks5RejectedAllAuth, "авторизаци"},
{"auth_login", "auth", ErrAuthRejected, "Логин"},
{"auth_password", "auth", ErrAuthRejected, "паро"},
{"connect_refused_rep05", "connect", ErrSocks5Reply{Code: 0x05}, "REP=05"},
{"connect_refused_text", "connect", ErrSocks5Reply{Code: 0x05}, "connection refused"},
{"connect_unsupported_rep07", "connect", ErrSocks5Reply{Code: 0x07}, "REP=07"},
{"udp_unsupported_mentions_udp", "udp", ErrSocks5Reply{Code: 0x07}, "UDP"},
{"udp_unsupported_mentions_unsupported", "udp", ErrSocks5Reply{Code: 0x07}, "не поддерж"},
{"udp_atyp_ipv6", "udp", ErrUnsupportedRelayATYP, "IPv6"},
{"stun_no_mapped_xor", "stun", ErrSTUNNoMappedAddress, "XOR-MAPPED"},
{"stun_timeout_mentions_stun", "stun", &timeoutOnlyError{}, "STUN"},
{"api_timeout_mentions_api_or_timeout", "api", &timeoutOnlyError{}, "таймаут"},
{"unknown_test_fallback_id", "unknown_test", errors.New("oops"), "unknown_test"},
{"unknown_test_fallback_err", "unknown_test", errors.New("oops"), "oops"},
{"tcp_fallback_friendly_name", "tcp", errors.New("weird"), "TCP"},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
got := hintFor(c.testID, c.err)
assert.Contains(t, got, c.substring, "got=%q", got)
})
}
}
func TestHintFor_AllSocks5ReplyCodesCovered(t *testing.T) {
// Every documented REP code (0x01..0x08) should produce a non-empty
// hint when surfaced via "connect" or "udp".
for code := byte(0x01); code <= 0x08; code++ {
err := ErrSocks5Reply{Code: code}
assert.NotEmpty(t, hintFor("connect", err), "connect code=%02X", code)
assert.NotEmpty(t, hintFor("udp", err), "udp code=%02X", code)
}
// Unknown REP code (0xFE) still gets a sensible fallback rather than
// an empty string.
err := ErrSocks5Reply{Code: 0xFE}
assert.NotEmpty(t, hintFor("connect", err))
assert.NotEmpty(t, hintFor("udp", err))
}
func TestHintFor_PerStepBranches(t *testing.T) {
cases := []struct {
name string
testID string
err error
substring string
}{
// tcp: ECONNREFUSED + generic fallback
{"tcp_econnrefused", "tcp", syscall.ECONNREFUSED, "отклонил"},
{"tcp_generic", "tcp", errors.New("dial fail"), "TCP"},
// greet: short reply, timeout, fallback
{"greet_short_reply", "greet", ErrShortReply, "укороченный"},
{"greet_timeout", "greet", &timeoutOnlyError{}, "вовремя"},
{"greet_fallback", "greet", errors.New("weird"), "приветствие"},
// auth: credential too long, short reply, timeout, fallback
{"auth_cred_too_long", "auth", ErrCredentialTooLong, "255"},
{"auth_short_reply", "auth", ErrShortReply, "укороченный"},
{"auth_timeout", "auth", &timeoutOnlyError{}, "вовремя"},
{"auth_fallback", "auth", errors.New("weird"), "авторизация"},
// connect: host too long, short reply, timeout, generic REP, fallback
{"connect_host_too_long", "connect", ErrHostTooLong, "255"},
{"connect_short_reply", "connect", ErrShortReply, "укороченный"},
{"connect_timeout", "connect", &timeoutOnlyError{}, "вовремя"},
{"connect_generic_rep", "connect", ErrSocks5Reply{Code: 0x03}, "REP=03"},
{"connect_unknown_rep", "connect", ErrSocks5Reply{Code: 0xFE}, "REP=FE"},
{"connect_fallback", "connect", errors.New("weird"), "TCP-туннель"},
// udp: short reply, timeout, fallback, non-7 REP
{"udp_short_reply", "udp", ErrShortReply, "укороченный"},
{"udp_timeout", "udp", &timeoutOnlyError{}, "вовремя"},
{"udp_other_rep", "udp", ErrSocks5Reply{Code: 0x05}, "REP=05"},
{"udp_unknown_rep", "udp", ErrSocks5Reply{Code: 0xFE}, "REP=FE"},
{"udp_fallback", "udp", errors.New("weird"), "UDP ASSOCIATE"},
// stun: every sentinel branch
{"stun_too_short", "stun", ErrSTUNTooShort, "20"},
{"stun_bad_magic", "stun", ErrSTUNBadMagicCookie, "magic"},
{"stun_not_success", "stun", ErrSTUNNotSuccess, "Binding"},
{"stun_txid_mismatch", "stun", ErrSTUNTxIDMismatch, "transaction"},
{"stun_unsupported_family", "stun", ErrSTUNUnsupportedFamily, "семейством"},
{"stun_fallback", "stun", errors.New("weird"), "STUN"},
// api: timeout vs generic
{"api_timeout", "api", &timeoutOnlyError{}, "таймаут"},
{"api_generic", "api", errors.New("tls boom"), "TLS"},
// socks5ReplyHint via uncategorised step (default branch)
// — we can't reach it via hintFor with current testIDs, but the
// default formatter still needs to be exercised.
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
got := hintFor(c.testID, c.err)
assert.Contains(t, got, c.substring, "got=%q", got)
})
}
}
func TestSocks5ReplyHint_DefaultStep(t *testing.T) {
// socks5ReplyHint("", code) hits the final fallback formatter.
got := socks5ReplyHint("", 0x03)
assert.Contains(t, got, "REP=03")
got = socks5ReplyHint("", 0xFE)
assert.Contains(t, got, "REP=FE")
}
func TestTcpFriendlyName(t *testing.T) {
cases := map[string]string{
"tcp": "TCP",
"greet": "приветствие SOCKS5",
"auth": "авторизация SOCKS5",
"connect": "TCP-туннель к Discord",
"udp": "UDP ASSOCIATE",
"stun": "STUN round-trip",
"api": "Discord API",
"weirdo": "weirdo",
}
for in, want := range cases {
t.Run(in, func(t *testing.T) {
assert.Equal(t, want, tcpFriendlyName(in))
})
}
}