mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 20:12:39 +08:00
1010 lines
43 KiB
Go
1010 lines
43 KiB
Go
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())
|
||
}
|