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:
shaw
2026-03-04 15:22:46 +08:00
parent 9dcd3cd491
commit 0819c8a51a
5 changed files with 252 additions and 22 deletions

View File

@@ -1403,7 +1403,7 @@ func (h *AccountHandler) GetBatchTodayStats(c *gin.Context) {
return
}
accountIDs := normalizeAccountIDList(req.AccountIDs)
accountIDs := normalizeInt64IDList(req.AccountIDs)
if len(accountIDs) == 0 {
response.Success(c, gin.H{"stats": map[string]any{}})
return

View File

@@ -1,7 +1,6 @@
package admin
import (
"sort"
"strconv"
"strings"
"time"
@@ -9,26 +8,6 @@ import (
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 {
if len(accountIDs) == 0 {
return "accounts_today_stats_empty"

View 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)
})
}
}

View 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)
})
}
}

View 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)
})
}