| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492 |
- //go:build unit
- package admin
- import (
- "context"
- "encoding/json"
- "errors"
- "net/http"
- "net/http/httptest"
- "testing"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- "github.com/Wei-Shaw/sub2api/internal/service"
- )
- type openAIQuotaWorkflowStub struct {
- resetResult *service.OpenAIQuotaResetResult
- resetErr error
- queryResult *service.OpenAIQuotaUsage
- queryErr error
- cacheErr error
- resetCalls int
- queryCalls int
- cacheCalls int
- queryCtxErr error
- cacheCtxErr error
- }
- func (s *openAIQuotaWorkflowStub) ResetCredit(context.Context, int64) (*service.OpenAIQuotaResetResult, error) {
- s.resetCalls++
- return s.resetResult, s.resetErr
- }
- func (s *openAIQuotaWorkflowStub) QueryUsage(ctx context.Context, _ int64) (*service.OpenAIQuotaUsage, error) {
- s.queryCalls++
- s.queryCtxErr = ctx.Err()
- return s.queryResult, s.queryErr
- }
- func (s *openAIQuotaWorkflowStub) CacheResetCreditsSnapshot(ctx context.Context, _ int64, _ *service.OpenAIRateLimitResetCredits) error {
- s.cacheCalls++
- s.cacheCtxErr = ctx.Err()
- return s.cacheErr
- }
- func (s *openAIQuotaWorkflowStub) CachePostResetSnapshot(ctx context.Context, _ int64, _ *service.OpenAIQuotaUsage) error {
- s.cacheCalls++
- s.cacheCtxErr = ctx.Err()
- return s.cacheErr
- }
- type openAIAccountStateRecovererStub struct {
- err error
- calls int
- accountID int64
- lastOptions service.AccountRecoveryOptions
- lastCtxErr error
- }
- func (s *openAIAccountStateRecovererStub) RecoverAccountState(ctx context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error) {
- s.calls++
- s.accountID = accountID
- s.lastOptions = options
- s.lastCtxErr = ctx.Err()
- return &service.SuccessfulTestRecoveryResult{}, s.err
- }
- type openAIResetAdminServiceStub struct {
- service.AdminService
- account *service.Account
- err error
- calls int
- }
- func (s *openAIResetAdminServiceStub) GetAccount(context.Context, int64) (*service.Account, error) {
- s.calls++
- return s.account, s.err
- }
- type openAIQuotaResetEnvelope struct {
- Code int `json:"code"`
- Data openAIQuotaResetResponse `json:"data"`
- }
- type openAIQuotaRefreshEnvelope struct {
- Code int `json:"code"`
- Data openAIQuotaRefreshResponse `json:"data"`
- }
- func performOpenAIQuotaResetRequest(t *testing.T, handler *OpenAIOAuthHandler) (int, openAIQuotaResetEnvelope) {
- t.Helper()
- return performOpenAIQuotaResetRequestWithContext(t, handler, nil)
- }
- // performOpenAIQuotaResetRequestWithContext drives the reset endpoint, optionally
- // with an already-canceled request context (client disconnect simulation).
- func performOpenAIQuotaResetRequestWithContext(t *testing.T, handler *OpenAIOAuthHandler, ctx context.Context) (int, openAIQuotaResetEnvelope) {
- t.Helper()
- gin.SetMode(gin.TestMode)
- router := gin.New()
- router.POST("/api/v1/admin/openai/accounts/:id/reset-quota", handler.ResetQuota)
- recorder := httptest.NewRecorder()
- request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/openai/accounts/42/reset-quota", nil)
- if ctx != nil {
- request = request.WithContext(ctx)
- }
- router.ServeHTTP(recorder, request)
- var envelope openAIQuotaResetEnvelope
- require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &envelope))
- return recorder.Code, envelope
- }
- func performOpenAIQuotaRefreshRequest(t *testing.T, handler *OpenAIOAuthHandler) (int, openAIQuotaRefreshEnvelope) {
- t.Helper()
- gin.SetMode(gin.TestMode)
- router := gin.New()
- router.POST("/api/v1/admin/openai/accounts/:id/quota/refresh", handler.RefreshQuota)
- recorder := httptest.NewRecorder()
- request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/openai/accounts/42/quota/refresh", nil)
- router.ServeHTTP(recorder, request)
- var envelope openAIQuotaRefreshEnvelope
- require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &envelope))
- return recorder.Code, envelope
- }
- func successfulOpenAIQuotaWorkflowStub() *openAIQuotaWorkflowStub {
- return &openAIQuotaWorkflowStub{
- resetResult: &service.OpenAIQuotaResetResult{
- Code: "success",
- WindowsReset: 1,
- },
- queryResult: &service.OpenAIQuotaUsage{
- FetchedAt: 123,
- RateLimitResetCredits: &service.OpenAIRateLimitResetCredits{
- AvailableCount: 0,
- Credits: []service.OpenAIRateLimitResetCreditDetail{},
- },
- },
- }
- }
- func recoveredAccountStub() *openAIResetAdminServiceStub {
- return &openAIResetAdminServiceStub{account: &service.Account{
- ID: 42,
- Name: "recovered",
- Platform: service.PlatformOpenAI,
- Type: service.AccountTypeOAuth,
- Status: service.StatusActive,
- Schedulable: false,
- }}
- }
- func TestOpenAIResetQuota_ResetFailureStopsWorkflow(t *testing.T) {
- quota := &openAIQuotaWorkflowStub{resetErr: errors.New("upstream reset failed")}
- recoverer := &openAIAccountStateRecovererStub{}
- handler := &OpenAIOAuthHandler{
- adminService: &openAIResetAdminServiceStub{},
- quotaService: quota,
- rateLimitService: recoverer,
- }
- status, _ := performOpenAIQuotaResetRequest(t, handler)
- require.Equal(t, http.StatusInternalServerError, status)
- require.Equal(t, 1, quota.resetCalls)
- require.Zero(t, quota.queryCalls)
- require.Zero(t, quota.cacheCalls)
- require.Zero(t, recoverer.calls)
- }
- // Account-state recovery is the reason the credit was spent (#3672 / #3740), so it
- // must run before — and independently of — the reset-credit display cache.
- func TestOpenAIResetQuota_RecoversAccountStateBeforeRefreshingCache(t *testing.T) {
- quota := successfulOpenAIQuotaWorkflowStub()
- recoverer := &openAIAccountStateRecovererStub{}
- adminService := recoveredAccountStub()
- handler := &OpenAIOAuthHandler{
- adminService: adminService,
- quotaService: quota,
- rateLimitService: recoverer,
- }
- status, envelope := performOpenAIQuotaResetRequest(t, handler)
- require.Equal(t, http.StatusOK, status)
- require.Empty(t, envelope.Data.WarningCode)
- require.True(t, envelope.Data.AccountStateRecovered)
- require.True(t, envelope.Data.CacheRefreshed)
- require.NotNil(t, envelope.Data.Quota)
- require.NotNil(t, envelope.Data.Account)
- require.Equal(t, int64(42), envelope.Data.Account.ID)
- require.False(t, envelope.Data.Account.Schedulable, "manual scheduling switch must not be flipped")
- require.Equal(t, int64(42), recoverer.accountID)
- require.True(t, recoverer.lastOptions.InvalidateToken)
- require.Equal(t, 1, quota.resetCalls)
- require.Equal(t, 1, quota.queryCalls)
- require.Equal(t, 1, quota.cacheCalls)
- require.Equal(t, 1, recoverer.calls)
- require.Equal(t, 1, adminService.calls)
- }
- func TestOpenAIResetQuota_RecoveryFailureStopsWorkflow(t *testing.T) {
- quota := successfulOpenAIQuotaWorkflowStub()
- recoverer := &openAIAccountStateRecovererStub{err: errors.New("recovery failed")}
- adminService := recoveredAccountStub()
- handler := &OpenAIOAuthHandler{
- adminService: adminService,
- quotaService: quota,
- rateLimitService: recoverer,
- }
- status, envelope := performOpenAIQuotaResetRequest(t, handler)
- require.Equal(t, http.StatusOK, status)
- require.Equal(t, service.OpenAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
- require.False(t, envelope.Data.AccountStateRecovered)
- require.False(t, envelope.Data.CacheRefreshed)
- require.Nil(t, envelope.Data.Quota)
- require.Nil(t, envelope.Data.Account)
- require.Equal(t, 1, recoverer.calls)
- require.Zero(t, quota.queryCalls)
- require.Zero(t, quota.cacheCalls)
- require.Zero(t, adminService.calls)
- }
- func TestOpenAIResetQuota_MissingRecovererReportsRecoveryFailure(t *testing.T) {
- quota := successfulOpenAIQuotaWorkflowStub()
- adminService := recoveredAccountStub()
- handler := &OpenAIOAuthHandler{
- adminService: adminService,
- quotaService: quota,
- }
- status, envelope := performOpenAIQuotaResetRequest(t, handler)
- require.Equal(t, http.StatusOK, status)
- require.Equal(t, service.OpenAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
- require.False(t, envelope.Data.AccountStateRecovered)
- require.Zero(t, quota.queryCalls)
- require.Zero(t, adminService.calls)
- }
- // A failed cache refresh must not hide the recovered account row: the operator
- // still needs the list to drop the stale rate-limit badge.
- func TestOpenAIResetQuota_QueryFailureStillRecoversAndReturnsAccount(t *testing.T) {
- quota := successfulOpenAIQuotaWorkflowStub()
- quota.queryResult = nil
- quota.queryErr = errors.New("upstream query failed")
- recoverer := &openAIAccountStateRecovererStub{}
- adminService := recoveredAccountStub()
- handler := &OpenAIOAuthHandler{
- adminService: adminService,
- quotaService: quota,
- rateLimitService: recoverer,
- }
- status, envelope := performOpenAIQuotaResetRequest(t, handler)
- require.Equal(t, http.StatusOK, status)
- require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
- require.True(t, envelope.Data.AccountStateRecovered)
- require.False(t, envelope.Data.CacheRefreshed)
- require.Nil(t, envelope.Data.Quota)
- require.NotNil(t, envelope.Data.Account)
- require.Equal(t, 1, quota.queryCalls)
- require.Zero(t, quota.cacheCalls)
- require.Equal(t, 1, recoverer.calls)
- require.Equal(t, 1, adminService.calls)
- }
- func TestOpenAIResetQuota_CacheFailureStillRecoversAndReturnsAccount(t *testing.T) {
- quota := successfulOpenAIQuotaWorkflowStub()
- quota.cacheErr = errors.New("cache write failed")
- recoverer := &openAIAccountStateRecovererStub{}
- adminService := recoveredAccountStub()
- handler := &OpenAIOAuthHandler{
- adminService: adminService,
- quotaService: quota,
- rateLimitService: recoverer,
- }
- status, envelope := performOpenAIQuotaResetRequest(t, handler)
- require.Equal(t, http.StatusOK, status)
- require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
- require.True(t, envelope.Data.AccountStateRecovered)
- require.False(t, envelope.Data.CacheRefreshed)
- require.Nil(t, envelope.Data.Quota)
- require.NotNil(t, envelope.Data.Account)
- require.Equal(t, 1, quota.cacheCalls)
- require.Equal(t, 1, adminService.calls)
- }
- func TestOpenAIResetQuota_AccountRefreshFailureReportsRecoveredState(t *testing.T) {
- quota := successfulOpenAIQuotaWorkflowStub()
- recoverer := &openAIAccountStateRecovererStub{}
- adminService := &openAIResetAdminServiceStub{err: errors.New("account refresh failed")}
- handler := &OpenAIOAuthHandler{
- adminService: adminService,
- quotaService: quota,
- rateLimitService: recoverer,
- }
- status, envelope := performOpenAIQuotaResetRequest(t, handler)
- require.Equal(t, http.StatusOK, status)
- require.Equal(t, service.OpenAIQuotaResetWarningAccountRefreshFailed, envelope.Data.WarningCode)
- require.True(t, envelope.Data.CacheRefreshed)
- require.True(t, envelope.Data.AccountStateRecovered)
- require.NotNil(t, envelope.Data.Quota)
- require.Nil(t, envelope.Data.Account)
- require.Equal(t, 1, adminService.calls)
- }
- // The first (most actionable) failure wins so the UI never downgrades a cache
- // problem into a cosmetic "could not reload the row" message.
- func TestOpenAIResetQuota_CacheAndAccountFailureKeepsFirstWarning(t *testing.T) {
- quota := successfulOpenAIQuotaWorkflowStub()
- quota.cacheErr = errors.New("cache write failed")
- recoverer := &openAIAccountStateRecovererStub{}
- adminService := &openAIResetAdminServiceStub{err: errors.New("account refresh failed")}
- handler := &OpenAIOAuthHandler{
- adminService: adminService,
- quotaService: quota,
- rateLimitService: recoverer,
- }
- status, envelope := performOpenAIQuotaResetRequest(t, handler)
- require.Equal(t, http.StatusOK, status)
- require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
- require.True(t, envelope.Data.AccountStateRecovered)
- require.Nil(t, envelope.Data.Account)
- }
- // The credit is non-refundable once consumed, so post-reset bookkeeping must
- // survive a client disconnect instead of leaving the account rate-limited.
- func TestOpenAIResetQuota_PostProcessingSurvivesClientCancellation(t *testing.T) {
- quota := successfulOpenAIQuotaWorkflowStub()
- recoverer := &openAIAccountStateRecovererStub{}
- adminService := recoveredAccountStub()
- handler := &OpenAIOAuthHandler{
- adminService: adminService,
- quotaService: quota,
- rateLimitService: recoverer,
- }
- ctx, cancel := context.WithCancel(context.Background())
- cancel()
- status, _ := performOpenAIQuotaResetRequestWithContext(t, handler, ctx)
- require.Equal(t, http.StatusOK, status)
- require.Equal(t, 1, recoverer.calls)
- require.NoError(t, recoverer.lastCtxErr, "recovery must not inherit the canceled client context")
- require.NoError(t, quota.queryCtxErr)
- require.NoError(t, quota.cacheCtxErr)
- require.Equal(t, 1, adminService.calls)
- }
- func TestOpenAIRefreshQuota_PersistsSnapshot(t *testing.T) {
- quota := successfulOpenAIQuotaWorkflowStub()
- handler := &OpenAIOAuthHandler{
- adminService: &openAIResetAdminServiceStub{},
- quotaService: quota,
- }
- status, envelope := performOpenAIQuotaRefreshRequest(t, handler)
- require.Equal(t, http.StatusOK, status)
- require.True(t, envelope.Data.CachePersisted)
- require.Equal(t, int64(123), envelope.Data.FetchedAt)
- require.Equal(t, 1, quota.queryCalls)
- require.Equal(t, 1, quota.cacheCalls)
- require.Zero(t, quota.resetCalls)
- }
- // A rejected snapshot write must never discard the usage payload: otherwise the
- // card loses its credit count and the reset button stays disabled forever.
- func TestOpenAIRefreshQuota_PersistFailureStillReturnsUsage(t *testing.T) {
- quota := successfulOpenAIQuotaWorkflowStub()
- quota.queryResult = &service.OpenAIQuotaUsage{
- FetchedAt: 456,
- RateLimitResetCredits: &service.OpenAIRateLimitResetCredits{
- AvailableCount: 2,
- },
- }
- quota.cacheErr = errors.New("expiration details unavailable")
- handler := &OpenAIOAuthHandler{
- adminService: &openAIResetAdminServiceStub{},
- quotaService: quota,
- }
- status, envelope := performOpenAIQuotaRefreshRequest(t, handler)
- require.Equal(t, http.StatusOK, status)
- require.False(t, envelope.Data.CachePersisted)
- require.Equal(t, int64(456), envelope.Data.FetchedAt)
- require.NotNil(t, envelope.Data.RateLimitResetCredits)
- require.Equal(t, 2, envelope.Data.RateLimitResetCredits.AvailableCount)
- require.Equal(t, 1, quota.cacheCalls)
- }
- // An empty-but-successful upstream read must not be dereferenced blindly.
- func TestOpenAIQuotaEmptyUsageIsHandledWithoutPanic(t *testing.T) {
- t.Run("refresh reports an internal error", func(t *testing.T) {
- quota := successfulOpenAIQuotaWorkflowStub()
- quota.queryResult = nil
- handler := &OpenAIOAuthHandler{
- adminService: &openAIResetAdminServiceStub{},
- quotaService: quota,
- }
- status, envelope := performOpenAIQuotaRefreshRequest(t, handler)
- require.Equal(t, http.StatusInternalServerError, status)
- require.False(t, envelope.Data.CachePersisted)
- require.Zero(t, quota.cacheCalls)
- })
- t.Run("reset degrades to a cache warning", func(t *testing.T) {
- quota := successfulOpenAIQuotaWorkflowStub()
- quota.queryResult = nil
- recoverer := &openAIAccountStateRecovererStub{}
- adminService := recoveredAccountStub()
- handler := &OpenAIOAuthHandler{
- adminService: adminService,
- quotaService: quota,
- rateLimitService: recoverer,
- }
- status, envelope := performOpenAIQuotaResetRequest(t, handler)
- require.Equal(t, http.StatusOK, status)
- require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
- require.True(t, envelope.Data.AccountStateRecovered)
- require.NotNil(t, envelope.Data.Account)
- require.Zero(t, quota.cacheCalls)
- })
- }
- func TestOpenAIRefreshQuota_QueryFailureIsReported(t *testing.T) {
- quota := &openAIQuotaWorkflowStub{queryErr: errors.New("upstream query failed")}
- handler := &OpenAIOAuthHandler{
- adminService: &openAIResetAdminServiceStub{},
- quotaService: quota,
- }
- status, _ := performOpenAIQuotaRefreshRequest(t, handler)
- require.Equal(t, http.StatusInternalServerError, status)
- require.Equal(t, 1, quota.queryCalls)
- require.Zero(t, quota.cacheCalls)
- }
- // Storing a nil *Service in an interface field would make the capability guards
- // non-nil and panic on the first call; the constructor must keep them nil.
- func TestNewOpenAIOAuthHandlerKeepsNilQuotaCapabilitiesGuarded(t *testing.T) {
- gin.SetMode(gin.TestMode)
- handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil, nil)
- require.Nil(t, handler.quotaService)
- require.Nil(t, handler.rateLimitService)
- router := gin.New()
- router.Use(gin.Recovery())
- router.GET("/openai/accounts/:id/quota", handler.QueryQuota)
- router.POST("/openai/accounts/:id/quota/refresh", handler.RefreshQuota)
- router.POST("/openai/accounts/:id/reset-quota", handler.ResetQuota)
- for _, tc := range []struct {
- method string
- path string
- }{
- {http.MethodGet, "/openai/accounts/42/quota"},
- {http.MethodPost, "/openai/accounts/42/quota/refresh"},
- {http.MethodPost, "/openai/accounts/42/reset-quota"},
- } {
- recorder := httptest.NewRecorder()
- router.ServeHTTP(recorder, httptest.NewRequest(tc.method, tc.path, nil))
- require.Equal(t, http.StatusBadRequest, recorder.Code, "%s %s", tc.method, tc.path)
- }
- }
|