sub2api/backend/internal/handler/usage_handler_daily_test.go
DaydreamCoding cfb195c7b2 feat(usage): 记录并展示失败请求(用户端+管理端)
- 记录失败请求并在用户端/管理端展示;分类下拉改用统一 Select 组件
- 模型过滤改后端 ILIKE 模糊匹配;新增「Key 名称」列(含已删除标记)与按 Key 过滤;时间列移至末列
- 用户可见「已删除 key 失败请求」:OpsErrorLogFilter 加 MatchDeletedKeyOwner,用户侧归属
  放宽为 (user_id OR deleted_key_owner_user_id),让 key 原所有者能看到删除 key 后继续请求
  导致的认证失败记录(他人仍 NotFound,不泄露存在性)
- 迁移 148:ops_error_logs 用户+时间索引

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:00:57 +08:00

196 lines
5.5 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type dailyUsageRepoStub struct {
service.UsageLogRepository
trend []usagestats.TrendDataPoint
called bool
startTime time.Time
endTime time.Time
granularity string
userID int64
apiKeyID int64
}
func (s *dailyUsageRepoStub) GetUsageTrendWithFilters(
ctx context.Context,
startTime, endTime time.Time,
granularity string,
userID, apiKeyID, accountID, groupID int64,
model string,
requestType *int16,
stream *bool,
billingType *int8,
) ([]usagestats.TrendDataPoint, error) {
s.called = true
s.startTime = startTime
s.endTime = endTime
s.granularity = granularity
s.userID = userID
s.apiKeyID = apiKeyID
return s.trend, nil
}
type dailyUsageAPIKeyRepoStub struct {
service.APIKeyRepository
keys map[int64]*service.APIKey
}
func (s *dailyUsageAPIKeyRepoStub) GetByID(ctx context.Context, id int64) (*service.APIKey, error) {
key, ok := s.keys[id]
if !ok {
return nil, service.ErrAPIKeyNotFound
}
clone := *key
return &clone, nil
}
func newDailyUsageTestRouter(usageRepo *dailyUsageRepoStub, apiKeyRepo *dailyUsageAPIKeyRepoStub, userID int64) *gin.Engine {
gin.SetMode(gin.TestMode)
usageSvc := service.NewUsageService(usageRepo, nil, nil, nil)
apiKeySvc := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, nil)
handler := NewUsageHandler(usageSvc, apiKeySvc, nil, nil)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: userID})
c.Next()
})
router.GET("/user/api-keys/:id/usage/daily", handler.GetMyAPIKeyDailyUsage)
return router
}
type dailyUsageHandlerResponse struct {
Code int `json:"code"`
Data struct {
Items []usagestats.APIKeyDailyUsagePoint `json:"items"`
Days int `json:"days"`
} `json:"data"`
}
func TestGetMyAPIKeyDailyUsageRejectsCrossUserAccess(t *testing.T) {
usageRepo := &dailyUsageRepoStub{}
apiKeyRepo := &dailyUsageAPIKeyRepoStub{
keys: map[int64]*service.APIKey{
7: {ID: 7, UserID: 99, Status: service.StatusAPIKeyActive},
},
}
router := newDailyUsageTestRouter(usageRepo, apiKeyRepo, 42)
req := httptest.NewRequest(http.MethodGet, "/user/api-keys/7/usage/daily?days=30", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusForbidden, rec.Code)
require.False(t, usageRepo.called)
}
func TestGetMyAPIKeyDailyUsageRejectsInvalidDays(t *testing.T) {
for _, path := range []string{
"/user/api-keys/7/usage/daily?days=0",
"/user/api-keys/7/usage/daily?days=91",
} {
t.Run(path, func(t *testing.T) {
usageRepo := &dailyUsageRepoStub{}
apiKeyRepo := &dailyUsageAPIKeyRepoStub{
keys: map[int64]*service.APIKey{
7: {ID: 7, UserID: 42, Status: service.StatusAPIKeyActive},
},
}
router := newDailyUsageTestRouter(usageRepo, apiKeyRepo, 42)
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code)
require.False(t, usageRepo.called)
})
}
}
func TestGetMyAPIKeyDailyUsageReturnsEmptyData(t *testing.T) {
usageRepo := &dailyUsageRepoStub{trend: []usagestats.TrendDataPoint{}}
apiKeyRepo := &dailyUsageAPIKeyRepoStub{
keys: map[int64]*service.APIKey{
7: {ID: 7, UserID: 42, Status: service.StatusAPIKeyActive},
},
}
router := newDailyUsageTestRouter(usageRepo, apiKeyRepo, 42)
req := httptest.NewRequest(http.MethodGet, "/user/api-keys/7/usage/daily", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var got dailyUsageHandlerResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, 30, got.Data.Days)
require.Empty(t, got.Data.Items)
}
func TestGetMyAPIKeyDailyUsageAggregatesByDayForOwnedKey(t *testing.T) {
usageRepo := &dailyUsageRepoStub{
trend: []usagestats.TrendDataPoint{
{
Date: "2026-05-19",
Requests: 3,
InputTokens: 10,
OutputTokens: 20,
CacheCreationTokens: 4,
CacheReadTokens: 6,
TotalTokens: 40,
Cost: 0.5,
ActualCost: 0.4,
},
},
}
apiKeyRepo := &dailyUsageAPIKeyRepoStub{
keys: map[int64]*service.APIKey{
7: {ID: 7, UserID: 42, Status: service.StatusAPIKeyActive},
},
}
router := newDailyUsageTestRouter(usageRepo, apiKeyRepo, 42)
req := httptest.NewRequest(http.MethodGet, "/user/api-keys/7/usage/daily?days=7", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.True(t, usageRepo.called)
require.Equal(t, "day", usageRepo.granularity)
require.Equal(t, int64(42), usageRepo.userID)
require.Equal(t, int64(7), usageRepo.apiKeyID)
require.True(t, usageRepo.startTime.Before(usageRepo.endTime))
var got dailyUsageHandlerResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, 7, got.Data.Days)
require.Len(t, got.Data.Items, 1)
require.Equal(t, usagestats.APIKeyDailyUsagePoint{
Date: "2026-05-19",
Requests: 3,
InputTokens: 10,
OutputTokens: 20,
CacheReadTokens: 6,
CacheWriteTokens: 4,
TotalTokens: 40,
Cost: 0.5,
ActualCost: 0.4,
}, got.Data.Items[0])
}