591 lines
22 KiB
Go
591 lines
22 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"codex-helper/internal/cliproxy"
|
|
"codex-helper/internal/security"
|
|
"codex-helper/internal/store"
|
|
)
|
|
|
|
type fakeCPAClient struct {
|
|
mu sync.Mutex
|
|
configured bool
|
|
snapshots map[string]cliproxy.Snapshot
|
|
errors map[string]error
|
|
calls []string
|
|
}
|
|
|
|
func (f *fakeCPAClient) Configured() bool { return f.configured }
|
|
|
|
func (f *fakeCPAClient) Snapshot(_ context.Context, authIndex string) (cliproxy.Snapshot, error) {
|
|
return f.snapshot(authIndex)
|
|
}
|
|
|
|
func (f *fakeCPAClient) snapshot(authIndex string) (cliproxy.Snapshot, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.calls = append(f.calls, authIndex)
|
|
if err := f.errors[authIndex]; err != nil {
|
|
return cliproxy.Snapshot{}, err
|
|
}
|
|
snapshot, ok := f.snapshots[authIndex]
|
|
if !ok {
|
|
return cliproxy.Snapshot{}, errors.New("auth 不存在")
|
|
}
|
|
return snapshot, nil
|
|
}
|
|
|
|
func testSnapshot(authIndex string) cliproxy.Snapshot {
|
|
email := authIndex + "@example.com"
|
|
plan := "plus"
|
|
lifetime := int64(1234)
|
|
peak := int64(321)
|
|
return cliproxy.Snapshot{
|
|
Auth: cliproxy.AuthFile{AuthIndex: authIndex, Label: "CPA 标签", Email: &email, AccountID: "acct-1", PlanType: &plan},
|
|
Limits: []cliproxy.Limit{{LimitID: "codex", WindowType: "primary", UsedPercent: 12.5, WindowDurationMinutes: 300, ResetsAt: time.Now().Add(time.Hour).Unix(), PlanType: &plan}},
|
|
Summary: cliproxy.UsageSummary{LifetimeTokens: &lifetime, PeakDailyTokens: &peak},
|
|
Usage: []cliproxy.UsagePoint{{Date: time.Now().UTC().Format("2006-01-02"), TotalTokens: 100}},
|
|
ProfileAvailable: true,
|
|
UsageAvailable: true,
|
|
FetchedAt: time.Now().UTC(),
|
|
}
|
|
}
|
|
|
|
type blockingCPAClient struct {
|
|
started chan struct{}
|
|
release chan struct{}
|
|
active atomic.Int32
|
|
maximum atomic.Int32
|
|
}
|
|
|
|
func (f *blockingCPAClient) Configured() bool { return true }
|
|
|
|
func (f *blockingCPAClient) Snapshot(_ context.Context, authIndex string) (cliproxy.Snapshot, error) {
|
|
active := f.active.Add(1)
|
|
defer f.active.Add(-1)
|
|
for {
|
|
maximum := f.maximum.Load()
|
|
if active <= maximum || f.maximum.CompareAndSwap(maximum, active) {
|
|
break
|
|
}
|
|
}
|
|
f.started <- struct{}{}
|
|
<-f.release
|
|
return testSnapshot(authIndex), nil
|
|
}
|
|
|
|
func configureTestCPA(a *App, authIndexes ...string) *fakeCPAClient {
|
|
client := &fakeCPAClient{configured: true, snapshots: map[string]cliproxy.Snapshot{}, errors: map[string]error{}}
|
|
for _, authIndex := range authIndexes {
|
|
client.snapshots[authIndex] = testSnapshot(authIndex)
|
|
}
|
|
a.cpa = client
|
|
return client
|
|
}
|
|
|
|
func authenticatedRequest(t *testing.T, a *App, method, path, body string) *http.Request {
|
|
t.Helper()
|
|
if err := a.store.Set("initialized", "true"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session := "test-session"
|
|
if _, err := a.store.DB.Exec("INSERT OR IGNORE INTO sessions(token_hash,expires_at,created_at) VALUES(?,?,?)", security.HashToken(session), time.Now().Add(time.Hour).Unix(), time.Now().Unix()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
request := httptest.NewRequest(method, path, strings.NewReader(body))
|
|
request.AddCookie(&http.Cookie{Name: "session", Value: session})
|
|
if method != http.MethodGet && method != http.MethodHead {
|
|
request.Header.Set("X-Requested-With", "codex-helper")
|
|
}
|
|
return request
|
|
}
|
|
|
|
func TestSystemStatusUsesCPAFlagAndRejectsNonGET(t *testing.T) {
|
|
a := newReminderTestApp(t)
|
|
a.cpa = &fakeCPAClient{configured: true}
|
|
recorder := httptest.NewRecorder()
|
|
a.api(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/system/status", nil))
|
|
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"cpa":true`) || strings.Contains(recorder.Body.String(), "appServer") {
|
|
t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
recorder = httptest.NewRecorder()
|
|
a.api(recorder, httptest.NewRequest(http.MethodPost, "/api/v1/system/status", nil))
|
|
if recorder.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSystemStatusReturnsBuildVersion(t *testing.T) {
|
|
a := newReminderTestApp(t)
|
|
originalVersion := Version
|
|
Version = "1.2.3-test"
|
|
t.Cleanup(func() { Version = originalVersion })
|
|
recorder := httptest.NewRecorder()
|
|
a.api(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/system/status", nil))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var body struct {
|
|
Version string `json:"version"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Version != "1.2.3-test" {
|
|
t.Fatalf("version = %q", body.Version)
|
|
}
|
|
}
|
|
|
|
func TestAnonymousOverviewRedactsCPAAndIdentityData(t *testing.T) {
|
|
a := newReminderTestApp(t)
|
|
if err := a.store.Set("initialized", "true"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
account, err := a.store.CreateAccountWithVisibility("公开账号", "private-cpa-index", "personal", true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
email, plan, authMode := "owner@example.com", "plus", "cliproxyapi"
|
|
if err := a.store.UpdateAccount(account.ID, &email, &plan, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a.addRuntime(account.ID)
|
|
a.runtime(account.ID).dash = Dashboard{
|
|
AccountID: account.ID,
|
|
DisplayName: account.DisplayName,
|
|
Account: AccountView{Email: &email, PlanType: &plan, AuthMode: &authMode, Connected: true},
|
|
Limits: []LimitBucket{},
|
|
Usage: []UsagePoint{},
|
|
ResetCredits: &ResetCreditsSummary{AvailableCount: 2, ExpiresAt: []int64{1784246400}},
|
|
FetchedAt: time.Now().Unix(),
|
|
LastError: "private upstream detail",
|
|
}
|
|
|
|
accountsRecorder := httptest.NewRecorder()
|
|
a.api(accountsRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/accounts", nil))
|
|
if accountsRecorder.Code != http.StatusOK || strings.Contains(accountsRecorder.Body.String(), "private-cpa-index") || strings.Contains(accountsRecorder.Body.String(), "owner@example.com") {
|
|
t.Fatalf("anonymous accounts status=%d body=%s", accountsRecorder.Code, accountsRecorder.Body.String())
|
|
}
|
|
|
|
dashboardRecorder := httptest.NewRecorder()
|
|
a.api(dashboardRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/dashboard?accountId="+strconvID(account.ID), nil))
|
|
if dashboardRecorder.Code != http.StatusOK {
|
|
t.Fatalf("dashboard status=%d body=%s", dashboardRecorder.Code, dashboardRecorder.Body.String())
|
|
}
|
|
var dashboard Dashboard
|
|
if err := json.Unmarshal(dashboardRecorder.Body.Bytes(), &dashboard); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if dashboard.Account.Email != nil || dashboard.Account.AuthMode != nil || dashboard.LastError != "" || dashboard.ResetCredits == nil || dashboard.ResetCredits.AvailableCount != 2 {
|
|
t.Fatalf("anonymous dashboard = %#v", dashboard)
|
|
}
|
|
|
|
for _, target := range []struct {
|
|
method string
|
|
path string
|
|
}{
|
|
{http.MethodGet, "/api/v1/settings/general"},
|
|
{http.MethodPost, "/api/v1/accounts"},
|
|
{http.MethodPost, "/api/v1/accounts/" + strconvID(account.ID) + "/sync"},
|
|
} {
|
|
recorder := httptest.NewRecorder()
|
|
a.api(recorder, httptest.NewRequest(target.method, target.path, nil))
|
|
if recorder.Code != http.StatusUnauthorized {
|
|
t.Fatalf("anonymous %s %s status=%d", target.method, target.path, recorder.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAccountVisibilityFiltersAnonymousOverviewAndCanBeUpdated(t *testing.T) {
|
|
a := newReminderTestApp(t)
|
|
if err := a.store.Set("initialized", "true"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
publicAccount, err := a.store.CreateAccountWithVisibility("公开账号", "public-auth", "team", true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
privateAccount, err := a.store.CreateAccount("私有账号", "private-auth", "personal")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a.addRuntime(publicAccount.ID)
|
|
a.addRuntime(privateAccount.ID)
|
|
|
|
recorder := httptest.NewRecorder()
|
|
a.api(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/accounts", nil))
|
|
var accounts []store.Account
|
|
if recorder.Code != http.StatusOK || json.Unmarshal(recorder.Body.Bytes(), &accounts) != nil || len(accounts) != 1 || accounts[0].ID != publicAccount.ID {
|
|
t.Fatalf("anonymous accounts status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
privateDashboard := httptest.NewRecorder()
|
|
a.api(privateDashboard, httptest.NewRequest(http.MethodGet, "/api/v1/dashboard?accountId="+strconvID(privateAccount.ID), nil))
|
|
if privateDashboard.Code != http.StatusNotFound {
|
|
t.Fatalf("private dashboard status=%d", privateDashboard.Code)
|
|
}
|
|
|
|
update := httptest.NewRecorder()
|
|
a.api(update, authenticatedRequest(t, a, http.MethodPut, "/api/v1/accounts/"+strconvID(privateAccount.ID), `{"displayName":"私有账号","expectedKind":"personal","publicVisible":true}`))
|
|
if update.Code != http.StatusOK {
|
|
t.Fatalf("update status=%d body=%s", update.Code, update.Body.String())
|
|
}
|
|
updated, err := a.store.Account(privateAccount.ID)
|
|
if err != nil || !updated.PublicVisible {
|
|
t.Fatalf("updated=%#v err=%v", updated, err)
|
|
}
|
|
}
|
|
|
|
func TestDashboardSerializesNilListsAsEmptyArrays(t *testing.T) {
|
|
a := newReminderTestApp(t)
|
|
account, err := a.store.CreateAccount("账号", "auth-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a.addRuntime(account.ID)
|
|
rt := a.runtime(account.ID)
|
|
rt.dash.Limits = nil
|
|
rt.dash.Usage = nil
|
|
recorder := httptest.NewRecorder()
|
|
a.api(recorder, authenticatedRequest(t, a, http.MethodGet, "/api/v1/dashboard?accountId="+strconvID(account.ID), ""))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var body struct {
|
|
Limits []LimitBucket `json:"limits"`
|
|
Usage []UsagePoint `json:"usage"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Limits == nil || body.Usage == nil {
|
|
t.Fatalf("nil lists: %s", recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestCreateAccountRequiresAuthIndexAndImmediatelySyncs(t *testing.T) {
|
|
a := newReminderTestApp(t)
|
|
configureTestCPA(a, "auth-1")
|
|
|
|
missing := httptest.NewRecorder()
|
|
a.api(missing, authenticatedRequest(t, a, http.MethodPost, "/api/v1/accounts", `{"displayName":"A"}`))
|
|
if missing.Code != http.StatusBadRequest {
|
|
t.Fatalf("missing authIndex status = %d body = %s", missing.Code, missing.Body.String())
|
|
}
|
|
|
|
recorder := httptest.NewRecorder()
|
|
a.api(recorder, authenticatedRequest(t, a, http.MethodPost, "/api/v1/accounts", `{"authIndex":"auth-1","expectedKind":"personal","publicVisible":true}`))
|
|
if recorder.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var created store.Account
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &created); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if created.AuthIndex != "auth-1" || created.DisplayName != "CPA 标签" || !created.Connected || created.Email == nil {
|
|
t.Fatalf("created = %#v", created)
|
|
}
|
|
rt := a.runtime(created.ID)
|
|
if rt == nil || rt.dash.Stale || rt.dash.Account.AuthMode == nil || *rt.dash.Account.AuthMode != "cliproxyapi" || len(rt.dash.Limits) != 1 || rt.dash.Summary.PeakDailyTokens == nil || *rt.dash.Summary.PeakDailyTokens != 100 {
|
|
t.Fatalf("dashboard = %#v", rt)
|
|
}
|
|
var usageRows int
|
|
if err := a.store.DB.QueryRow("SELECT COUNT(*) FROM daily_usage WHERE account_id=?", created.ID).Scan(&usageRows); err != nil || usageRows != 1 {
|
|
t.Fatalf("usage rows = %d err = %v", usageRows, err)
|
|
}
|
|
}
|
|
|
|
func TestCreateAccountRejectsDuplicateAuthIndex(t *testing.T) {
|
|
a := newReminderTestApp(t)
|
|
configureTestCPA(a, "auth-1")
|
|
first := httptest.NewRecorder()
|
|
a.api(first, authenticatedRequest(t, a, http.MethodPost, "/api/v1/accounts", `{"displayName":"A","authIndex":"auth-1"}`))
|
|
if first.Code != http.StatusCreated {
|
|
t.Fatalf("first status = %d body = %s", first.Code, first.Body.String())
|
|
}
|
|
second := httptest.NewRecorder()
|
|
a.api(second, authenticatedRequest(t, a, http.MethodPost, "/api/v1/accounts", `{"displayName":"B","authIndex":"auth-1"}`))
|
|
if second.Code != http.StatusConflict {
|
|
t.Fatalf("second status = %d body = %s", second.Code, second.Body.String())
|
|
}
|
|
accounts, _ := a.store.Accounts()
|
|
if len(accounts) != 1 {
|
|
t.Fatalf("accounts = %#v", accounts)
|
|
}
|
|
}
|
|
|
|
func TestFailedCreateDoesNotPersistAccount(t *testing.T) {
|
|
a := newReminderTestApp(t)
|
|
client := configureTestCPA(a)
|
|
client.errors["bad-auth"] = errors.New("CPA upstream unavailable")
|
|
recorder := httptest.NewRecorder()
|
|
a.api(recorder, authenticatedRequest(t, a, http.MethodPost, "/api/v1/accounts", `{"displayName":"A","authIndex":"bad-auth"}`))
|
|
if recorder.Code != http.StatusBadGateway {
|
|
t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
accounts, _ := a.store.Accounts()
|
|
if len(accounts) != 0 || len(a.runtimes) != 0 {
|
|
t.Fatalf("partial account persisted: accounts=%#v runtimes=%d", accounts, len(a.runtimes))
|
|
}
|
|
}
|
|
|
|
func TestSyncSuccessAndFailureUpdatesStaleState(t *testing.T) {
|
|
a := newReminderTestApp(t)
|
|
client := configureTestCPA(a, "auth-1")
|
|
account, err := a.store.CreateAccount("账号", "auth-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a.addRuntime(account.ID)
|
|
if err := a.syncAccount(context.Background(), account.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rt := a.runtime(account.ID)
|
|
if rt.dash.Stale || rt.dash.LastError != "" {
|
|
t.Fatalf("successful dashboard = %#v", rt.dash)
|
|
}
|
|
client.errors["auth-1"] = errors.New("CPA upstream unavailable")
|
|
if err := a.syncAccount(context.Background(), account.ID); err == nil {
|
|
t.Fatal("failed sync unexpectedly succeeded")
|
|
}
|
|
if !rt.dash.Stale || rt.dash.LastError != "CPA upstream unavailable" || len(rt.dash.Limits) != 1 {
|
|
t.Fatalf("stale dashboard = %#v", rt.dash)
|
|
}
|
|
}
|
|
|
|
func TestSyncPreservesUsageWhenProfileBucketsAreUnavailable(t *testing.T) {
|
|
a := newReminderTestApp(t)
|
|
client := configureTestCPA(a, "auth-1")
|
|
account, err := a.store.CreateAccount("账号", "auth-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a.addRuntime(account.ID)
|
|
if err := a.syncAccount(context.Background(), account.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
withoutProfile := client.snapshots["auth-1"]
|
|
updatedLifetime := int64(9999)
|
|
withoutProfile.ProfileAvailable = true
|
|
withoutProfile.UsageAvailable = false
|
|
withoutProfile.Summary = cliproxy.UsageSummary{LifetimeTokens: &updatedLifetime}
|
|
withoutProfile.Usage = nil
|
|
withoutProfile.Limits[0].UsedPercent = 55
|
|
client.snapshots["auth-1"] = withoutProfile
|
|
if err := a.syncAccount(context.Background(), account.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dashboard := a.runtime(account.ID).dash
|
|
if len(dashboard.Limits) != 1 || dashboard.Limits[0].UsedPercent != 55 || dashboard.Summary.LifetimeTokens == nil || *dashboard.Summary.LifetimeTokens != 9999 || len(dashboard.Usage) != 1 || dashboard.CurrentCycle == nil {
|
|
t.Fatalf("dashboard = %#v", dashboard)
|
|
}
|
|
}
|
|
|
|
func TestUpdateAuthIndexFailureKeepsOldBinding(t *testing.T) {
|
|
a := newReminderTestApp(t)
|
|
client := configureTestCPA(a, "old-auth")
|
|
client.errors["new-auth"] = errors.New("new auth unavailable")
|
|
account, err := a.store.CreateAccount("账号", "old-auth")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a.addRuntime(account.ID)
|
|
if err := a.syncAccount(context.Background(), account.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
path := "/api/v1/accounts/" + strconvID(account.ID)
|
|
a.api(recorder, authenticatedRequest(t, a, http.MethodPut, path, `{"displayName":"新名称","authIndex":"new-auth"}`))
|
|
if recorder.Code != http.StatusBadGateway {
|
|
t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
stored, err := a.store.Account(account.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stored.AuthIndex != "old-auth" || stored.DisplayName != "账号" || a.runtime(account.ID).dash.DisplayName != "账号" {
|
|
t.Fatalf("half-updated account=%#v dashboard=%#v", stored, a.runtime(account.ID).dash)
|
|
}
|
|
}
|
|
|
|
func TestUpdateAuthIndexClearsPreviousIdentityHistory(t *testing.T) {
|
|
a := newReminderTestApp(t)
|
|
client := configureTestCPA(a, "old-auth", "new-auth")
|
|
newSnapshot := client.snapshots["new-auth"]
|
|
newSnapshot.Usage = []cliproxy.UsagePoint{{Date: "2026-08-15", TotalTokens: 999}}
|
|
client.snapshots["new-auth"] = newSnapshot
|
|
account, err := a.store.CreateAccount("账号", "old-auth")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a.addRuntime(account.ID)
|
|
if err := a.syncAccount(context.Background(), account.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := a.store.DB.Exec(`INSERT INTO notifications
|
|
(dedupe_key,channel,kind,status,scheduled_at,body) VALUES(?, 'configured', 'after', 'sent', 1, '{}')`, strconvID(account.ID)+":codex:primary:1:after"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
recorder := httptest.NewRecorder()
|
|
path := "/api/v1/accounts/" + strconvID(account.ID)
|
|
a.api(recorder, authenticatedRequest(t, a, http.MethodPut, path, `{"displayName":"新身份","authIndex":"new-auth"}`))
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var usageRows, limitRows, notificationRows int
|
|
if err := a.store.DB.QueryRow("SELECT COUNT(*) FROM daily_usage WHERE account_id=?", account.ID).Scan(&usageRows); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := a.store.DB.QueryRow("SELECT COUNT(*) FROM limit_snapshots WHERE account_id=?", account.ID).Scan(&limitRows); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := a.store.DB.QueryRow("SELECT COUNT(*) FROM notifications WHERE dedupe_key GLOB ?", strconvID(account.ID)+":*").Scan(¬ificationRows); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var tokens int64
|
|
if err := a.store.DB.QueryRow("SELECT total_tokens FROM daily_usage WHERE account_id=? AND date='2026-08-15'", account.ID).Scan(&tokens); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if usageRows != 1 || limitRows != 1 || notificationRows != 0 || tokens != 999 {
|
|
t.Fatalf("rows usage=%d limits=%d notifications=%d tokens=%d", usageRows, limitRows, notificationRows, tokens)
|
|
}
|
|
}
|
|
|
|
func TestPublicAccountsDoNotExposeAuthIndex(t *testing.T) {
|
|
a := newReminderTestApp(t)
|
|
if err := a.store.Set("initialized", "true"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
account, err := a.store.CreateAccountWithVisibility("公开", "secret-auth-index", "any", true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a.addRuntime(account.ID)
|
|
recorder := httptest.NewRecorder()
|
|
a.api(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/accounts", nil))
|
|
if recorder.Code != http.StatusOK || strings.Contains(recorder.Body.String(), "secret-auth-index") || strings.Contains(recorder.Body.String(), "authIndex") {
|
|
t.Fatalf("status = %d body = %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestDeviceLoginAndLogoutRoutesAreRemoved(t *testing.T) {
|
|
a := newReminderTestApp(t)
|
|
account, err := a.store.CreateAccount("账号", "auth-1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a.addRuntime(account.ID)
|
|
for _, suffix := range []string{"/login/device", "/logout"} {
|
|
recorder := httptest.NewRecorder()
|
|
path := "/api/v1/accounts/" + strconvID(account.ID) + suffix
|
|
a.api(recorder, authenticatedRequest(t, a, http.MethodPost, path, ""))
|
|
if recorder.Code != http.StatusNotFound {
|
|
t.Fatalf("%s status = %d body = %s", suffix, recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCurrentTokenCycleUsesLongestWindowAndFiltersDailyUsage(t *testing.T) {
|
|
now := time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC)
|
|
reset := time.Date(2026, time.August, 15, 0, 0, 0, 0, time.UTC)
|
|
cycle := currentTokenCycle(
|
|
[]LimitBucket{{LimitID: "codex", WindowType: "primary", WindowDurationMinutes: 300, ResetsAt: now.Add(5 * time.Hour).Unix()}, {LimitID: "codex", WindowType: "secondary", WindowDurationMinutes: 7 * 24 * 60, ResetsAt: reset.Unix()}},
|
|
[]UsagePoint{{Date: "2026-08-07", TotalTokens: 50}, {Date: "2026-08-08", TotalTokens: 100}, {Date: "2026-08-10", TotalTokens: 200}, {Date: "2026-08-14", TotalTokens: 300}, {Date: "2026-08-15", TotalTokens: 400}},
|
|
now.Unix(),
|
|
)
|
|
if cycle == nil || cycle.WindowType != "secondary" || cycle.TotalTokens != 600 {
|
|
t.Fatalf("cycle = %#v", cycle)
|
|
}
|
|
}
|
|
|
|
func TestCurrentTokenCycleRequiresAValidFutureResetWindow(t *testing.T) {
|
|
cycle := currentTokenCycle(
|
|
[]LimitBucket{{WindowDurationMinutes: 0, ResetsAt: 1}},
|
|
[]UsagePoint{{Date: "2026-08-14", TotalTokens: 100}},
|
|
time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC).Unix(),
|
|
)
|
|
if cycle != nil {
|
|
t.Fatalf("cycle = %#v; want nil", cycle)
|
|
}
|
|
}
|
|
|
|
func TestPeakDailyTokensUsesOnlyTheCurrentTokenCycle(t *testing.T) {
|
|
now := time.Date(2026, time.August, 14, 12, 0, 0, 0, time.UTC)
|
|
cycle := &TokenCycle{StartedAt: time.Date(2026, time.August, 8, 0, 0, 0, 0, time.UTC).Unix(), ResetsAt: time.Date(2026, time.August, 15, 0, 0, 0, 0, time.UTC).Unix()}
|
|
peak := peakDailyTokensForCycle(cycle, []UsagePoint{
|
|
{Date: "2026-08-07", TotalTokens: 900},
|
|
{Date: "2026-08-08", TotalTokens: 100},
|
|
{Date: "2026-08-10", TotalTokens: 200},
|
|
{Date: "2026-08-14", TotalTokens: 300},
|
|
{Date: "2026-08-15", TotalTokens: 800},
|
|
}, now.Unix())
|
|
if peak == nil || *peak != 300 {
|
|
t.Fatalf("peak = %v; want 300", peak)
|
|
}
|
|
}
|
|
|
|
func TestSyncAllUsesBoundedConcurrency(t *testing.T) {
|
|
a := newReminderTestApp(t)
|
|
client := &blockingCPAClient{started: make(chan struct{}, 6), release: make(chan struct{})}
|
|
a.cpa = client
|
|
for i := 0; i < 6; i++ {
|
|
account, err := a.store.CreateAccount(fmt.Sprintf("账号%d", i), fmt.Sprintf("auth-%d", i))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a.addRuntime(account.ID)
|
|
}
|
|
done := make(chan struct{})
|
|
go func() {
|
|
a.syncAll(context.Background())
|
|
close(done)
|
|
}()
|
|
for i := 0; i < 4; i++ {
|
|
select {
|
|
case <-client.started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("four account syncs did not start concurrently")
|
|
}
|
|
}
|
|
select {
|
|
case <-client.started:
|
|
t.Fatal("more than four account syncs started before a slot was released")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
close(client.release)
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("syncAll did not finish")
|
|
}
|
|
if maximum := client.maximum.Load(); maximum != 4 {
|
|
t.Fatalf("maximum concurrency = %d; want 4", maximum)
|
|
}
|
|
}
|
|
|
|
func TestAutomaticSyncIntervalIsFixedAtFiveMinutes(t *testing.T) {
|
|
a := newReminderTestApp(t)
|
|
if err := a.store.SetJSON("general", GeneralSettings{SyncMinutes: 60, RetentionDays: 90, BeforeMinutes: 30}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := a.general().SyncMinutes; got != automaticSyncMinutes || automaticSyncInterval != 5*time.Minute {
|
|
t.Fatalf("sync minutes = %d interval = %s", got, automaticSyncInterval)
|
|
}
|
|
}
|
|
|
|
func strconvID(id int64) string {
|
|
return fmt.Sprintf("%d", id)
|
|
}
|