sub2api/backend/internal/handler/user_platform_quotas_handler_test.go
DaydreamCoding 6b39b344d8 feat(quota): 用户 × 平台 USD 配额
为用户在 anthropic/openai/gemini/antigravity 四个平台上提供日/周/月
三个窗口的 USD 配额管控。配额语义:未设置=不限制,0=禁用,>0=美元上限。

两层模型:
- 配置层:系统默认配额,以及 email/linuxdo/oidc/wechat/github/google/
  dingtalk 七个鉴权来源的默认配额,存于 settings,以嵌套 JSON 整体读写
  (系统 1 个 key + 每个来源 1 个 key),整体替换语义。
- 运行时层:user_platform_quota 表按用户记录实际配额,与配置层解耦。

后端:新增 ent schema 与 140_user_platform_quotas.sql 迁移、repository
与 service 端口、计费链路集成、管理端与用户端读写接口。
前端:管理端设置页配额编辑、用户配额管理 Modal、用户 Dashboard 展示、
中英文案。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 10:49:20 +08:00

213 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build unit
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/handler/quotaview"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// fakeQuotaRepoForUserHandler 实现 service.UserPlatformQuotaRepository 最小子集
type fakeQuotaRepoForUserHandler struct {
service.UserPlatformQuotaRepository
records []service.UserPlatformQuotaRecord
}
func (f *fakeQuotaRepoForUserHandler) ListByUser(_ context.Context, _ int64) ([]service.UserPlatformQuotaRecord, error) {
return f.records, nil
}
func TestGetMyPlatformQuotas_EmptyReturns200WithEmptyArray(t *testing.T) {
repo := &fakeQuotaRepoForUserHandler{records: nil}
h := &UserHandler{userPlatformQuotaRepo: repo}
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/user/platform-quotas", nil)
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 42})
h.GetMyPlatformQuotas(c)
if w.Code != 200 {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
var body struct {
Code int `json:"code"`
Data struct {
PlatformQuotas []any `json:"platform_quotas"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("unmarshal error: %v, body: %s", err, w.Body.String())
}
if body.Code != 0 {
t.Errorf("expected code=0, got %d", body.Code)
}
if body.Data.PlatformQuotas == nil {
// nil 和 empty slice 均视为可接受JSON 可能序列化为 null 或 []
// 此断言只验证 HTTP 200 + code=0 即可
}
}
func TestGetMyPlatformQuotas_D14_LazyZeroForExpiredWindow(t *testing.T) {
pastStart := time.Now().UTC().AddDate(0, 0, -2)
daily := 5.0
repo := &fakeQuotaRepoForUserHandler{records: []service.UserPlatformQuotaRecord{{
UserID: 42,
Platform: "anthropic",
DailyLimitUSD: &daily,
DailyUsageUSD: 3.0,
DailyWindowStart: &pastStart,
}}}
h := &UserHandler{userPlatformQuotaRepo: repo}
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/user/platform-quotas", nil)
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 42})
h.GetMyPlatformQuotas(c)
if w.Code != 200 {
t.Fatalf("expected 200, got %d. body: %s", w.Code, w.Body.String())
}
// 解析 response验证过期 daily 的 usage_usd=0 且 window_resets_at=null
body := w.Body.String()
if !strings.Contains(body, `"daily_usage_usd":0`) {
t.Errorf("expected daily_usage_usd:0 in body, got: %s", body)
}
if !strings.Contains(body, `"daily_window_resets_at":null`) {
t.Errorf("expected daily_window_resets_at:null in body, got: %s", body)
}
}
func TestGetMyPlatformQuotas_NilRepo_Returns200Empty(t *testing.T) {
h := &UserHandler{userPlatformQuotaRepo: nil}
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/user/platform-quotas", nil)
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 99})
h.GetMyPlatformQuotas(c)
if w.Code != 200 {
t.Fatalf("expected 200, got %d", w.Code)
}
}
func TestGetMyPlatformQuotas_NoAuth_Returns401(t *testing.T) {
h := &UserHandler{userPlatformQuotaRepo: nil}
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/user/platform-quotas", nil)
// 不设置 auth subject
h.GetMyPlatformQuotas(c)
if w.Code != 401 {
t.Fatalf("expected 401, got %d", w.Code)
}
}
func TestLazyZeroQuotaForResponse_UserViewStripsWindowStart(t *testing.T) {
start := time.Now().UTC().Add(-1 * time.Hour)
r := service.UserPlatformQuotaRecord{
Platform: "anthropic",
DailyUsageUSD: 1.0,
DailyWindowStart: &start,
}
out := quotaview.LazyZeroQuotaForResponse(r, time.Now().UTC(), false)
if _, ok := out["daily_window_start"]; ok {
t.Error("user view should not include daily_window_start")
}
}
func TestLazyZeroQuotaForResponse_AdminViewIncludesWindowStart(t *testing.T) {
start := time.Now().UTC().Add(-1 * time.Hour)
r := service.UserPlatformQuotaRecord{
Platform: "anthropic",
DailyWindowStart: &start,
}
out := quotaview.LazyZeroQuotaForResponse(r, time.Now().UTC(), true)
if _, ok := out["daily_window_start"]; !ok {
t.Error("admin view should include daily_window_start")
}
}
func TestLazyZeroQuotaForResponse_ActiveWindowPreservesUsage(t *testing.T) {
// 今天的窗口起始时间(不过期):按全局时区取当天 0 点,与 view 层同口径
now := time.Now()
today := timezone.StartOfDay(now)
usage := 2.5
r := service.UserPlatformQuotaRecord{
Platform: "openai",
DailyUsageUSD: usage,
DailyWindowStart: &today,
}
out := quotaview.LazyZeroQuotaForResponse(r, now, false)
if out["daily_usage_usd"] != usage {
t.Errorf("expected daily_usage_usd=%v, got %v", usage, out["daily_usage_usd"])
}
// 活跃窗口应有 resets_at非 nil
if out["daily_window_resets_at"] == nil {
t.Error("active window should have daily_window_resets_at set")
}
}
func TestNeedsDailyReset_NilStart_ReturnsFalse(t *testing.T) {
if quotaview.NeedsDailyReset(nil, time.Now().UTC()) {
t.Error("nil start should not need reset")
}
}
func TestNeedsDailyReset_OldStart_ReturnsTrue(t *testing.T) {
old := time.Now().UTC().AddDate(0, 0, -1)
if !quotaview.NeedsDailyReset(&old, time.Now().UTC()) {
t.Error("yesterday start should need daily reset")
}
}
func TestNeedsWeeklyReset_NilStart_ReturnsFalse(t *testing.T) {
if quotaview.NeedsWeeklyReset(nil, time.Now().UTC()) {
t.Error("nil start should not need weekly reset")
}
}
func TestNeedsMonthlyReset_NilStart_ReturnsFalse(t *testing.T) {
if quotaview.NeedsMonthlyReset(nil, time.Now().UTC()) {
t.Error("nil start should not need monthly reset")
}
}
// TestNeedsMonthlyReset_30DayRolling 验证 30 天滚动语义C-NEW-1
func TestNeedsMonthlyReset_30DayRolling_Expired(t *testing.T) {
start := time.Now().UTC().Add(-31 * 24 * time.Hour) // 31 天前,已过期
if !quotaview.NeedsMonthlyReset(&start, time.Now().UTC()) {
t.Error("31 days ago should need monthly reset (30-day rolling)")
}
}
func TestNeedsMonthlyReset_30DayRolling_Active(t *testing.T) {
start := time.Now().UTC().Add(-15 * 24 * time.Hour) // 15 天前,窗口有效
if quotaview.NeedsMonthlyReset(&start, time.Now().UTC()) {
t.Error("15 days ago should NOT need monthly reset (30-day rolling, still active)")
}
}
// TestNeedsMonthlyReset_CrossMonthBoundary 验证跨自然月时 30 天未满不重置(旧自然月语义会提前重置)。
func TestNeedsMonthlyReset_CrossMonthBoundary(t *testing.T) {
// 窗口起始 4 月 20 日5 月 1 日仅过了 11 天,不足 30 天,不应重置
windowStart := time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC)
now := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
if quotaview.NeedsMonthlyReset(&windowStart, now) {
t.Error("cross-month boundary within 30 days should NOT trigger reset (30-day rolling)")
}
}