feat(test):新增单元测试
This commit is contained in:
1 parent
62826d9140
commit
f3b4be9b2a
7 files changed
+1006
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user