Files
sub2api/backend/internal/service/opencode_go_usage_test.go
T

1010 lines
43 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.
package service
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"reflect"
"sync"
"sync/atomic"
"testing"
"time"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/stretchr/testify/require"
)
// openCodeGoUsageFixture is the verified upstream 200 response (2026-08-13).
const openCodeGoUsageFixture = `{"usage":{"rolling":{"status":"ok","percent":6,"resetsAt":"2026-08-13T18:26:39.281Z"},"weekly":{"status":"ok","percent":2,"resetsAt":"2026-08-17T00:00:00.281Z"},"monthly":{"status":"ok","percent":1,"resetsAt":"2026-09-13T13:24:47.281Z"}}}`
type openCodeGoUsageTestRepo struct {
AccountRepository
mu sync.Mutex
accounts map[int64]*Account
due []Account
groupResolveCalls atomic.Int64
getByIDCalls atomic.Int64
disableAutoCalls atomic.Int64
disableAutoAttempt atomic.Int64
}
func (r *openCodeGoUsageTestRepo) GetByID(_ context.Context, id int64) (*Account, error) {
r.getByIDCalls.Add(1)
r.mu.Lock()
defer r.mu.Unlock()
account := r.accounts[id]
if account == nil {
return nil, ErrAccountNotFound
}
clone := *account
clone.Credentials = mergeMap(nil, account.Credentials)
clone.Extra = mergeMap(nil, account.Extra)
return &clone, nil
}
func (r *openCodeGoUsageTestRepo) GetByIDs(_ context.Context, ids []int64) ([]*Account, error) {
r.mu.Lock()
defer r.mu.Unlock()
result := make([]*Account, 0, len(ids))
for _, id := range ids {
if account := r.accounts[id]; account != nil {
result = append(result, account)
}
}
return result, nil
}
func (r *openCodeGoUsageTestRepo) ListOpenCodeGoUsageGroupAccounts(_ context.Context, anchors []*Account) ([]Account, error) {
r.groupResolveCalls.Add(1)
r.mu.Lock()
defer r.mu.Unlock()
wanted := make(map[string]struct{}, len(anchors))
for _, anchor := range anchors {
if fingerprint, ok := openCodeGoUsageGroupFingerprint(anchor); ok {
wanted[fingerprint] = struct{}{}
}
}
result := make([]Account, 0, len(r.accounts))
for _, account := range r.accounts {
fingerprint, ok := openCodeGoUsageGroupFingerprint(account)
if _, match := wanted[fingerprint]; !ok || !match {
continue
}
result = append(result, cloneOpenCodeGoUsageTestAccount(*account))
}
return result, nil
}
func (r *openCodeGoUsageTestRepo) SetOpenCodeGoUsageAutoRefresh(_ context.Context, expected *Account, enabled bool) error {
r.mu.Lock()
defer r.mu.Unlock()
members, err := r.openCodeGoGroupMembersLocked(expected)
if err != nil {
return err
}
for _, account := range members {
applyOpenCodeGoUsageTestManagedExtra(account, expected)
account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = enabled
}
return nil
}
func (r *openCodeGoUsageTestRepo) UpdateOpenCodeGoUsageSnapshot(_ context.Context, expected *Account, snapshot *OpenCodeGoUsageSnapshot) error {
r.mu.Lock()
defer r.mu.Unlock()
members, err := r.openCodeGoGroupMembersLocked(expected)
if err != nil {
return err
}
for _, account := range members {
applyOpenCodeGoUsageTestManagedExtra(account, expected)
account.Extra[OpenCodeGoUsageSnapshotExtraKey] = snapshot
}
return nil
}
func (r *openCodeGoUsageTestRepo) DisableOpenCodeGoUsageAutoRefresh(_ context.Context, expected *Account) error {
r.disableAutoAttempt.Add(1)
r.mu.Lock()
defer r.mu.Unlock()
members, err := r.openCodeGoGroupMembersLocked(expected)
if err != nil {
return err
}
for _, account := range members {
applyOpenCodeGoUsageTestManagedExtra(account, expected)
account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = false
delete(account.Extra, OpenCodeGoUsageSnapshotExtraKey)
}
r.disableAutoCalls.Add(1)
return nil
}
func (r *openCodeGoUsageTestRepo) openCodeGoGroupMembersLocked(expected *Account) ([]*Account, error) {
anchor := r.accounts[expected.ID]
if !sameOpenCodeGoUsageTestIdentity(anchor, expected) {
return nil, ErrOpenCodeGoUsageIdentityChanged
}
fingerprint, ok := openCodeGoUsageGroupFingerprint(expected)
if !ok {
return nil, ErrOpenCodeGoUsageAccountInvalid
}
members := make([]*Account, 0, len(r.accounts))
for _, account := range r.accounts {
candidate, valid := openCodeGoUsageGroupFingerprint(account)
if valid && candidate == fingerprint {
if account.Extra == nil {
account.Extra = make(map[string]any)
}
members = append(members, account)
}
}
return members, nil
}
func applyOpenCodeGoUsageTestManagedExtra(account, source *Account) {
for _, key := range []string{OpenCodeGoUsageAutoRefreshExtraKey, OpenCodeGoUsageSnapshotExtraKey} {
delete(account.Extra, key)
if value, ok := source.Extra[key]; ok {
account.Extra[key] = value
}
}
}
func sameOpenCodeGoUsageTestIdentity(left, right *Account) bool {
return left != nil && right != nil && left.Platform == right.Platform && left.Type == right.Type &&
reflect.DeepEqual(left.Credentials, right.Credentials) && reflect.DeepEqual(left.ProxyID, right.ProxyID)
}
func (r *openCodeGoUsageTestRepo) ListDueOpenCodeGoUsageAccounts(_ context.Context, _ time.Time, _, _ time.Duration, limit int) ([]Account, error) {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.due) > 0 {
out := make([]Account, 0, min(limit, len(r.due)))
for _, account := range r.due[:min(limit, len(r.due))] {
out = append(out, cloneOpenCodeGoUsageTestAccount(account))
}
return out, nil
}
out := make([]Account, 0, len(r.accounts))
for _, account := range r.accounts {
out = append(out, cloneOpenCodeGoUsageTestAccount(*account))
if len(out) == limit {
break
}
}
return out, nil
}
func cloneOpenCodeGoUsageTestAccount(account Account) Account {
account.Credentials = mergeMap(nil, account.Credentials)
account.Extra = mergeMap(nil, account.Extra)
return account
}
type openCodeGoUsageHTTPStub struct {
status int
body []byte
header http.Header
calls atomic.Int64
beforeResponse func(*http.Request)
lastRequest *http.Request
lastProxy string
mu sync.Mutex
}
func (s *openCodeGoUsageHTTPStub) Do(req *http.Request, proxyURL string, _ int64, _ int) (*http.Response, error) {
s.calls.Add(1)
s.mu.Lock()
s.lastRequest = req
s.lastProxy = proxyURL
s.mu.Unlock()
if s.beforeResponse != nil {
s.beforeResponse(req)
}
status := s.status
if status == 0 {
status = http.StatusOK
}
header := s.header
if header == nil {
header = http.Header{"Content-Type": []string{"application/json"}}
}
return &http.Response{StatusCode: status, Header: header, Body: io.NopCloser(bytes.NewReader(s.body)), Request: req}, nil
}
func (s *openCodeGoUsageHTTPStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
return s.Do(req, proxyURL, accountID, concurrency)
}
func openCodeGoUsageAccount(id int64) *Account {
return &Account{
ID: id, Name: fmt.Sprintf("opencode-%d", id), Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
Credentials: map[string]any{"base_url": "https://opencode.ai/zen/go/v1", "api_key": fmt.Sprintf("key-%d", id)},
Extra: map[string]any{}, Status: StatusActive, Schedulable: true, Concurrency: 1,
}
}
func newOpenCodeGoUsageTestService(t *testing.T, repo *openCodeGoUsageTestRepo, upstream HTTPUpstream, settingsRepo SettingRepository) *OpenCodeGoUsageService {
t.Helper()
svc := NewOpenCodeGoUsageService(repo, upstream, NewSettingService(settingsRepo, nil))
t.Cleanup(svc.Stop)
return svc
}
func TestOpenCodeGoUsageParseJSON200(t *testing.T) {
data, err := parseOpenCodeGoUsageJSON([]byte(openCodeGoUsageFixture))
require.NoError(t, err)
require.Equal(t, "ok", data.Rolling.Status)
require.Equal(t, 6.0, data.Rolling.Percent)
require.Equal(t, "ok", data.Weekly.Status)
require.Equal(t, 2.0, data.Weekly.Percent)
require.Equal(t, "ok", data.Monthly.Status)
require.Equal(t, 1.0, data.Monthly.Percent)
rollingReset, err := time.Parse(time.RFC3339, "2026-08-13T18:26:39.281Z")
require.NoError(t, err)
require.Equal(t, rollingReset.UTC(), data.Rolling.ResetsAt)
weeklyReset, err := time.Parse(time.RFC3339, "2026-08-17T00:00:00.281Z")
require.NoError(t, err)
require.Equal(t, weeklyReset.UTC(), data.Weekly.ResetsAt)
}
func TestOpenCodeGoUsageParseJSONLenient(t *testing.T) {
// top-level windows without a usage wrapper
data, err := parseOpenCodeGoUsageJSON([]byte(`{"rolling":{"status":"ok","percent":6,"resetsAt":"2026-08-13T18:26:39.281Z"}}`))
require.NoError(t, err)
require.Equal(t, 6.0, data.Rolling.Percent)
require.Zero(t, data.Weekly.Percent)
// missing windows/fields degrade to zero values instead of failing
data, err = parseOpenCodeGoUsageJSON([]byte(`{"usage":{"rolling":{"status":"ok"}}}`))
require.NoError(t, err)
require.Equal(t, "ok", data.Rolling.Status)
require.Zero(t, data.Rolling.Percent)
require.True(t, data.Rolling.ResetsAt.IsZero())
// integer percent parses as float
data, err = parseOpenCodeGoUsageJSON([]byte(`{"usage":{"weekly":{"status":"ok","percent":2}}}`))
require.NoError(t, err)
require.Equal(t, 2.0, data.Weekly.Percent)
// malformed JSON is an error
_, err = parseOpenCodeGoUsageJSON([]byte(`{"usage": {broken`))
require.Error(t, err)
}
func TestOpenCodeGoUsageRefresh200Success(t *testing.T) {
account := openCodeGoUsageAccount(7)
account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true
repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}}
stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)}
svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{})
state, err := svc.Refresh(context.Background(), 7)
require.NoError(t, err)
require.True(t, state.Eligible)
require.True(t, state.AutoRefreshEnabled)
require.True(t, openCodeGoUsageAutoRefreshEnabled(account))
require.Equal(t, OpenCodeGoUsageStatusOK, state.Snapshot.Status)
require.Equal(t, 6.0, state.Snapshot.Data.Rolling.Percent)
require.Equal(t, 2.0, state.Snapshot.Data.Weekly.Percent)
require.Equal(t, 1.0, state.Snapshot.Data.Monthly.Percent)
require.Equal(t, http.StatusOK, state.Snapshot.HTTPStatus)
require.Equal(t, 0, state.Snapshot.FailureCount)
require.NotNil(t, state.Snapshot.FetchedAt)
require.False(t, state.Snapshot.NextRefreshAt.IsZero())
require.Equal(t, "Bearer key-7", stub.lastRequest.Header.Get("Authorization"))
require.Equal(t, "https://opencode.ai/zen/go/v1/usage", stub.lastRequest.URL.String())
require.Equal(t, "application/json", stub.lastRequest.Header.Get("Accept"))
}
func TestOpenCodeGoUsageRefresh401Unauthorized(t *testing.T) {
account := openCodeGoUsageAccount(7)
account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true
repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}}
stub := &openCodeGoUsageHTTPStub{status: http.StatusUnauthorized}
svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{})
state, err := svc.Refresh(context.Background(), 7)
require.NoError(t, err)
require.Equal(t, OpenCodeGoUsageStatusUnauthorized, state.Snapshot.Status)
require.Equal(t, http.StatusUnauthorized, state.Snapshot.HTTPStatus)
require.Equal(t, "unauthorized", state.Snapshot.LastError)
require.Equal(t, 1, state.Snapshot.FailureCount)
require.False(t, state.Snapshot.NextRefreshAt.IsZero())
// data is kept and auto-refresh stays enabled
require.True(t, openCodeGoUsageAutoRefreshEnabled(account))
}
func TestOpenCodeGoUsageRefresh403Forbidden(t *testing.T) {
account := openCodeGoUsageAccount(7)
repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}}
stub := &openCodeGoUsageHTTPStub{status: http.StatusForbidden}
svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{})
state, err := svc.Refresh(context.Background(), 7)
require.NoError(t, err)
require.Equal(t, OpenCodeGoUsageStatusFailed, state.Snapshot.Status)
require.Equal(t, http.StatusForbidden, state.Snapshot.HTTPStatus)
require.Equal(t, "forbidden", state.Snapshot.LastError)
require.Equal(t, 1, state.Snapshot.FailureCount)
}
func TestOpenCodeGoUsageRefreshMalformedJSON(t *testing.T) {
account := openCodeGoUsageAccount(7)
repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}}
stub := &openCodeGoUsageHTTPStub{status: http.StatusOK, body: []byte(`{"usage": {broken`)}
svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{})
state, err := svc.Refresh(context.Background(), 7)
require.NoError(t, err)
require.Equal(t, OpenCodeGoUsageStatusFailed, state.Snapshot.Status)
require.Equal(t, http.StatusOK, state.Snapshot.HTTPStatus)
require.Equal(t, "invalid_json", state.Snapshot.LastError)
require.Equal(t, 1, state.Snapshot.FailureCount)
}
func TestOpenCodeGoUsageRefreshFailureKeepsPreviousData(t *testing.T) {
now := time.Now().UTC()
account := openCodeGoUsageAccount(7)
account.Extra[OpenCodeGoUsageSnapshotExtraKey] = &OpenCodeGoUsageSnapshot{
Status: OpenCodeGoUsageStatusOK, Data: &OpenCodeGoUsageData{Rolling: OpenCodeGoUsageWindow{Status: "ok", Percent: 6}},
FetchedAt: &now, LastAttemptAt: now.Add(-30 * time.Second), NextRefreshAt: now.Add(time.Hour), FailureCount: 1,
}
repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}}
stub := &openCodeGoUsageHTTPStub{status: http.StatusInternalServerError}
svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{})
state, err := svc.Refresh(context.Background(), 7)
require.NoError(t, err)
require.Equal(t, OpenCodeGoUsageStatusFailed, state.Snapshot.Status)
require.Equal(t, "http_error", state.Snapshot.LastError)
require.Equal(t, 2, state.Snapshot.FailureCount)
require.NotNil(t, state.Snapshot.Data, "previous data must be retained on failure")
require.Equal(t, 6.0, state.Snapshot.Data.Rolling.Percent)
}
func TestNextOpenCodeGoUsageDelayBackoff(t *testing.T) {
// success: base interval with ±10% jitter
delay := nextOpenCodeGoUsageDelay(15, 0, 0)
require.GreaterOrEqual(t, delay, 13*time.Minute)
require.LessOrEqual(t, delay, 17*time.Minute)
// failure 1: 1x interval (2^min(0,6))
delay = nextOpenCodeGoUsageDelay(15, 1, 0)
require.GreaterOrEqual(t, delay, 13*time.Minute)
require.LessOrEqual(t, delay, 17*time.Minute)
// failure 2: 2x interval
delay = nextOpenCodeGoUsageDelay(15, 2, 0)
require.GreaterOrEqual(t, delay, 27*time.Minute)
require.LessOrEqual(t, delay, 33*time.Minute)
// failure 3: 4x interval
delay = nextOpenCodeGoUsageDelay(15, 3, 0)
require.GreaterOrEqual(t, delay, 55*time.Minute)
require.LessOrEqual(t, delay, 65*time.Minute)
// failure 8: exponent capped at 6 → 64x interval (16h)
delay = nextOpenCodeGoUsageDelay(15, 8, 0)
require.GreaterOrEqual(t, delay, 15*time.Hour)
require.LessOrEqual(t, delay, 17*time.Hour)
// hard cap at 24h even for huge intervals
delay = nextOpenCodeGoUsageDelay(1440, 8, 0)
require.GreaterOrEqual(t, delay, 23*time.Hour)
require.LessOrEqual(t, delay, 25*time.Hour)
// Retry-After hint wins over the computed backoff
delay = nextOpenCodeGoUsageDelay(15, 0, 2*time.Hour)
require.GreaterOrEqual(t, delay, 2*time.Hour)
// a hint beyond the 24h ceiling is clamped, never adopted verbatim
delay = nextOpenCodeGoUsageDelay(15, 0, 48*time.Hour)
require.GreaterOrEqual(t, delay, 23*time.Hour+55*time.Minute)
require.LessOrEqual(t, delay, 24*time.Hour+5*time.Minute)
// floor of one minute
delay = nextOpenCodeGoUsageDelay(5, 0, 0)
require.GreaterOrEqual(t, delay, time.Minute)
}
func TestOpenCodeGoUsageRefreshClampsHugeRetryAfter(t *testing.T) {
now := time.Now().UTC()
account := openCodeGoUsageAccount(7)
repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}}
stub := &openCodeGoUsageHTTPStub{
status: http.StatusTooManyRequests,
header: http.Header{"Retry-After": []string{"99999999"}},
}
svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{})
svc.now = func() time.Time { return now }
state, err := svc.Refresh(context.Background(), 7)
require.NoError(t, err)
require.Equal(t, OpenCodeGoUsageStatusFailed, state.Snapshot.Status)
require.Equal(t, http.StatusTooManyRequests, state.Snapshot.HTTPStatus)
require.Equal(t, "http_error", state.Snapshot.LastError)
maxExpected := now.Add(24*time.Hour + 5*time.Minute)
minExpected := now.Add(23*time.Hour + 55*time.Minute)
require.True(t, state.Snapshot.NextRefreshAt.Before(maxExpected.Add(time.Second)))
require.True(t, state.Snapshot.NextRefreshAt.After(minExpected.Add(-time.Second)))
}
func TestOpenCodeGoUsageManualRefreshThrottle(t *testing.T) {
now := time.Now().UTC()
account := openCodeGoUsageAccount(7)
account.Extra[OpenCodeGoUsageSnapshotExtraKey] = &OpenCodeGoUsageSnapshot{
Status: OpenCodeGoUsageStatusOK, LastAttemptAt: now.Add(-2 * time.Second),
}
repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}}
stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)}
svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{})
svc.now = func() time.Time { return now }
_, err := svc.Refresh(context.Background(), 7)
require.Error(t, err)
require.True(t, errors.Is(err, ErrOpenCodeGoUsageRefreshRateLimited))
require.Equal(t, int64(0), stub.calls.Load(), "throttled refresh must not hit upstream")
var appErr *infraerrors.ApplicationError
require.ErrorAs(t, err, &appErr)
require.NotEmpty(t, appErr.Metadata["retry_after_seconds"])
}
func TestOpenCodeGoUsageManualRefreshNotThrottledAfterWindow(t *testing.T) {
now := time.Now().UTC()
account := openCodeGoUsageAccount(7)
account.Extra[OpenCodeGoUsageSnapshotExtraKey] = &OpenCodeGoUsageSnapshot{
Status: OpenCodeGoUsageStatusOK, LastAttemptAt: now.Add(-30 * time.Second),
}
repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}}
stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)}
svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{})
svc.now = func() time.Time { return now }
state, err := svc.Refresh(context.Background(), 7)
require.NoError(t, err)
require.Equal(t, OpenCodeGoUsageStatusOK, state.Snapshot.Status)
require.Equal(t, int64(1), stub.calls.Load())
}
func TestOpenCodeGoUsageIsAutoRefreshDue(t *testing.T) {
debounce := time.Minute
maxWait := time.Hour
now := time.Date(2026, time.July, 25, 12, 0, 0, 0, time.UTC)
fetched := now.Add(-30 * time.Minute)
ptr := func(ts time.Time) *time.Time { return &ts }
require.True(t, openCodeGoUsageIsAutoRefreshDue(nil, nil, now, debounce, maxWait), "missing snapshot first due")
require.True(t, openCodeGoUsageIsAutoRefreshDue(&OpenCodeGoUsageSnapshot{Status: "bogus"}, nil, now, debounce, maxWait), "invalid status first due")
okSnap := &OpenCodeGoUsageSnapshot{
Status: OpenCodeGoUsageStatusOK, FetchedAt: ptr(fetched),
LastAttemptAt: fetched, NextRefreshAt: fetched.Add(maxWait),
}
require.False(t, openCodeGoUsageIsAutoRefreshDue(okSnap, nil, now, debounce, maxWait), "no request after success")
require.False(t, openCodeGoUsageIsAutoRefreshDue(okSnap, ptr(fetched), now, debounce, maxWait), "request not after fetched_at")
require.False(t, openCodeGoUsageIsAutoRefreshDue(okSnap, ptr(now.Add(-30*time.Second)), now, debounce, maxWait), "debounce not elapsed")
require.True(t, openCodeGoUsageIsAutoRefreshDue(okSnap, ptr(now.Add(-time.Minute)), now, debounce, maxWait), "single request quiet for debounce")
// Continuous requests: last used is now, but max-wait from old fetch forces due.
oldFetched := now.Add(-2 * time.Hour)
oldSnap := &OpenCodeGoUsageSnapshot{
Status: OpenCodeGoUsageStatusOK, FetchedAt: ptr(oldFetched),
LastAttemptAt: oldFetched, NextRefreshAt: oldFetched.Add(maxWait),
}
require.True(t, openCodeGoUsageIsAutoRefreshDue(oldSnap, ptr(now), now, debounce, maxWait), "max-wait forces due while requests continue")
// First request after a very old snapshot is immediately due because fetched+maxWait is past.
require.True(t, openCodeGoUsageIsAutoRefreshDue(oldSnap, ptr(now.Add(-time.Second)), now, debounce, maxWait), "stale snapshot first request immediate")
failSnap := &OpenCodeGoUsageSnapshot{
Status: OpenCodeGoUsageStatusFailed, FetchedAt: ptr(fetched),
LastAttemptAt: now.Add(-10 * time.Minute), NextRefreshAt: now.Add(20 * time.Minute),
}
require.False(t, openCodeGoUsageIsAutoRefreshDue(failSnap, nil, now, debounce, maxWait), "failure without new request")
require.False(t, openCodeGoUsageIsAutoRefreshDue(failSnap, ptr(now.Add(-time.Minute)), now, debounce, maxWait), "failure blocked by backoff")
failSnap.NextRefreshAt = now.Add(-time.Second)
require.True(t, openCodeGoUsageIsAutoRefreshDue(failSnap, ptr(now.Add(-time.Minute)), now, debounce, maxWait), "failure after backoff with new request")
require.True(t, openCodeGoUsageIsAutoRefreshDue(&OpenCodeGoUsageSnapshot{
Status: OpenCodeGoUsageStatusOK, LastAttemptAt: now,
}, nil, now, debounce, maxWait), "ok without fetched_at fails open")
}
// The success path stopped consulting next_refresh_at, which is where
// nextOpenCodeGoUsageDelay used to apply the minimum interval. Activity may pull
// a refresh forward only as far as that floor, otherwise request traffic spaced
// just wider than the debounce drives the group's outbound rate far above the
// pre-existing minimum.
func TestOpenCodeGoUsageAutoRefreshDueAtHonoursMinFetchInterval(t *testing.T) {
debounce := time.Minute
maxWait := time.Hour
now := time.Date(2026, time.July, 25, 12, 0, 0, 0, time.UTC)
ptr := func(ts time.Time) *time.Time { return &ts }
// Debounce elapsed, but the last successful fetch is inside the floor.
recent := now.Add(-2 * time.Minute)
recentSnap := &OpenCodeGoUsageSnapshot{
Status: OpenCodeGoUsageStatusOK, FetchedAt: ptr(recent), LastAttemptAt: recent,
}
dueAt, ok := openCodeGoUsageAutoRefreshDueAt(recentSnap, ptr(now.Add(-time.Minute)), debounce, maxWait)
require.True(t, ok)
require.Equal(t, recent.Add(OpenCodeGoUsageMinFetchInterval), dueAt,
"due time must be clamped to fetched_at + min fetch interval")
require.False(t, openCodeGoUsageIsAutoRefreshDue(recentSnap, ptr(now.Add(-time.Minute)), now, debounce, maxWait),
"debounce alone must not refresh within the min fetch interval")
// Once the floor has passed the debounce governs again.
atFloor := now.Add(-OpenCodeGoUsageMinFetchInterval)
floorSnap := &OpenCodeGoUsageSnapshot{
Status: OpenCodeGoUsageStatusOK, FetchedAt: ptr(atFloor), LastAttemptAt: atFloor,
}
require.True(t, openCodeGoUsageIsAutoRefreshDue(floorSnap, ptr(now.Add(-2*time.Minute)), now, debounce, maxWait),
"past the floor a quiet debounce window is due")
// The floor never delays a refresh that max-wait has already forced.
oldFetched := now.Add(-2 * time.Hour)
oldSnap := &OpenCodeGoUsageSnapshot{
Status: OpenCodeGoUsageStatusOK, FetchedAt: ptr(oldFetched), LastAttemptAt: oldFetched,
}
dueAt, ok = openCodeGoUsageAutoRefreshDueAt(oldSnap, ptr(now), debounce, maxWait)
require.True(t, ok)
require.Equal(t, oldFetched.Add(maxWait), dueAt, "max-wait due time must not be pushed out by the floor")
}
func TestOpenCodeGoUsageGroupSharesStateAcrossSiblings(t *testing.T) {
source := openCodeGoUsageAccount(71)
source.Credentials["api_key"] = "shared-key"
source.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true
source.Extra[OpenCodeGoUsageSnapshotExtraKey] = &OpenCodeGoUsageSnapshot{
Status: OpenCodeGoUsageStatusOK,
Data: &OpenCodeGoUsageData{Rolling: OpenCodeGoUsageWindow{Status: "ok", Percent: 6}},
}
source.UpdatedAt = time.Now().Add(-time.Minute)
sibling := openCodeGoUsageAccount(72)
sibling.Credentials = map[string]any{"base_url": "HTTPS://OPENCODE.AI/ZEN/GO/V1/", "api_key": "shared-key"}
sibling.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true
sibling.UpdatedAt = time.Now()
different := openCodeGoUsageAccount(73)
different.Credentials["api_key"] = "different-key"
repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{
source.ID: source, sibling.ID: sibling, different.ID: different,
}}
svc := newOpenCodeGoUsageTestService(t, repo, &openCodeGoUsageHTTPStub{}, &upstreamBillingProbeSettingRepo{})
state, err := svc.GetState(context.Background(), sibling.ID)
require.NoError(t, err)
require.True(t, state.Eligible)
require.True(t, state.AutoRefreshEnabled)
require.Equal(t, 6.0, state.Snapshot.Data.Rolling.Percent)
differentState, err := svc.GetState(context.Background(), different.ID)
require.NoError(t, err)
require.False(t, differentState.AutoRefreshEnabled)
require.Nil(t, differentState.Snapshot)
newSibling := openCodeGoUsageAccount(74)
newSibling.Credentials = map[string]any{"base_url": "https://opencode.ai/zen/go/v1", "api_key": "shared-key"}
repo.mu.Lock()
repo.accounts[newSibling.ID] = newSibling
repo.mu.Unlock()
newState, err := svc.GetState(context.Background(), newSibling.ID)
require.NoError(t, err)
require.True(t, newState.AutoRefreshEnabled)
require.Equal(t, state.Snapshot, newState.Snapshot)
before := repo.groupResolveCalls.Load()
require.NoError(t, svc.ResolveOpenCodeGoUsageAccounts(context.Background(), []*Account{source, sibling, different, newSibling}))
require.Equal(t, before+1, repo.groupResolveCalls.Load(), "one list batch must issue one group lookup")
}
func TestOpenCodeGoUsageSetAutoRefreshAndSnapshotAreGroupScoped(t *testing.T) {
first := openCodeGoUsageAccount(81)
first.Credentials["api_key"] = "shared-key"
second := openCodeGoUsageAccount(82)
second.Credentials = map[string]any{"base_url": "https://opencode.ai/zen/go/v1/", "api_key": "shared-key"}
different := openCodeGoUsageAccount(83)
different.Credentials["api_key"] = "different-key"
repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{
first.ID: first, second.ID: second, different.ID: different,
}}
svc := newOpenCodeGoUsageTestService(t, repo, &openCodeGoUsageHTTPStub{}, &upstreamBillingProbeSettingRepo{})
state, err := svc.SetAutoRefresh(context.Background(), second.ID, true)
require.NoError(t, err)
require.True(t, state.AutoRefreshEnabled)
require.Equal(t, true, first.Extra[OpenCodeGoUsageAutoRefreshExtraKey])
require.Equal(t, true, second.Extra[OpenCodeGoUsageAutoRefreshExtraKey])
require.NotContains(t, different.Extra, OpenCodeGoUsageAutoRefreshExtraKey)
// A snapshot write must not wipe the auto-refresh switch (pure merge).
now := time.Now().UTC()
_, err = svc.Refresh(context.Background(), first.ID)
require.NoError(t, err)
require.Equal(t, true, first.Extra[OpenCodeGoUsageAutoRefreshExtraKey], "snapshot write must preserve auto_refresh")
require.Equal(t, true, second.Extra[OpenCodeGoUsageAutoRefreshExtraKey], "snapshot write must preserve auto_refresh on siblings")
require.NotNil(t, decodeOpenCodeGoUsageSnapshot(second.Extra), "snapshot must be shared with siblings")
require.Equal(t, decodeOpenCodeGoUsageSnapshot(first.Extra), decodeOpenCodeGoUsageSnapshot(second.Extra))
require.NotNil(t, now)
}
func TestOpenCodeGoUsageRefreshSingleflightAndRunnerDeduplicateSharedGroup(t *testing.T) {
first := openCodeGoUsageAccount(91)
first.Credentials["api_key"] = "shared-key"
first.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true
second := openCodeGoUsageAccount(92)
second.Credentials = map[string]any{"base_url": "https://opencode.ai/zen/go/v1/", "api_key": "shared-key"}
second.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true
repo := &openCodeGoUsageTestRepo{
accounts: map[int64]*Account{first.ID: first, second.ID: second},
due: []Account{*first, *second},
}
settingsRepo := &upstreamBillingProbeSettingRepo{values: map[string]string{
SettingKeyOpenCodeGoUsageSettings: `{"enabled":true,"interval_minutes":15}`,
}}
started := make(chan struct{})
release := make(chan struct{})
var once sync.Once
upstream := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture), beforeResponse: func(*http.Request) {
once.Do(func() { close(started) })
<-release
}}
svc := newOpenCodeGoUsageTestService(t, repo, upstream, settingsRepo)
errs := make(chan error, 2)
go func() { _, err := svc.Refresh(context.Background(), first.ID); errs <- err }()
<-started
// The first caller is now parked in the stub, having loaded the account twice
// (once to build the group key, once inside the singleflight function).
loadsBeforeSecond := repo.getByIDCalls.Load()
go func() { _, err := svc.Refresh(context.Background(), second.ID); errs <- err }()
// Only release the first caller once the second one has loaded its own
// account, which happens immediately before it joins the singleflight group.
require.Eventually(t, func() bool {
return repo.getByIDCalls.Load() > loadsBeforeSecond
}, 5*time.Second, time.Millisecond, "the second caller must reach the singleflight group before the first is released")
close(release)
require.NoError(t, <-errs)
require.NoError(t, <-errs)
require.Equal(t, int64(1), upstream.calls.Load())
require.NotNil(t, decodeOpenCodeGoUsageSnapshot(first.Extra))
require.Equal(t, decodeOpenCodeGoUsageSnapshot(first.Extra), decodeOpenCodeGoUsageSnapshot(second.Extra))
delete(first.Extra, OpenCodeGoUsageSnapshotExtraKey)
delete(second.Extra, OpenCodeGoUsageSnapshotExtraKey)
upstream.beforeResponse = nil
require.NoError(t, svc.RunDue(context.Background()))
require.Equal(t, int64(2), upstream.calls.Load(), "RunDue must issue one request for the shared group")
}
func TestOpenCodeGoUsageRefreshRejectsGroupChangeBeforeUpstreamRequest(t *testing.T) {
account := openCodeGoUsageAccount(94)
base := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{account.ID: account}}
repo := &openCodeGoRefreshPreflightIdentityChangeRepo{openCodeGoUsageTestRepo: base}
upstream := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)}
svc := NewOpenCodeGoUsageService(repo, upstream, NewSettingService(&upstreamBillingProbeSettingRepo{}, nil))
t.Cleanup(svc.Stop)
_, err := svc.Refresh(context.Background(), account.ID)
require.ErrorIs(t, err, ErrOpenCodeGoUsageIdentityChanged)
require.Zero(t, upstream.calls.Load())
require.NotContains(t, account.Extra, OpenCodeGoUsageSnapshotExtraKey)
}
type openCodeGoRefreshPreflightIdentityChangeRepo struct {
*openCodeGoUsageTestRepo
getCalls atomic.Int64
}
func (r *openCodeGoRefreshPreflightIdentityChangeRepo) GetByID(ctx context.Context, id int64) (*Account, error) {
if r.getCalls.Add(1) == 2 {
r.mu.Lock()
r.accounts[id].Credentials["api_key"] = "rotated-before-refresh"
r.mu.Unlock()
}
return r.openCodeGoUsageTestRepo.GetByID(ctx, id)
}
func TestOpenCodeGoUsageRunnerDisablesAutoRefreshAfterIdentityError(t *testing.T) {
account := openCodeGoUsageAccount(14)
account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true
missingProxyID := int64(99)
account.ProxyID = &missingProxyID
account.Proxy = nil
repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{14: account}}
settingsRepo := &upstreamBillingProbeSettingRepo{values: map[string]string{
SettingKeyOpenCodeGoUsageSettings: `{"enabled":true,"interval_minutes":15}`,
}}
upstream := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)}
svc := newOpenCodeGoUsageTestService(t, repo, upstream, settingsRepo)
require.NoError(t, svc.RunDue(context.Background()))
require.Equal(t, int64(1), repo.disableAutoCalls.Load())
require.Equal(t, false, account.Extra[OpenCodeGoUsageAutoRefreshExtraKey])
require.Zero(t, upstream.calls.Load())
require.NoError(t, svc.RunDue(context.Background()))
require.Equal(t, int64(1), repo.disableAutoCalls.Load())
require.Zero(t, upstream.calls.Load())
}
func TestOpenCodeGoUsageRunDueRefreshesDueAccounts(t *testing.T) {
account := openCodeGoUsageAccount(7)
account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true
repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}}
stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)}
settingsRepo := &upstreamBillingProbeSettingRepo{}
require.NoError(t, settingsRepo.Set(context.Background(), SettingKeyOpenCodeGoUsageSettings, `{"enabled":true,"interval_minutes":15}`))
svc := newOpenCodeGoUsageTestService(t, repo, stub, settingsRepo)
require.NoError(t, svc.RunDue(context.Background()))
require.Equal(t, int64(1), stub.calls.Load())
require.Equal(t, OpenCodeGoUsageStatusOK, decodeOpenCodeGoUsageSnapshot(account.Extra).Status)
}
func TestOpenCodeGoUsageRunDueSkipsWhenDisabled(t *testing.T) {
account := openCodeGoUsageAccount(7)
account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true
repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}}
stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)}
svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{})
require.NoError(t, svc.RunDue(context.Background()))
require.Equal(t, int64(0), stub.calls.Load())
}
func TestIsOpenCodeGoUsageAccount(t *testing.T) {
mount := func() *Account {
return &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://opencode.ai/zen/go/v1", "api_key": "k"}}
}
require.True(t, IsOpenCodeGoUsageAccount(mount()))
// trailing slash on the path is allowed
account := mount()
account.Credentials["base_url"] = "https://opencode.ai/zen/go/v1/"
require.True(t, IsOpenCodeGoUsageAccount(account))
// anthropic base URL variant (/zen/go) is allowed
account = mount()
account.Credentials["base_url"] = "https://opencode.ai/zen/go"
require.True(t, IsOpenCodeGoUsageAccount(account))
account = mount()
account.Credentials["base_url"] = "https://opencode.ai/zen/go/"
require.True(t, IsOpenCodeGoUsageAccount(account))
// explicit default port :443 is allowed for both path variants
account = mount()
account.Credentials["base_url"] = "https://opencode.ai:443/zen/go/v1"
require.True(t, IsOpenCodeGoUsageAccount(account))
account = mount()
account.Credentials["base_url"] = "https://opencode.ai:443/zen/go"
require.True(t, IsOpenCodeGoUsageAccount(account))
// non-default port is rejected
account = mount()
account.Credentials["base_url"] = "https://opencode.ai:444/zen/go/v1"
require.False(t, IsOpenCodeGoUsageAccount(account))
// scheme/host/path are case-insensitive
account = mount()
account.Credentials["base_url"] = "HTTPS://OPENCODE.AI/ZEN/GO/V1"
require.True(t, IsOpenCodeGoUsageAccount(account))
// zen paths are rejected (no subscription quota window)
for _, raw := range []string{
"https://opencode.ai/zen/v1", "https://opencode.ai/zen", "https://opencode.ai/zen/v1/",
} {
account = mount()
account.Credentials["base_url"] = raw
require.False(t, IsOpenCodeGoUsageAccount(account), raw)
}
// wrong path
account = mount()
account.Credentials["base_url"] = "https://opencode.ai/v1"
require.False(t, IsOpenCodeGoUsageAccount(account))
// wrong host
account = mount()
account.Credentials["base_url"] = "https://ollama.com/zen/go/v1"
require.False(t, IsOpenCodeGoUsageAccount(account))
// query string rejected
account = mount()
account.Credentials["base_url"] = "https://opencode.ai/zen/go/v1?x=1"
require.False(t, IsOpenCodeGoUsageAccount(account))
// missing base_url
account = mount()
account.Credentials = map[string]any{"api_key": "k"}
require.False(t, IsOpenCodeGoUsageAccount(account))
// non-apikey type
account = mount()
account.Type = "oauth"
require.False(t, IsOpenCodeGoUsageAccount(account))
// mount platforms: every whitelist platform qualifies with the official base URL
for _, platform := range []string{
PlatformOpenAI, PlatformAnthropic, PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax,
} {
account = mount()
account.Platform = platform
require.True(t, IsOpenCodeGoUsageAccount(account), platform)
}
// non-whitelist platforms never qualify, even with the official base URL
for _, platform := range []string{PlatformGemini, PlatformGrok, PlatformAntigravity} {
account = mount()
account.Platform = platform
require.False(t, IsOpenCodeGoUsageAccount(account), platform)
}
// opencode_go platform: eligibility comes from platform + go mode, not base_url
platformAccount := func() *Account {
return &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "k"}}
}
require.True(t, IsOpenCodeGoUsageAccount(platformAccount())) // no base_url at all
account = platformAccount()
account.Credentials["base_url"] = "https://opencode.ai/zen/go" // anthropic variant
require.True(t, IsOpenCodeGoUsageAccount(account))
account = platformAccount()
account.Credentials["base_url"] = "https://relay.example.com/v1" // arbitrary relay
require.True(t, IsOpenCodeGoUsageAccount(account))
account = platformAccount()
account.Credentials["account_mode"] = AccountModeGo
require.True(t, IsOpenCodeGoUsageAccount(account))
// zen mode has no subscription quota window
account = platformAccount()
account.Credentials["account_mode"] = AccountModeZen
require.False(t, IsOpenCodeGoUsageAccount(account))
// opencode_go still requires the apikey type
account = platformAccount()
account.Type = "oauth"
require.False(t, IsOpenCodeGoUsageAccount(account))
}
func TestOpenCodeGoUsageGroupFingerprint(t *testing.T) {
first := openCodeGoUsageAccount(1)
first.Credentials["api_key"] = "shared-key"
second := openCodeGoUsageAccount(2)
second.Credentials = map[string]any{"base_url": "HTTPS://OPENCODE.AI/ZEN/GO/V1/", "api_key": "shared-key"}
// 同一订阅 key 以 opencode_go 平台账号存在:与挂载平台账号必须同组(身份平台无关)
third := openCodeGoUsageAccount(3)
third.Platform = PlatformOpenCodeGo
third.Credentials = map[string]any{"account_mode": AccountModeGo, "api_key": "shared-key"}
fourth := openCodeGoUsageAccount(4)
fourth.Credentials["api_key"] = "other-key"
// zen 模式的 opencode_go 账号不合格,无指纹
zen := openCodeGoUsageAccount(5)
zen.Platform = PlatformOpenCodeGo
zen.Credentials = map[string]any{"account_mode": AccountModeZen, "api_key": "shared-key"}
firstFP, firstOK := openCodeGoUsageGroupFingerprint(first)
secondFP, secondOK := openCodeGoUsageGroupFingerprint(second)
thirdFP, thirdOK := openCodeGoUsageGroupFingerprint(third)
fourthFP, fourthOK := openCodeGoUsageGroupFingerprint(fourth)
_, zenOK := openCodeGoUsageGroupFingerprint(zen)
require.True(t, firstOK)
require.True(t, secondOK)
require.True(t, thirdOK)
require.True(t, fourthOK)
require.False(t, zenOK)
require.Equal(t, firstFP, secondFP, "same api_key across base_url variants must share a group")
require.Equal(t, firstFP, thirdFP, "same api_key across platforms must share a group")
require.NotEqual(t, firstFP, fourthFP, "different api_key must not share a group")
// ineligible accounts have no fingerprint
account := openCodeGoUsageAccount(6)
account.Credentials["base_url"] = "https://opencode.ai/v1"
_, ok := openCodeGoUsageGroupFingerprint(account)
require.False(t, ok)
}
func TestScheduleOpenCodeGoUsageActivityOnlyForOpenCode(t *testing.T) {
deferred := &DeferredService{}
openCode := openCodeGoUsageAccount(1)
openCode.Credentials["api_key"] = "k"
other := openCodeGoUsageAccount(2)
other.Credentials["base_url"] = "https://opencode.ai/v1"
scheduleOpenCodeGoUsageActivity(deferred, openCode)
scheduleOpenCodeGoUsageActivity(deferred, other)
scheduleOpenCodeGoUsageActivity(nil, openCode)
scheduleOpenCodeGoUsageActivity(deferred, nil)
}
func TestOpenCodeGoUsageSettingsDefaultOffAndValidation(t *testing.T) {
repo := &upstreamBillingProbeSettingRepo{}
settingsService := NewSettingService(repo, nil)
settings, err := settingsService.GetOpenCodeGoUsageSettings(context.Background())
require.NoError(t, err)
require.False(t, settings.Enabled)
require.Equal(t, 15, settings.IntervalMinutes)
require.Equal(t, 1, settings.DebounceMinutes)
// below the minimum
err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 1})
require.Error(t, err)
// above the maximum
err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 2000})
require.Error(t, err)
// debounce out of range
err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 30, DebounceMinutes: 61})
require.Error(t, err)
// DebounceMinutes=0 (legacy omit) defaults to 1 on write.
err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 30, DebounceMinutes: 0})
require.NoError(t, err)
settings, err = settingsService.GetOpenCodeGoUsageSettings(context.Background())
require.NoError(t, err)
require.Equal(t, 1, settings.DebounceMinutes)
// valid update round-trips
err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 30, DebounceMinutes: 2})
require.NoError(t, err)
settings, err = settingsService.GetOpenCodeGoUsageSettings(context.Background())
require.NoError(t, err)
require.True(t, settings.Enabled)
require.Equal(t, 30, settings.IntervalMinutes)
require.Equal(t, 2, settings.DebounceMinutes)
// debounce >= interval would make the debounce term unreachable in
// min(lastUsed+debounce, fetchedAt+maxWait), silently ignoring the operator's
// setting, so it is rejected rather than accepted and dropped.
err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 5, DebounceMinutes: 5})
require.Error(t, err, "debounce equal to interval must be rejected")
err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 5, DebounceMinutes: 60})
require.Error(t, err, "debounce greater than interval must be rejected")
err = settingsService.SetOpenCodeGoUsageSettings(context.Background(), &OpenCodeGoUsageSettings{Enabled: true, IntervalMinutes: 6, DebounceMinutes: 5})
require.NoError(t, err, "debounce below interval stays valid")
// Legacy JSON without debounce_minutes defaults to 1.
repo.values[SettingKeyOpenCodeGoUsageSettings] = `{"enabled":true,"interval_minutes":45}`
settings, err = settingsService.GetOpenCodeGoUsageSettings(context.Background())
require.NoError(t, err)
require.Equal(t, 45, settings.IntervalMinutes)
require.Equal(t, 1, settings.DebounceMinutes)
}
func TestOpenCodeGoUsageStateFromAccount(t *testing.T) {
account := openCodeGoUsageAccount(7)
state := OpenCodeGoUsageStateFromAccount(account)
require.True(t, state.Eligible)
require.False(t, state.AutoRefreshEnabled)
require.Nil(t, state.Snapshot)
account.Extra[OpenCodeGoUsageAutoRefreshExtraKey] = true
account.Extra[OpenCodeGoUsageSnapshotExtraKey] = &OpenCodeGoUsageSnapshot{Status: OpenCodeGoUsageStatusOK}
state = OpenCodeGoUsageStateFromAccount(account)
require.True(t, state.AutoRefreshEnabled)
require.NotNil(t, state.Snapshot)
require.Equal(t, OpenCodeGoUsageStatusOK, state.Snapshot.Status)
// ineligible account exposes no managed state
// (anthropic 已是挂载白名单成员,改用非白名单平台验证不合格路径)
account.Platform = PlatformGemini
state = OpenCodeGoUsageStateFromAccount(account)
require.False(t, state.Eligible)
require.Nil(t, state.Snapshot)
}
func TestOpenCodeGoUsageRefreshRejectsIneligibleAccount(t *testing.T) {
account := openCodeGoUsageAccount(7)
account.Credentials["base_url"] = "https://opencode.ai/v1"
repo := &openCodeGoUsageTestRepo{accounts: map[int64]*Account{7: account}}
stub := &openCodeGoUsageHTTPStub{body: []byte(openCodeGoUsageFixture)}
svc := newOpenCodeGoUsageTestService(t, repo, stub, &upstreamBillingProbeSettingRepo{})
_, err := svc.Refresh(context.Background(), 7)
require.Error(t, err)
require.True(t, errors.Is(err, ErrOpenCodeGoUsageAccountInvalid))
require.Equal(t, int64(0), stub.calls.Load())
}