Files
sub2api/backend/internal/service/ops_openai_token_stats_test.go
yangjianbo 65661f24e2 feat(ops): 运维监控新增 OpenAI Token 请求统计表
- 新增管理端接口 /api/v1/admin/ops/dashboard/openai-token-stats,按模型聚合统计 gpt% 请求

- 支持 time_range=30m|1h|1d|15d|30d(默认 30d),支持 platform/group_id 过滤

- 支持分页(page/page_size)或 TopN(top_n)互斥查询

- 前端运维监控页新增统计表卡片,包含空态/错误态与分页/TopN 交互

- 补齐后端与前端测试
2026-02-12 14:20:14 +08:00

163 lines
3.8 KiB
Go

package service
import (
"context"
"testing"
"time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/stretchr/testify/require"
)
type openAITokenStatsRepoStub struct {
OpsRepository
resp *OpsOpenAITokenStatsResponse
err error
captured *OpsOpenAITokenStatsFilter
}
func (s *openAITokenStatsRepoStub) GetOpenAITokenStats(ctx context.Context, filter *OpsOpenAITokenStatsFilter) (*OpsOpenAITokenStatsResponse, error) {
s.captured = filter
if s.err != nil {
return nil, s.err
}
if s.resp != nil {
return s.resp, nil
}
return &OpsOpenAITokenStatsResponse{}, nil
}
func TestOpsServiceGetOpenAITokenStats_Validation(t *testing.T) {
now := time.Now().UTC()
tests := []struct {
name string
filter *OpsOpenAITokenStatsFilter
wantCode int
wantReason string
}{
{
name: "filter 不能为空",
filter: nil,
wantCode: 400,
wantReason: "OPS_FILTER_REQUIRED",
},
{
name: "start_time/end_time 必填",
filter: &OpsOpenAITokenStatsFilter{
StartTime: time.Time{},
EndTime: now,
},
wantCode: 400,
wantReason: "OPS_TIME_RANGE_REQUIRED",
},
{
name: "start_time 不能晚于 end_time",
filter: &OpsOpenAITokenStatsFilter{
StartTime: now,
EndTime: now.Add(-1 * time.Minute),
},
wantCode: 400,
wantReason: "OPS_TIME_RANGE_INVALID",
},
{
name: "group_id 必须大于 0",
filter: &OpsOpenAITokenStatsFilter{
StartTime: now.Add(-time.Hour),
EndTime: now,
GroupID: int64Ptr(0),
},
wantCode: 400,
wantReason: "OPS_GROUP_ID_INVALID",
},
{
name: "top_n 与分页参数互斥",
filter: &OpsOpenAITokenStatsFilter{
StartTime: now.Add(-time.Hour),
EndTime: now,
TopN: 10,
Page: 1,
},
wantCode: 400,
wantReason: "OPS_PAGINATION_CONFLICT",
},
{
name: "top_n 参数越界",
filter: &OpsOpenAITokenStatsFilter{
StartTime: now.Add(-time.Hour),
EndTime: now,
TopN: 101,
},
wantCode: 400,
wantReason: "OPS_TOPN_INVALID",
},
{
name: "page_size 参数越界",
filter: &OpsOpenAITokenStatsFilter{
StartTime: now.Add(-time.Hour),
EndTime: now,
Page: 1,
PageSize: 101,
},
wantCode: 400,
wantReason: "OPS_PAGE_SIZE_INVALID",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := &OpsService{
opsRepo: &openAITokenStatsRepoStub{},
}
_, err := svc.GetOpenAITokenStats(context.Background(), tt.filter)
require.Error(t, err)
require.Equal(t, tt.wantCode, infraerrors.Code(err))
require.Equal(t, tt.wantReason, infraerrors.Reason(err))
})
}
}
func TestOpsServiceGetOpenAITokenStats_DefaultPagination(t *testing.T) {
now := time.Now().UTC()
repo := &openAITokenStatsRepoStub{
resp: &OpsOpenAITokenStatsResponse{
Items: []*OpsOpenAITokenStatsItem{
{Model: "gpt-4o-mini", RequestCount: 10},
},
Total: 1,
},
}
svc := &OpsService{opsRepo: repo}
filter := &OpsOpenAITokenStatsFilter{
TimeRange: "30d",
StartTime: now.Add(-30 * 24 * time.Hour),
EndTime: now,
}
resp, err := svc.GetOpenAITokenStats(context.Background(), filter)
require.NoError(t, err)
require.NotNil(t, resp)
require.NotNil(t, repo.captured)
require.Equal(t, 1, repo.captured.Page)
require.Equal(t, 20, repo.captured.PageSize)
require.Equal(t, 0, repo.captured.TopN)
}
func TestOpsServiceGetOpenAITokenStats_RepoUnavailable(t *testing.T) {
now := time.Now().UTC()
svc := &OpsService{}
_, err := svc.GetOpenAITokenStats(context.Background(), &OpsOpenAITokenStatsFilter{
TimeRange: "1h",
StartTime: now.Add(-time.Hour),
EndTime: now,
TopN: 10,
})
require.Error(t, err)
require.Equal(t, 503, infraerrors.Code(err))
require.Equal(t, "OPS_REPO_UNAVAILABLE", infraerrors.Reason(err))
}
func int64Ptr(v int64) *int64 { return &v }