错误请求列表(/admin/usage 错误 tab、Ops 弹窗、用户端 /usage 错误 tab) 对齐用量明细的交互与信息密度。Squash of: - feat(usage): 错误请求全面对齐用量明细(UI/列序/排序/筛选/列设置/新列) - refactor(usage): 错误表提取共享徽章工具与 IP 批量工具条,后端排序解析归并 SetSort - feat(usage): /admin/usage 错误请求新增分类过滤 - fix(ops): 错误列表 phase=upstream 过滤生效,守卫豁免改为显式 opt-in - fix(ops): 错误列表用户列回退显示已删除 KEY 所有者 - fix(usage): 错误请求状态码排序对齐展示/过滤,筛选项改固定常用码
129 lines
5.2 KiB
Go
129 lines
5.2 KiB
Go
package repository
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
)
|
|
|
|
func TestBuildOpsErrorLogsWhere_UserScopedFilters(t *testing.T) {
|
|
uid := int64(42)
|
|
kid := int64(7)
|
|
filter := &service.OpsErrorLogFilter{
|
|
UserID: &uid,
|
|
APIKeyID: &kid,
|
|
Model: "claude-sonnet-4-5",
|
|
ExcludeCountTokens: true,
|
|
ErrorPhasesAny: []string{"auth"},
|
|
ErrorTypesAny: []string{"rate_limit_error"},
|
|
View: "all",
|
|
}
|
|
where, args := buildOpsErrorLogsWhere(filter)
|
|
|
|
for _, want := range []string{
|
|
"e.user_id = $",
|
|
"e.api_key_id = $",
|
|
"COALESCE(e.requested_model, e.model, '') = $",
|
|
"COALESCE(e.is_count_tokens, false) = false",
|
|
"e.error_phase = ANY($",
|
|
"e.error_type = ANY($",
|
|
} {
|
|
if !strings.Contains(where, want) {
|
|
t.Fatalf("where missing %q\nfull: %s", want, where)
|
|
}
|
|
}
|
|
if len(args) != 5 {
|
|
t.Fatalf("expected 5 args, got %d", len(args))
|
|
}
|
|
}
|
|
|
|
func TestBuildOpsErrorLogsWhere_ModelFuzzy(t *testing.T) {
|
|
// 默认(ModelFuzzy=false)保持精确匹配
|
|
exact := &service.OpsErrorLogFilter{Model: "claude"}
|
|
whereExact, _ := buildOpsErrorLogsWhere(exact)
|
|
if !strings.Contains(whereExact, "COALESCE(e.requested_model, e.model, '') = $") {
|
|
t.Fatalf("default should be exact match, got: %s", whereExact)
|
|
}
|
|
|
|
// ModelFuzzy=true → ILIKE
|
|
fuzzy := &service.OpsErrorLogFilter{Model: "claude", ModelFuzzy: true}
|
|
whereFuzzy, args := buildOpsErrorLogsWhere(fuzzy)
|
|
if !strings.Contains(whereFuzzy, "COALESCE(e.requested_model, e.model, '') ILIKE $") {
|
|
t.Fatalf("ModelFuzzy should use ILIKE, got: %s", whereFuzzy)
|
|
}
|
|
if len(args) != 1 || args[0] != "%claude%" {
|
|
t.Fatalf("expected arg \"%%claude%%\", got %v", args)
|
|
}
|
|
|
|
// 通配符转义:输入含 % 应被转义为字面量
|
|
esc := &service.OpsErrorLogFilter{Model: "50%off", ModelFuzzy: true}
|
|
_, escArgs := buildOpsErrorLogsWhere(esc)
|
|
if len(escArgs) != 1 || escArgs[0] != `%50\%off%` {
|
|
t.Fatalf("expected escaped arg, got %v", escArgs)
|
|
}
|
|
|
|
esc2 := &service.OpsErrorLogFilter{Model: "gpt_4o", ModelFuzzy: true}
|
|
_, escArgs2 := buildOpsErrorLogsWhere(esc2)
|
|
if len(escArgs2) != 1 || escArgs2[0] != `%gpt\_4o%` {
|
|
t.Fatalf("underscore should be escaped, got %v", escArgs2)
|
|
}
|
|
}
|
|
|
|
// TestBuildOpsErrorLogsWhere_CyberPolicyStatusExemption verifies that streaming
|
|
// cyber_policy hits (status_code=200) remain visible in admin + user error-request
|
|
// lists. The repository filter must emit an OR exemption for error_type='cyber_policy'
|
|
// so that stream-path cyber rows (upstream delivers 200 with a failed SSE event) are
|
|
// not silently excluded by the COALESCE(status_code,0) >= 400 guard.
|
|
func TestBuildOpsErrorLogsWhere_CyberPolicyStatusExemption(t *testing.T) {
|
|
// Default filter (no phase) must include the cyber_policy exemption.
|
|
where, _ := buildOpsErrorLogsWhere(&service.OpsErrorLogFilter{})
|
|
if !strings.Contains(where, "e.error_type = 'cyber_policy'") {
|
|
t.Fatalf("default filter must exempt cyber_policy from status >= 400 guard\nfull: %s", where)
|
|
}
|
|
if !strings.Contains(where, "COALESCE(e.status_code, 0) >= 400") {
|
|
t.Fatalf("default filter must still include the status >= 400 guard for non-cyber rows\nfull: %s", where)
|
|
}
|
|
|
|
// phase=upstream WITHOUT the recovered-upstream opt-in keeps the status guard:
|
|
// request-error list endpoints filter by phase=upstream as a plain condition.
|
|
whereUpstream, _ := buildOpsErrorLogsWhere(&service.OpsErrorLogFilter{Phase: "upstream"})
|
|
if !strings.Contains(whereUpstream, "COALESCE(e.status_code, 0) >= 400") {
|
|
t.Fatalf("upstream phase without IncludeRecoveredUpstream must keep the status guard\nfull: %s", whereUpstream)
|
|
}
|
|
if !strings.Contains(whereUpstream, "e.error_phase = $") {
|
|
t.Fatalf("upstream phase filter must emit the error_phase condition\nfull: %s", whereUpstream)
|
|
}
|
|
|
|
// phase=upstream WITH IncludeRecoveredUpstream (ops 上游列表) skips the guard,
|
|
// exposing recovered (<400) upstream rows.
|
|
whereRecovered, _ := buildOpsErrorLogsWhere(&service.OpsErrorLogFilter{Phase: "upstream", IncludeRecoveredUpstream: true})
|
|
if strings.Contains(whereRecovered, "status_code") {
|
|
t.Fatalf("upstream phase with IncludeRecoveredUpstream must not add any status_code clause\nfull: %s", whereRecovered)
|
|
}
|
|
}
|
|
|
|
func TestBuildOpsErrorLogsWhere_MatchDeletedKeyOwner(t *testing.T) {
|
|
uid := int64(42)
|
|
|
|
// 开关开启 → 归属放宽为 OR(user_id 或 deleted_key_owner_user_id),且共用同一占位符
|
|
on := &service.OpsErrorLogFilter{UserID: &uid, MatchDeletedKeyOwner: true}
|
|
whereOn, argsOn := buildOpsErrorLogsWhere(on)
|
|
if !strings.Contains(whereOn, "(e.user_id = $1 OR e.deleted_key_owner_user_id = $1)") {
|
|
t.Fatalf("MatchDeletedKeyOwner=true should widen to OR, got: %s", whereOn)
|
|
}
|
|
if len(argsOn) != 1 || argsOn[0] != uid {
|
|
t.Fatalf("expected single reused arg %d, got %v", uid, argsOn)
|
|
}
|
|
|
|
// 开关关闭(默认)→ 仅精确 user_id,绝不出现 deleted_key_owner_user_id(admin 回归)
|
|
off := &service.OpsErrorLogFilter{UserID: &uid}
|
|
whereOff, _ := buildOpsErrorLogsWhere(off)
|
|
if !strings.Contains(whereOff, "e.user_id = $1") {
|
|
t.Fatalf("default should match user_id exactly, got: %s", whereOff)
|
|
}
|
|
if strings.Contains(whereOff, "deleted_key_owner_user_id") {
|
|
t.Fatalf("default must NOT include deleted_key_owner_user_id, got: %s", whereOff)
|
|
}
|
|
}
|