Files
wuxu 8997886eef
Frontend / quality (push) Canceled after 0s
feat: migrate account usage to CLIProxyAPI
2026-09-08 09:56:08 +08:00

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(&notificationRows); 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)
}