refactor: 消除重复的 normalizeAccountIDList,补充 PR#754 新增组件的单元测试
- 删除 account_today_stats_cache.go 中重复的 normalizeAccountIDList,统一使用 id_list_utils.go 的 normalizeInt64IDList - 新增 snapshot_cache_test.go:覆盖 snapshotCache、buildETagFromAny、parseBoolQueryWithDefault - 新增 id_list_utils_test.go:覆盖 normalizeInt64IDList、buildAccountTodayStatsBatchCacheKey - 新增 ops_query_mode_test.go:覆盖 shouldFallbackOpsPreagg、cloneOpsFilterWithMode
This commit is contained in:
@@ -1403,7 +1403,7 @@ func (h *AccountHandler) GetBatchTodayStats(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
accountIDs := normalizeAccountIDList(req.AccountIDs)
|
accountIDs := normalizeInt64IDList(req.AccountIDs)
|
||||||
if len(accountIDs) == 0 {
|
if len(accountIDs) == 0 {
|
||||||
response.Success(c, gin.H{"stats": map[string]any{}})
|
response.Success(c, gin.H{"stats": map[string]any{}})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -9,26 +8,6 @@ import (
|
|||||||
|
|
||||||
var accountTodayStatsBatchCache = newSnapshotCache(30 * time.Second)
|
var accountTodayStatsBatchCache = newSnapshotCache(30 * time.Second)
|
||||||
|
|
||||||
func normalizeAccountIDList(accountIDs []int64) []int64 {
|
|
||||||
if len(accountIDs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
seen := make(map[int64]struct{}, len(accountIDs))
|
|
||||||
out := make([]int64, 0, len(accountIDs))
|
|
||||||
for _, id := range accountIDs {
|
|
||||||
if id <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, ok := seen[id]; ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[id] = struct{}{}
|
|
||||||
out = append(out, id)
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildAccountTodayStatsBatchCacheKey(accountIDs []int64) string {
|
func buildAccountTodayStatsBatchCacheKey(accountIDs []int64) string {
|
||||||
if len(accountIDs) == 0 {
|
if len(accountIDs) == 0 {
|
||||||
return "accounts_today_stats_empty"
|
return "accounts_today_stats_empty"
|
||||||
|
|||||||
57
backend/internal/handler/admin/id_list_utils_test.go
Normal file
57
backend/internal/handler/admin/id_list_utils_test.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
//go:build unit
|
||||||
|
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeInt64IDList(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in []int64
|
||||||
|
want []int64
|
||||||
|
}{
|
||||||
|
{"nil input", nil, nil},
|
||||||
|
{"empty input", []int64{}, nil},
|
||||||
|
{"single element", []int64{5}, []int64{5}},
|
||||||
|
{"already sorted unique", []int64{1, 2, 3}, []int64{1, 2, 3}},
|
||||||
|
{"duplicates removed", []int64{3, 1, 3, 2, 1}, []int64{1, 2, 3}},
|
||||||
|
{"zero filtered", []int64{0, 1, 2}, []int64{1, 2}},
|
||||||
|
{"negative filtered", []int64{-5, -1, 3}, []int64{3}},
|
||||||
|
{"all invalid", []int64{0, -1, -2}, []int64{}},
|
||||||
|
{"sorted output", []int64{9, 3, 7, 1}, []int64{1, 3, 7, 9}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := normalizeInt64IDList(tc.in)
|
||||||
|
if tc.want == nil {
|
||||||
|
require.Nil(t, got)
|
||||||
|
} else {
|
||||||
|
require.Equal(t, tc.want, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAccountTodayStatsBatchCacheKey(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ids []int64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"empty", nil, "accounts_today_stats_empty"},
|
||||||
|
{"single", []int64{42}, "accounts_today_stats:42"},
|
||||||
|
{"multiple", []int64{1, 2, 3}, "accounts_today_stats:1,2,3"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := buildAccountTodayStatsBatchCacheKey(tc.ids)
|
||||||
|
require.Equal(t, tc.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
128
backend/internal/handler/admin/snapshot_cache_test.go
Normal file
128
backend/internal/handler/admin/snapshot_cache_test.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
//go:build unit
|
||||||
|
|
||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSnapshotCache_SetAndGet(t *testing.T) {
|
||||||
|
c := newSnapshotCache(5 * time.Second)
|
||||||
|
|
||||||
|
entry := c.Set("key1", map[string]string{"hello": "world"})
|
||||||
|
require.NotEmpty(t, entry.ETag)
|
||||||
|
require.NotNil(t, entry.Payload)
|
||||||
|
|
||||||
|
got, ok := c.Get("key1")
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, entry.ETag, got.ETag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotCache_Expiration(t *testing.T) {
|
||||||
|
c := newSnapshotCache(1 * time.Millisecond)
|
||||||
|
|
||||||
|
c.Set("key1", "value")
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
|
||||||
|
_, ok := c.Get("key1")
|
||||||
|
require.False(t, ok, "expired entry should not be returned")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotCache_GetEmptyKey(t *testing.T) {
|
||||||
|
c := newSnapshotCache(5 * time.Second)
|
||||||
|
_, ok := c.Get("")
|
||||||
|
require.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotCache_GetMiss(t *testing.T) {
|
||||||
|
c := newSnapshotCache(5 * time.Second)
|
||||||
|
_, ok := c.Get("nonexistent")
|
||||||
|
require.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotCache_NilReceiver(t *testing.T) {
|
||||||
|
var c *snapshotCache
|
||||||
|
_, ok := c.Get("key")
|
||||||
|
require.False(t, ok)
|
||||||
|
|
||||||
|
entry := c.Set("key", "value")
|
||||||
|
require.Empty(t, entry.ETag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotCache_SetEmptyKey(t *testing.T) {
|
||||||
|
c := newSnapshotCache(5 * time.Second)
|
||||||
|
|
||||||
|
// Set with empty key should return entry but not store it
|
||||||
|
entry := c.Set("", "value")
|
||||||
|
require.NotEmpty(t, entry.ETag)
|
||||||
|
|
||||||
|
_, ok := c.Get("")
|
||||||
|
require.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotCache_DefaultTTL(t *testing.T) {
|
||||||
|
c := newSnapshotCache(0)
|
||||||
|
require.Equal(t, 30*time.Second, c.ttl)
|
||||||
|
|
||||||
|
c2 := newSnapshotCache(-1 * time.Second)
|
||||||
|
require.Equal(t, 30*time.Second, c2.ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotCache_ETagDeterministic(t *testing.T) {
|
||||||
|
c := newSnapshotCache(5 * time.Second)
|
||||||
|
payload := map[string]int{"a": 1, "b": 2}
|
||||||
|
|
||||||
|
entry1 := c.Set("k1", payload)
|
||||||
|
entry2 := c.Set("k2", payload)
|
||||||
|
require.Equal(t, entry1.ETag, entry2.ETag, "same payload should produce same ETag")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSnapshotCache_ETagFormat(t *testing.T) {
|
||||||
|
c := newSnapshotCache(5 * time.Second)
|
||||||
|
entry := c.Set("k", "test")
|
||||||
|
// ETag should be quoted hex string: "abcdef..."
|
||||||
|
require.True(t, len(entry.ETag) > 2)
|
||||||
|
require.Equal(t, byte('"'), entry.ETag[0])
|
||||||
|
require.Equal(t, byte('"'), entry.ETag[len(entry.ETag)-1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildETagFromAny_UnmarshalablePayload(t *testing.T) {
|
||||||
|
// channels are not JSON-serializable
|
||||||
|
etag := buildETagFromAny(make(chan int))
|
||||||
|
require.Empty(t, etag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseBoolQueryWithDefault(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
def bool
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"empty returns default true", "", true, true},
|
||||||
|
{"empty returns default false", "", false, false},
|
||||||
|
{"1", "1", false, true},
|
||||||
|
{"true", "true", false, true},
|
||||||
|
{"TRUE", "TRUE", false, true},
|
||||||
|
{"yes", "yes", false, true},
|
||||||
|
{"on", "on", false, true},
|
||||||
|
{"0", "0", true, false},
|
||||||
|
{"false", "false", true, false},
|
||||||
|
{"FALSE", "FALSE", true, false},
|
||||||
|
{"no", "no", true, false},
|
||||||
|
{"off", "off", true, false},
|
||||||
|
{"whitespace trimmed", " true ", false, true},
|
||||||
|
{"unknown returns default true", "maybe", true, true},
|
||||||
|
{"unknown returns default false", "maybe", false, false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := parseBoolQueryWithDefault(tc.raw, tc.def)
|
||||||
|
require.Equal(t, tc.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
66
backend/internal/service/ops_query_mode_test.go
Normal file
66
backend/internal/service/ops_query_mode_test.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
//go:build unit
|
||||||
|
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShouldFallbackOpsPreagg(t *testing.T) {
|
||||||
|
preaggErr := ErrOpsPreaggregatedNotPopulated
|
||||||
|
otherErr := errors.New("some other error")
|
||||||
|
|
||||||
|
autoFilter := &OpsDashboardFilter{QueryMode: OpsQueryModeAuto}
|
||||||
|
rawFilter := &OpsDashboardFilter{QueryMode: OpsQueryModeRaw}
|
||||||
|
preaggFilter := &OpsDashboardFilter{QueryMode: OpsQueryModePreagg}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
filter *OpsDashboardFilter
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"auto mode + preagg error => fallback", autoFilter, preaggErr, true},
|
||||||
|
{"auto mode + other error => no fallback", autoFilter, otherErr, false},
|
||||||
|
{"auto mode + nil error => no fallback", autoFilter, nil, false},
|
||||||
|
{"raw mode + preagg error => no fallback", rawFilter, preaggErr, false},
|
||||||
|
{"preagg mode + preagg error => no fallback", preaggFilter, preaggErr, false},
|
||||||
|
{"nil filter => no fallback", nil, preaggErr, false},
|
||||||
|
{"wrapped preagg error => fallback", autoFilter, errors.Join(preaggErr, otherErr), true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := shouldFallbackOpsPreagg(tc.filter, tc.err)
|
||||||
|
require.Equal(t, tc.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloneOpsFilterWithMode(t *testing.T) {
|
||||||
|
t.Run("nil filter returns nil", func(t *testing.T) {
|
||||||
|
require.Nil(t, cloneOpsFilterWithMode(nil, OpsQueryModeRaw))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("cloned filter has new mode", func(t *testing.T) {
|
||||||
|
groupID := int64(42)
|
||||||
|
original := &OpsDashboardFilter{
|
||||||
|
StartTime: time.Now(),
|
||||||
|
EndTime: time.Now().Add(time.Hour),
|
||||||
|
Platform: "anthropic",
|
||||||
|
GroupID: &groupID,
|
||||||
|
QueryMode: OpsQueryModeAuto,
|
||||||
|
}
|
||||||
|
|
||||||
|
cloned := cloneOpsFilterWithMode(original, OpsQueryModeRaw)
|
||||||
|
require.Equal(t, OpsQueryModeRaw, cloned.QueryMode)
|
||||||
|
require.Equal(t, OpsQueryModeAuto, original.QueryMode, "original should not be modified")
|
||||||
|
require.Equal(t, original.Platform, cloned.Platform)
|
||||||
|
require.Equal(t, original.StartTime, cloned.StartTime)
|
||||||
|
require.Equal(t, original.GroupID, cloned.GroupID)
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user