@@ -288,9 +288,53 @@ func TestServeIndex(t *testing.T) {
288288 if w .Code != 200 {
289289 t .Errorf ("expected 200, got %d" , w .Code )
290290 }
291- if ! strings .Contains (w .Body .String (), "<!DOCTYPE html>" ) {
291+ body := w .Body .String ()
292+ if ! strings .Contains (body , "<!DOCTYPE html>" ) {
292293 t .Error ("expected HTML doctype" )
293294 }
295+ for _ , want := range []string {`id="quotaCard"` , `id="quotaTable"` , "renderQuotaDetails(d)" , "组织资源包" } {
296+ if ! strings .Contains (body , want ) {
297+ t .Errorf ("expected quota detail UI marker %q" , want )
298+ }
299+ }
300+ }
301+
302+ // The panel's headline must reflect the allowance the account actually holds.
303+ // A free account reports userQuota.total=0 while the usable budget sits in
304+ // addOnQuota; keying the headline off userQuota rendered "套餐 8 / 0 · 剩 0"
305+ // for an account that still had credits, and hid the pool that really mattered.
306+ func TestQuotaSourceSelectionIgnoresEmptyPools (t * testing.T ) {
307+ start := strings .Index (indexHTML , "function quotaPools(acct) {" )
308+ if start < 0 {
309+ t .Fatal ("quotaPools not found in the embedded UI" )
310+ }
311+ end := strings .Index (indexHTML [start :], "\n /*\n * Prefer the authoritative" )
312+ if end < 0 {
313+ t .Fatal ("could not delimit quotaPools" )
314+ }
315+ pools := indexHTML [start : start + end ]
316+
317+ // A zero-total pool carries no allowance and must not be offered as one.
318+ for _ , guard := range []string {
319+ "acct.user_quota && Number(acct.user_quota.total || 0) > 0" ,
320+ "acct.add_on_quota && Number(acct.add_on_quota.total || 0) > 0" ,
321+ "acct.org_resource_package && Number(acct.org_resource_package.cap || 0) > 0" ,
322+ } {
323+ if ! strings .Contains (pools , guard ) {
324+ t .Errorf ("quotaPools must skip zero-capacity pools; missing guard %q" , guard )
325+ }
326+ }
327+
328+ // Both renderers must share that selection instead of hand-rolling their
329+ // own, otherwise the headline and the detail card can disagree.
330+ renderCredits := indexHTML [strings .Index (indexHTML , "function renderCredits(d) {" ):]
331+ renderCredits = renderCredits [:strings .Index (renderCredits , "function renderQuotaDetails(d) {" )]
332+ if ! strings .Contains (renderCredits , "quotaPools(acct)" ) {
333+ t .Error ("renderCredits must select its pools via quotaPools(acct)" )
334+ }
335+ if strings .Contains (renderCredits , "acct.user_quota ||" ) {
336+ t .Error ("renderCredits must not hardcode user_quota as the headline source" )
337+ }
294338}
295339
296340// Repeated wrong passwords trigger per-IP rate limiting (HTTP 429).
@@ -505,3 +549,96 @@ func TestConfigPartialInvalidTimeoutAppliesNothing(t *testing.T) {
505549 t .Errorf ("hot-apply must not run on rejected input, got %s" , timeouts .Header )
506550 }
507551}
552+
553+ // The billing-cycle fields and account metadata must reach the panel through
554+ // the stats endpoint, so the UI can render cycle-relative credits without any
555+ // network call of its own.
556+ func TestStatsEndpointExposesBillingCycle (t * testing.T ) {
557+ reset := time .Date (2026 , time .September , 25 , 0 , 0 , 0 , 0 , time .Local )
558+ rec := stats .NewRecorder (nil )
559+ rec .SetBillingCycle (reset .UnixMilli ())
560+ rec .SetAccount (& stats.Account {
561+ Plan : "PLAN_TIER_TEAM" , Tag : "Teams" , OrgName : "Example Org" ,
562+ TotalUsagePercentage : 0.98 ,
563+ UserQuota : & stats.Quota {Total : 3000 , Used : 2939 , Remaining : 61 , Percentage : 0.98 , Unit : "credits" },
564+ OrgResourcePackage : & stats.OrgResourcePackage {Cap : 4000 , Available : false , Unit : "credits" },
565+ })
566+ rec .RecordUsage (& stats.Usage {PromptTokens : 5 , Credits : 1.25 })
567+
568+ a := New (tempStore (t ), func (ctx context.Context ) []string { return nil }, rec )
569+ w := doRequest (t , a .handleStats , "GET" , "/admin/api/stats" , nil )
570+
571+ var resp struct {
572+ Credits float64 `json:"credits"`
573+ CycleCredits float64 `json:"cycle_credits"`
574+ CycleStartMs int64 `json:"cycle_start_ms"`
575+ NextResetMs int64 `json:"next_reset_ms"`
576+ Account * struct {
577+ Plan string `json:"plan"`
578+ Tag string `json:"tag"`
579+ OrgName string `json:"org_name"`
580+ TotalUsagePercentage float64 `json:"total_usage_percentage"`
581+ UserQuota * struct {
582+ Total float64 `json:"total"`
583+ Used float64 `json:"used"`
584+ Remaining float64 `json:"remaining"`
585+ } `json:"user_quota"`
586+ OrgResourcePackage * struct {
587+ Cap float64 `json:"cap"`
588+ Available bool `json:"available"`
589+ } `json:"org_resource_package"`
590+ } `json:"account"`
591+ }
592+ if err := json .Unmarshal (w .Body .Bytes (), & resp ); err != nil {
593+ t .Fatalf ("bad response JSON: %v" , err )
594+ }
595+ if resp .NextResetMs != reset .UnixMilli () {
596+ t .Errorf ("expected next_reset_ms %d, got %d" , reset .UnixMilli (), resp .NextResetMs )
597+ }
598+ wantStart := time .Date (2026 , time .August , 25 , 0 , 0 , 0 , 0 , time .Local ).UnixMilli ()
599+ if resp .CycleStartMs != wantStart {
600+ t .Errorf ("expected cycle_start_ms %d (one calendar month back), got %d" , wantStart , resp .CycleStartMs )
601+ }
602+ if resp .CycleCredits < 1.25 - 1e-9 || resp .CycleCredits > 1.25 + 1e-9 {
603+ t .Errorf ("expected cycle_credits 1.25, got %v" , resp .CycleCredits )
604+ }
605+ if resp .Account == nil || resp .Account .Tag != "Teams" || resp .Account .Plan != "PLAN_TIER_TEAM" {
606+ t .Errorf ("expected account metadata in the response, got %+v" , resp .Account )
607+ }
608+ if resp .Account .UserQuota == nil || resp .Account .UserQuota .Total != 3000 || resp .Account .UserQuota .Used != 2939 || resp .Account .UserQuota .Remaining != 61 {
609+ t .Errorf ("expected authoritative user quota in the response, got %+v" , resp .Account .UserQuota )
610+ }
611+ if resp .Account .OrgResourcePackage == nil || resp .Account .OrgResourcePackage .Cap != 4000 || resp .Account .OrgResourcePackage .Available {
612+ t .Errorf ("expected organization resource package in the response, got %+v" , resp .Account .OrgResourcePackage )
613+ }
614+ }
615+
616+ // With no account status ever resolved the cycle fields stay zero and the
617+ // account is omitted, which is what makes the UI fall back to the lifetime
618+ // total instead of rendering a bogus "resetting soon".
619+ func TestStatsEndpointBillingCycleUnknown (t * testing.T ) {
620+ rec := stats .NewRecorder (nil )
621+ rec .RecordUsage (& stats.Usage {Credits : 2 })
622+
623+ a := New (tempStore (t ), func (ctx context.Context ) []string { return nil }, rec )
624+ w := doRequest (t , a .handleStats , "GET" , "/admin/api/stats" , nil )
625+
626+ var resp struct {
627+ Credits float64 `json:"credits"`
628+ NextResetMs int64 `json:"next_reset_ms"`
629+ CycleCredits float64 `json:"cycle_credits"`
630+ Account map [string ]any `json:"account"`
631+ }
632+ if err := json .Unmarshal (w .Body .Bytes (), & resp ); err != nil {
633+ t .Fatalf ("bad response JSON: %v" , err )
634+ }
635+ if resp .NextResetMs != 0 {
636+ t .Errorf ("expected no reset boundary, got %d" , resp .NextResetMs )
637+ }
638+ if resp .Credits < 2 - 1e-9 || resp .Credits > 2 + 1e-9 {
639+ t .Errorf ("lifetime credits must still accrue with no cycle known, got %v" , resp .Credits )
640+ }
641+ if resp .Account != nil {
642+ t .Errorf ("expected account to be omitted, got %+v" , resp .Account )
643+ }
644+ }
0 commit comments