feat(test):新增单元测试

This commit is contained in:
chengqian committed 2026-06-04 11:36:03 +08:00
1 parent 62826d9140
commit f3b4be9b2a
7 files changed
+1006

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
package relay
import (
"crypto/sha256"
"encoding/hex"
"testing"
)
func TestApiKeyHash(t *testing.T) {
const key = "sk-abcdef123456"
got := apiKeyHash(key)
// 契约:apiKeyHash 等价于 hex(sha256(key))
sum := sha256.Sum256([]byte(key))
want := hex.EncodeToString(sum[:])
if got != want {
t.Errorf("apiKeyHash(%q) = %s, want %s", key, got, want)
}
if len(got) != 64 {
t.Errorf("hash length = %d, want 64 hex chars", len(got))
}
// 确定性
if apiKeyHash(key) != got {
t.Error("apiKeyHash is not deterministic")
}
// 不同输入 -> 不同输出
if apiKeyHash("sk-different-key") == got {
t.Error("different inputs produced the same hash")
}
}
func TestDefaultChannelSettings(t *testing.T) {
s := DefaultChannelSettings()
if s.TimeoutSeconds != 60 {
t.Errorf("default TimeoutSeconds = %d, want 60", s.TimeoutSeconds)
}
if s.RetryCount != 1 {
t.Errorf("default RetryCount = %d, want 1", s.RetryCount)
}
}
func TestParseChannelSettings_Defaults(t *testing.T) {
for _, in := range []string{"", "{}", "null"} {
s := ParseChannelSettings(in)
if s.TimeoutSeconds != 60 || s.RetryCount != 1 {
t.Errorf("ParseChannelSettings(%q) = %+v, want defaults (60,1)", in, s)
}
}
}
func TestParseChannelSettings_InvalidJSONFallsBackToDefaults(t *testing.T) {
s := ParseChannelSettings("{not valid json")
if s.TimeoutSeconds != 60 || s.RetryCount != 1 {
t.Errorf("invalid JSON should fall back to defaults, got %+v", s)
}
}
func TestParseChannelSettings_PartialOverrideKeepsDefaults(t *testing.T) {
// 只覆盖 timeout,retry 缺省应回填为 1
s := ParseChannelSettings(`{"timeout_seconds":30}`)
if s.TimeoutSeconds != 30 {
t.Errorf("TimeoutSeconds = %d, want 30", s.TimeoutSeconds)
}
if s.RetryCount != 1 {
t.Errorf("missing RetryCount should default to 1, got %d", s.RetryCount)
}
}
func TestParseChannelSettings_FullOverride(t *testing.T) {
s := ParseChannelSettings(`{"timeout_seconds":120,"retry_count":3}`)
if s.TimeoutSeconds != 120 {
t.Errorf("TimeoutSeconds = %d, want 120", s.TimeoutSeconds)
}
if s.RetryCount != 3 {
t.Errorf("RetryCount = %d, want 3", s.RetryCount)
}
}
+111
View File
@@ -0,0 +1,111 @@
package relay
import (
"math"
"testing"
)
const floatEps = 1e-6
func floatEq(a, b float64) bool {
return math.Abs(a-b) < floatEps
}
func TestCalcLatencyScore(t *testing.T) {
tests := []struct {
name string
latency float64
want float64
}{
{"zero", 0, 100},
{"under 1s", 500, 100},
{"exactly 1s", 1000, 100},
{"1s-3s midpoint 2s", 2000, 65}, // 80 - (2000-1000)/2000*30
{"exactly 3s", 3000, 50}, // 80 - (3000-1000)/2000*30
{"3s-10s midpoint 6.5s", 6500, 35}, // 50 - (6500-3000)/7000*30
{"exactly 10s", 10000, 20}, // 50 - (10000-3000)/7000*30
{"over 10s", 20000, 20},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := calcLatencyScore(tt.latency); !floatEq(got, tt.want) {
t.Errorf("calcLatencyScore(%.0f) = %.4f, want %.4f", tt.latency, got, tt.want)
}
})
}
}
func TestCalcHealthScore(t *testing.T) {
tests := []struct {
name string
successRate float64
latencyMs float64
stability float64
consecutiveFax int
want float64
}{
// 全满分:100*.4 + 100*.25 + 100*.2 + 100*.15 = 100
{"perfect", 100, 500, 100, 0, 100},
// 全最差:0 + 20*.25 + 0 + 0 = 5
{"worst", 0, 20000, 0, 5, 5},
// 80*.4 + 65*.25(2s) + 90*.2 + 80*.15(1 fail) = 32 + 16.25 + 18 + 12 = 78.25
{"mixed", 80, 2000, 90, 1, 78.25},
// 连续失败分下限为 0(不为负):100*.4 + 100*.25 + 100*.2 + 0*.15 = 85
{"fail score floored at zero", 100, 500, 100, 10, 85},
// 失败 5 次即 0 分:100*.4 + 100*.25 + 100*.2 + 0 = 85
{"five failures hits zero", 100, 500, 100, 5, 85},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := calcHealthScore(tt.successRate, tt.latencyMs, tt.stability, tt.consecutiveFax)
if !floatEq(got, tt.want) {
t.Errorf("calcHealthScore(%.0f,%.0f,%.0f,%d) = %.4f, want %.4f",
tt.successRate, tt.latencyMs, tt.stability, tt.consecutiveFax, got, tt.want)
}
if got < 0 || got > 100 {
t.Errorf("score %.4f out of [0,100] range", got)
}
})
}
}
func TestCalcHealthScore_RoundedToTwoDecimals(t *testing.T) {
// 构造一个会产生多位小数的组合,验证四舍五入到两位
got := calcHealthScore(33.333, 500, 33.333, 0)
// 33.333*.4 + 100*.25 + 33.333*.2 + 100*.15 = 13.3332 + 25 + 6.6666 + 15 = 59.9998 -> 60.00
if !floatEq(got, 60) {
t.Errorf("got %.6f, want 60.00 (rounded)", got)
}
}
func TestCalcStability(t *testing.T) {
tests := []struct {
name string
newLatency float64
oldLatency float64
current float64
want float64
}{
{"no history returns 100", 1500, 0, 50, 100},
{"negative old returns 100", 1500, -1, 50, 100},
// 延迟无变化:cur*0.9 + 1*100*0.1
{"stable latency keeps high", 1000, 1000, 100, 100}, // 90 + 10
{"stable latency from 50", 1000, 1000, 50, 55}, // 45 + 10
// 延迟翻倍:ratio 2 -> 0.5 -> cur*0.9 + 0.5*100*0.1
{"latency doubled", 2000, 1000, 80, 77}, // 72 + 5
// 延迟减半:ratio 0.5(保持)-> 对称,结果同上
{"latency halved is symmetric", 500, 1000, 80, 77}, // 72 + 5
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := calcStability(tt.newLatency, tt.oldLatency, tt.current)
if !floatEq(got, tt.want) {
t.Errorf("calcStability(%.0f,%.0f,%.0f) = %.4f, want %.4f",
tt.newLatency, tt.oldLatency, tt.current, got, tt.want)
}
if got < 0 || got > 100 {
t.Errorf("stability %.4f out of [0,100] range", got)
}
})
}
}
+182
View File
@@ -0,0 +1,182 @@
package relay
import (
"fmt"
"strings"
"testing"
"unicode/utf8"
"github.com/qianfree/team-api/relay/common"
)
func TestSafeUTF8Truncate(t *testing.T) {
tests := []struct {
name string
s string
maxLen int
}{
{"shorter than max", "hello", 10},
{"equal to max", "hello", 5},
{"ascii cut", "hello world", 5},
{"multibyte euro cut mid-rune", "a€b", 2}, // € = 3 bytes
{"multibyte euro cut mid-rune 2", "a€b", 3},
{"cjk cut", "你好世界", 5}, // each rune 3 bytes
{"emoji cut", "🚀🚀", 3}, // each emoji 4 bytes
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := safeUTF8Truncate(tt.s, tt.maxLen)
if len(got) > tt.maxLen {
t.Errorf("result len %d exceeds maxLen %d", len(got), tt.maxLen)
}
if !utf8.ValidString(got) {
t.Errorf("result %q is not valid UTF-8 (truncated mid-rune)", got)
}
if !strings.HasPrefix(tt.s, got) {
t.Errorf("result %q is not a prefix of input %q", got, tt.s)
}
})
}
}
func TestTruncateBody_ShortReturnedVerbatim(t *testing.T) {
s := "short body"
if got := truncateBody(s, 100); got != s {
t.Errorf("short body should be returned verbatim, got %q", got)
}
}
func TestTruncateBody_LongNonStreamTruncated(t *testing.T) {
s := strings.Repeat("x", 500)
got := truncateBody(s, 100)
if len(got) >= len(s) {
t.Errorf("expected truncation, got len %d (input %d)", len(got), len(s))
}
if !strings.HasSuffix(got, "...[truncated]") {
t.Errorf("expected truncation marker suffix, got %q", got)
}
}
func TestTruncateStreamBody_KeepsHeadAndTailDropsMiddle(t *testing.T) {
// 40 行短 SSE,每行 "data: Lxx"(9 字节)。尾部 20 行 = 199 字节。
// maxLen=250 留出 headBudget>0,结果形如 head + marker + tail。
var lines []string
for i := range 40 {
lines = append(lines, fmt.Sprintf("data: L%02d", i))
}
s := strings.Join(lines, "\n")
got := truncateStreamBody(s, 250)
if len(got) > len(s) {
t.Fatalf("output longer than input: %d > %d", len(got), len(s))
}
if !strings.Contains(got, "data: L00") {
t.Errorf("head line 'data: L00' should be kept, got:\n%s", got)
}
if !strings.Contains(got, "data: L39") {
t.Errorf("tail line 'data: L39' should be kept, got:\n%s", got)
}
if strings.Contains(got, "data: L10") {
t.Errorf("middle line 'data: L10' should have been dropped, got:\n%s", got)
}
if !strings.Contains(got, "...[truncated]...") {
t.Errorf("expected middle truncation marker, got:\n%s", got)
}
}
func TestTruncateBody_RoutesStreamAndPreservesLastLine(t *testing.T) {
// 流式响应的关键信息(usage / [DONE])在末尾。普通截断会保留头部、丢掉末尾;
// 流式感知截断会保留尾部。以此验证 truncateBody 正确路由到流式分支。
var lines []string
for i := range 39 {
lines = append(lines, fmt.Sprintf("data: L%02d", i))
}
lines = append(lines, "data: [DONE]")
s := strings.Join(lines, "\n")
got := truncateBody(s, 300)
if !strings.Contains(got, "[DONE]") {
t.Errorf("stream-aware truncation should preserve trailing [DONE], got:\n%s", got)
}
if len(got) >= len(s) {
t.Errorf("expected truncation, got len %d (input %d)", len(got), len(s))
}
}
func TestJSONNullIfEmpty(t *testing.T) {
if got := jsonNullIfEmpty(""); got != nil {
t.Errorf("empty string should map to nil, got %v", got)
}
if got := jsonNullIfEmpty("{}"); got != "{}" {
t.Errorf("non-empty string should pass through, got %v", got)
}
}
func TestParseCapabilitiesJSON(t *testing.T) {
if got := parseCapabilitiesJSON(""); got != nil {
t.Errorf("empty -> nil, got %v", got)
}
if got := parseCapabilitiesJSON("{}"); got != nil {
t.Errorf("empty object -> nil, got %v", got)
}
if got := parseCapabilitiesJSON("not json"); got != nil {
t.Errorf("invalid json -> nil, got %v", got)
}
got := parseCapabilitiesJSON(`{"vision":true,"tools":false}`)
if got == nil {
t.Fatal("valid json should parse to a map")
}
if !got["vision"] || got["tools"] {
t.Errorf("parsed map wrong: %v", got)
}
}
func TestBuildUsageLogDO_RequestTypeDerivation(t *testing.T) {
tests := []struct {
name string
requestType int
isStream bool
want int
}{
{"explicit type preserved", 3, true, 3},
{"derived stream", 0, true, 2},
{"derived sync", 0, false, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := buildUsageLogDO(&common.UsageRecord{
RequestType: tt.requestType,
IsStream: tt.isStream,
})
got, ok := d.RequestType.(int)
if !ok || got != tt.want {
t.Errorf("RequestType = %v (%T), want %d", d.RequestType, d.RequestType, tt.want)
}
})
}
}
func TestBuildUsageLogDO_FieldMapping(t *testing.T) {
rec := &common.UsageRecord{
TenantID: 7,
LatencyMs: 123.9, // 应被截断为 int 123
BillingSnapshot: "", // 空 -> nil
}
d := buildUsageLogDO(rec)
if d.TenantId != int64(7) {
t.Errorf("TenantId = %v, want 7", d.TenantId)
}
if got, ok := d.LatencyMs.(int); !ok || got != 123 {
t.Errorf("LatencyMs = %v (%T), want int 123", d.LatencyMs, d.LatencyMs)
}
if d.BillingSnapshot != nil {
t.Errorf("empty BillingSnapshot should map to nil, got %v", d.BillingSnapshot)
}
rec2 := &common.UsageRecord{BillingSnapshot: `{"k":1}`}
if got := buildUsageLogDO(rec2).BillingSnapshot; got != `{"k":1}` {
t.Errorf("non-empty BillingSnapshot should pass through, got %v", got)
}
}
+187
View File
@@ -0,0 +1,187 @@
package middleware
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"testing"
)
func TestComputeHMACSignature(t *testing.T) {
const (
secret = "top-secret"
ts = "1700000000"
nonce = "abc123"
method = "POST"
path = "/open/v1/messages"
)
body := []byte(`{"hello":"world"}`)
got := computeHMACSignature(secret, ts, nonce, method, path, body)
// 契约:HMAC-SHA256(secret, "ts\nnonce\nmethod\npath\nhex(sha256(body))")
bodyHash := sha256.Sum256(body)
msg := fmt.Sprintf("%s\n%s\n%s\n%s\n%s", ts, nonce, method, path, hex.EncodeToString(bodyHash[:]))
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(msg))
want := hex.EncodeToString(mac.Sum(nil))
if got != want {
t.Errorf("signature = %s, want %s", got, want)
}
// 确定性
if computeHMACSignature(secret, ts, nonce, method, path, body) != got {
t.Error("signature is not deterministic")
}
// body 改变 -> 签名改变(请求完整性)
if computeHMACSignature(secret, ts, nonce, method, path, []byte(`{"hello":"WORLD"}`)) == got {
t.Error("changing the body must change the signature")
}
// secret 改变 -> 签名改变
if computeHMACSignature("other-secret", ts, nonce, method, path, body) == got {
t.Error("changing the secret must change the signature")
}
// 空 body 与有 body 的签名必须不同
if computeHMACSignature(secret, ts, nonce, method, path, nil) == got {
t.Error("empty body and non-empty body must differ")
}
}
func TestParseResourceFromPath(t *testing.T) {
tests := []struct {
path string
wantType string
wantID int64
}{
{"/api/admin/channels", "channel", 0},
{"/api/admin/channels/123", "channel", 123},
{"/api/tenant/users/5", "user", 5},
{"/api/admin/models", "model", 0},
{"/api/admin/orders/42", "order", 42},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
gotType, gotID := parseResourceFromPath(tt.path)
if gotType != tt.wantType || gotID != tt.wantID {
t.Errorf("parseResourceFromPath(%q) = (%q,%d), want (%q,%d)",
tt.path, gotType, gotID, tt.wantType, tt.wantID)
}
})
}
}
func TestBuildAction(t *testing.T) {
tests := []struct {
name string
method string
path string
resourceType string
want string
}{
{"create", "POST", "/api/admin/channels", "channel", "create_channel"},
{"update", "PUT", "/api/admin/channels/123", "channel", "update_channel"},
{"patch is update", "PATCH", "/api/admin/channels/123", "channel", "update_channel"},
{"delete", "DELETE", "/api/admin/channels/123", "channel", "delete_channel"},
{"custom action on resource", "POST", "/api/admin/channels/123/test", "channel", "test_channel"},
{"no resource type falls back to method", "GET", "/api/admin/dashboard", "", "get"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := buildAction(tt.method, tt.path, tt.resourceType); got != tt.want {
t.Errorf("buildAction(%q,%q,%q) = %q, want %q",
tt.method, tt.path, tt.resourceType, got, tt.want)
}
})
}
}
func TestMaskSensitiveFields(t *testing.T) {
t.Run("masks top-level sensitive fields", func(t *testing.T) {
out := maskSensitiveFields(`{"password":"secret","name":"bob"}`)
var m map[string]any
if err := json.Unmarshal([]byte(out), &m); err != nil {
t.Fatalf("output not valid JSON: %v", err)
}
if m["password"] != "******" {
t.Errorf("password not masked: %v", m["password"])
}
if m["name"] != "bob" {
t.Errorf("non-sensitive field altered: %v", m["name"])
}
})
t.Run("masks nested sensitive fields", func(t *testing.T) {
out := maskSensitiveFields(`{"user":{"api_key":"k","email":"a@b.c"}}`)
var m map[string]any
_ = json.Unmarshal([]byte(out), &m)
user := m["user"].(map[string]any)
if user["api_key"] != "******" {
t.Errorf("nested api_key not masked: %v", user["api_key"])
}
if user["email"] != "a@b.c" {
t.Errorf("nested non-sensitive field altered: %v", user["email"])
}
})
t.Run("case-insensitive key match", func(t *testing.T) {
out := maskSensitiveFields(`{"Password":"x","Refresh_Token":"y"}`)
var m map[string]any
_ = json.Unmarshal([]byte(out), &m)
if m["Password"] != "******" || m["Refresh_Token"] != "******" {
t.Errorf("case-insensitive masking failed: %v", m)
}
})
t.Run("non-JSON returned as-is", func(t *testing.T) {
if got := maskSensitiveFields("not json"); got != "not json" {
t.Errorf("non-JSON should pass through, got %q", got)
}
})
t.Run("empty string", func(t *testing.T) {
if got := maskSensitiveFields(""); got != "" {
t.Errorf("empty input should return empty, got %q", got)
}
})
}
func TestIsSensitive(t *testing.T) {
sensitive := []string{"password", "PASSWORD", "api_key", "Secret", "refresh_token", "encrypted_key"}
for _, f := range sensitive {
if !isSensitive(f) {
t.Errorf("%q should be sensitive", f)
}
}
for _, f := range []string{"name", "email", "id", "username"} {
if isSensitive(f) {
t.Errorf("%q should not be sensitive", f)
}
}
}
func TestParseMaintenanceDuration(t *testing.T) {
ctx := context.Background()
tests := []struct {
in string
want int
}{
{"", 300},
{"invalid", 300},
{"0s", 300},
{"-5s", 300},
{"30s", 30},
{"2m", 120},
{"1h", 3600},
}
for _, tt := range tests {
t.Run(tt.in, func(t *testing.T) {
if got := parseMaintenanceDuration(ctx, tt.in); got != tt.want {
t.Errorf("parseMaintenanceDuration(%q) = %d, want %d", tt.in, got, tt.want)
}
})
}
}
+157
View File
@@ -0,0 +1,157 @@
package crypto
import (
"bytes"
"encoding/base64"
"errors"
"strings"
"testing"
)
// key32 returns a deterministic 32-byte AES-256 key filled with b.
func key32(b byte) []byte {
return bytes.Repeat([]byte{b}, 32)
}
func TestEncryptDecrypt_RoundTrip(t *testing.T) {
key := key32('A')
cases := []string{
"",
"hello",
"sk-proj-1234567890abcdef",
strings.Repeat("长文本测试", 100), // 多字节 + 较长
}
for _, plain := range cases {
enc, err := EncryptString(key, plain)
if err != nil {
t.Fatalf("EncryptString(%q) error: %v", plain, err)
}
if enc == plain && plain != "" {
t.Errorf("ciphertext equals plaintext for %q", plain)
}
got, err := DecryptString(key, enc)
if err != nil {
t.Fatalf("DecryptString error: %v", err)
}
if got != plain {
t.Errorf("round-trip mismatch: got %q, want %q", got, plain)
}
}
}
func TestEncrypt_NonceIsRandom(t *testing.T) {
key := key32('A')
a, _ := EncryptString(key, "same-plaintext")
b, _ := EncryptString(key, "same-plaintext")
if a == b {
t.Error("two encryptions of the same plaintext produced identical ciphertext (nonce not random)")
}
// 两者都应能正确解密
for _, c := range []string{a, b} {
got, err := DecryptString(key, c)
if err != nil || got != "same-plaintext" {
t.Errorf("decrypt failed: got %q err %v", got, err)
}
}
}
func TestEncryptDecrypt_InvalidKeyLength(t *testing.T) {
for _, badKey := range [][]byte{nil, make([]byte, 16), make([]byte, 31), make([]byte, 33)} {
if _, err := Encrypt(badKey, []byte("x")); !errors.Is(err, ErrInvalidKeyLength) {
t.Errorf("Encrypt with key len %d: got %v, want ErrInvalidKeyLength", len(badKey), err)
}
if _, err := Decrypt(badKey, "AAAA"); !errors.Is(err, ErrInvalidKeyLength) {
t.Errorf("Decrypt with key len %d: got %v, want ErrInvalidKeyLength", len(badKey), err)
}
}
}
func TestDecrypt_WrongKeyFails(t *testing.T) {
enc, err := EncryptString(key32('A'), "secret")
if err != nil {
t.Fatal(err)
}
if _, err := DecryptString(key32('B'), enc); err == nil {
t.Error("decryption with a different key must fail (GCM auth), got nil error")
}
}
func TestDecrypt_TamperedCiphertextFails(t *testing.T) {
key := key32('A')
enc, err := EncryptString(key, "secret-payload")
if err != nil {
t.Fatal(err)
}
raw, err := base64.StdEncoding.DecodeString(enc)
if err != nil {
t.Fatal(err)
}
// 翻转最后一个字节(密文/认证标签部分)
raw[len(raw)-1] ^= 0xFF
tampered := base64.StdEncoding.EncodeToString(raw)
if _, err := DecryptString(key, tampered); err == nil {
t.Error("tampered ciphertext must fail authentication, got nil error")
}
}
func TestDecrypt_TooShortAndInvalidBase64(t *testing.T) {
key := key32('A')
// 合法 base64 但长度不足 nonce
short := base64.StdEncoding.EncodeToString([]byte("tiny"))
if _, err := Decrypt(key, short); !errors.Is(err, ErrInvalidCiphertext) {
t.Errorf("too-short ciphertext: got %v, want ErrInvalidCiphertext", err)
}
// 非法 base64
if _, err := Decrypt(key, "!!!not-base64!!!"); err == nil {
t.Error("invalid base64 must error, got nil")
}
}
func TestMustGetEncryptionKey(t *testing.T) {
// 合法:64 个 hex 字符 = 32 字节
key := MustGetEncryptionKey(strings.Repeat("ab", 32))
if len(key) != 32 {
t.Errorf("key length = %d, want 32", len(key))
}
// 非法 hex 应 panic
assertPanics(t, "invalid hex", func() { MustGetEncryptionKey("zzzz") })
// 长度不足应 panic(32 个 hex = 16 字节)
assertPanics(t, "wrong length", func() { MustGetEncryptionKey(strings.Repeat("ab", 16)) })
}
func TestHashAndVerifyPassword(t *testing.T) {
const pw = "S3cretP@ssw0rd"
hash, err := HashPassword(pw)
if err != nil {
t.Fatalf("HashPassword error: %v", err)
}
if hash == pw {
t.Error("hash must not equal the plaintext password")
}
if !VerifyPassword(pw, hash) {
t.Error("correct password failed verification")
}
if VerifyPassword("wrong-password", hash) {
t.Error("wrong password passed verification")
}
// 加盐:同一密码两次哈希应不同,但都能验证
hash2, _ := HashPassword(pw)
if hash2 == hash {
t.Error("two hashes of the same password are identical (no salt)")
}
if !VerifyPassword(pw, hash2) {
t.Error("second hash failed verification")
}
}
func assertPanics(t *testing.T, name string, fn func()) {
t.Helper()
defer func() {
if r := recover(); r == nil {
t.Errorf("%s: expected panic, got none", name)
}
}()
fn()
}
+90
View File
@@ -0,0 +1,90 @@
package totp
import (
"encoding/hex"
"strings"
"testing"
"time"
lib "github.com/pquerna/otp/totp"
)
func TestGenerateSecret(t *testing.T) {
secret, uri, err := GenerateSecret("alice@example.com")
if err != nil {
t.Fatalf("GenerateSecret error: %v", err)
}
if secret == "" {
t.Error("secret is empty")
}
if !strings.HasPrefix(uri, "otpauth://totp/") {
t.Errorf("uri should be an otpauth TOTP URI, got %q", uri)
}
if !strings.Contains(uri, "Team-API") {
t.Errorf("uri should embed issuer, got %q", uri)
}
}
func TestValidateCode(t *testing.T) {
secret, _, err := GenerateSecret("bob@example.com")
if err != nil {
t.Fatal(err)
}
// 用同一算法为当前时间生成正确验证码,应通过
valid, err := lib.GenerateCode(secret, time.Now())
if err != nil {
t.Fatal(err)
}
if !ValidateCode(valid, secret) {
t.Errorf("freshly generated code %q should validate", valid)
}
// 明显非法的验证码应被拒绝(非数字 / 长度错误)
for _, bad := range []string{"", "abcdef", "12", "1234567"} {
if ValidateCode(bad, secret) {
t.Errorf("invalid code %q should not validate", bad)
}
}
// 错误 secret 下,正确格式的码也应失败
otherSecret, _, _ := GenerateSecret("carol@example.com")
if ValidateCode(valid, otherSecret) && otherSecret != secret {
t.Error("code generated for one secret should not validate against another")
}
}
func TestGenerateBackupCodes(t *testing.T) {
const count = 10
codes, err := GenerateBackupCodes(count)
if err != nil {
t.Fatalf("GenerateBackupCodes error: %v", err)
}
if len(codes) != count {
t.Fatalf("got %d codes, want %d", len(codes), count)
}
seen := make(map[string]bool)
for _, c := range codes {
if len(c) != 12 { // 6 bytes -> 12 hex chars
t.Errorf("code %q length = %d, want 12 hex chars", c, len(c))
}
if _, err := hex.DecodeString(c); err != nil {
t.Errorf("code %q is not valid hex: %v", c, err)
}
if seen[c] {
t.Errorf("duplicate backup code: %q", c)
}
seen[c] = true
}
}
func TestGenerateBackupCodes_Zero(t *testing.T) {
codes, err := GenerateBackupCodes(0)
if err != nil {
t.Fatalf("error: %v", err)
}
if len(codes) != 0 {
t.Errorf("expected empty slice, got %d", len(codes))
}
}
+201
View File
@@ -0,0 +1,201 @@
package openai
import (
"encoding/json"
"testing"
"github.com/qianfree/team-api/relay/dto"
)
func TestExtractClaudeSystemText(t *testing.T) {
t.Run("plain string", func(t *testing.T) {
if got := extractClaudeSystemText("you are helpful"); got != "you are helpful" {
t.Errorf("got %q", got)
}
})
t.Run("array of text blocks joined", func(t *testing.T) {
sys := []any{
map[string]any{"type": "text", "text": "line1"},
map[string]any{"type": "text", "text": "line2"},
}
if got := extractClaudeSystemText(sys); got != "line1\nline2" {
t.Errorf("got %q, want \"line1\\nline2\"", got)
}
})
t.Run("non-text blocks ignored", func(t *testing.T) {
sys := []any{
map[string]any{"type": "text", "text": "keep"},
map[string]any{"type": "image", "text": "drop"},
}
if got := extractClaudeSystemText(sys); got != "keep" {
t.Errorf("got %q, want \"keep\"", got)
}
})
t.Run("unknown type returns empty", func(t *testing.T) {
if got := extractClaudeSystemText(42); got != "" {
t.Errorf("got %q, want empty", got)
}
})
}
func TestC2oJoinParts(t *testing.T) {
tests := []struct {
in []string
want string
}{
{nil, ""},
{[]string{}, ""},
{[]string{"only"}, "only"},
{[]string{"a", "b", "c"}, "a\nb\nc"},
}
for _, tt := range tests {
if got := c2oJoinParts(tt.in); got != tt.want {
t.Errorf("c2oJoinParts(%v) = %q, want %q", tt.in, got, tt.want)
}
}
}
func TestC2oConvertThinkingToReasoningEffort(t *testing.T) {
tests := []struct {
name string
budget *int
want string
}{
{"nil budget defaults medium", nil, "medium"},
{"low boundary", intPtr(2048), "low"},
{"low", intPtr(1000), "low"},
{"medium boundary", intPtr(16384), "medium"},
{"medium", intPtr(8000), "medium"},
{"high", intPtr(16385), "high"},
{"high large", intPtr(50000), "high"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := c2oConvertThinkingToReasoningEffort(&dto.ClaudeThinking{BudgetTokens: tt.budget})
if got != tt.want {
t.Errorf("budget=%v => %q, want %q", tt.budget, got, tt.want)
}
})
}
}
func TestG2oConvertThinkingConfig(t *testing.T) {
// 与 Claude 同阈值
tests := []struct {
budget *int
want string
}{
{nil, "medium"},
{intPtr(2048), "low"},
{intPtr(16384), "medium"},
{intPtr(20000), "high"},
}
for _, tt := range tests {
got := g2oConvertThinkingConfig(&dto.GeminiThinkingConfig{ThoughtBudget: tt.budget})
if got != tt.want {
t.Errorf("budget=%v => %q, want %q", tt.budget, got, tt.want)
}
}
}
func TestC2oConvertToolChoice(t *testing.T) {
t.Run("nil", func(t *testing.T) {
if got := c2oConvertToolChoice(nil); got != nil {
t.Errorf("got %v, want nil", got)
}
})
t.Run("string passthrough", func(t *testing.T) {
if got := c2oConvertToolChoice("auto"); got != "auto" {
t.Errorf("got %v", got)
}
})
t.Run("type mappings", func(t *testing.T) {
cases := map[string]string{"auto": "auto", "any": "required", "none": "none"}
for in, want := range cases {
got := c2oConvertToolChoice(map[string]any{"type": in})
if got != want {
t.Errorf("type=%q => %v, want %q", in, got, want)
}
}
})
t.Run("specific tool maps to function", func(t *testing.T) {
got := c2oConvertToolChoice(map[string]any{"type": "tool", "name": "get_weather"})
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("expected map, got %T", got)
}
if m["type"] != "function" {
t.Errorf("type = %v, want function", m["type"])
}
fn, ok := m["function"].(map[string]any)
if !ok || fn["name"] != "get_weather" {
t.Errorf("function = %v, want name=get_weather", m["function"])
}
})
t.Run("tool without name falls back to required", func(t *testing.T) {
if got := c2oConvertToolChoice(map[string]any{"type": "tool"}); got != "required" {
t.Errorf("got %v, want required", got)
}
})
}
func TestG2oMapRole(t *testing.T) {
tests := map[string]string{
"model": "assistant",
"user": "user",
"tool": "tool",
}
for in, want := range tests {
if got := g2oMapRole(in); got != want {
t.Errorf("g2oMapRole(%q) = %q, want %q", in, got, want)
}
}
}
func TestC2rContentToString(t *testing.T) {
if got := c2rContentToString(nil); got != "" {
t.Errorf("nil => %q, want empty", got)
}
if got := c2rContentToString("hello"); got != "hello" {
t.Errorf("string => %q", got)
}
// 非字符串 -> JSON 编码
got := c2rContentToString([]any{map[string]any{"type": "text", "text": "x"}})
var back []any
if err := json.Unmarshal([]byte(got), &back); err != nil {
t.Errorf("non-string content should be JSON-encoded, got %q (%v)", got, err)
}
}
func TestC2rGetMaxTokens(t *testing.T) {
tests := []struct {
name string
maxTok *int
maxComp *int
want int
}{
{"both nil", nil, nil, 0},
{"max_tokens only", intPtr(100), nil, 100},
{"max_completion larger wins", intPtr(100), intPtr(200), 200},
{"max_completion smaller keeps max_tokens", intPtr(300), intPtr(50), 300},
{"zero max_tokens ignored", intPtr(0), nil, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := dto.GeneralOpenAIRequest{MaxTokens: tt.maxTok, MaxCompletionTokens: tt.maxComp}
if got := c2rGetMaxTokens(req); got != tt.want {
t.Errorf("got %d, want %d", got, tt.want)
}
})
}
}
func TestConvertClaudeUserMessage_StringContent(t *testing.T) {
msgs := convertClaudeUserMessage(dto.ClaudeMessage{Role: "user", Content: "hi there"})
if len(msgs) != 1 {
t.Fatalf("got %d messages, want 1", len(msgs))
}
if msgs[0].Role != "user" || msgs[0].Content != "hi there" {
t.Errorf("got %+v", msgs[0])
}
}