gcli2api / internal /scheduler /activity_test.go
a3216's picture
chore: 同步到上游 1.12.0-panel + 凭证同步/独立启动器/保活
6d60378 verified
Raw History Blame Contribute Delete
21.5 kB
package scheduler
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/linguo2625469/workbuddy2api-panel/internal/auth"
"github.com/linguo2625469/workbuddy2api-panel/internal/pool"
"github.com/linguo2625469/workbuddy2api-panel/internal/upstream"
)
// fastActivity 关闭活跃上报账号间限速,避免测试白等 800ms。
func fastActivity(t *testing.T) {
t.Helper()
old := activityAccountDelay
activityAccountDelay = 0
t.Cleanup(func() { activityAccountDelay = old })
}
// reportStub 记录 /v2/report 调用次数与 userId。
type reportStub struct {
calls atomic.Int32
uids atomic.Int32
bodies atomic.Int32
}
func (s *reportStub) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/report" {
s.calls.Add(1)
uid := r.Header.Get("X-User-Id")
if uid != "" {
s.uids.Add(1)
}
s.bodies.Add(1) // 标记收到 body(断言数组含 userId 在 upstream 包单测覆盖)
w.Write([]byte(`{"code":0,"msg":"OK"}`))
return
}
http.Error(w, "not found", 404)
})
}
// TestRunActivityNowReportsEachAccount 遍历池内每个可用账号上报一次。
func TestRunActivityNowReportsEachAccount(t *testing.T) {
fastActivity(t)
stub := &reportStub{}
srv := httptest.NewServer(stub.handler())
defer srv.Close()
p := pool.New("")
p.Add(&auth.Auth{UID: "u1", AccessToken: "at", RefreshToken: "rt", ExpiresAt: 9999999999})
p.Add(&auth.Auth{UID: "u2", AccessToken: "at", RefreshToken: "rt", ExpiresAt: 9999999999})
up := &upstream.Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL}
s := New(Config{Pool: p, Upstream: up})
s.RunActivityNow()
if n := stub.calls.Load(); n != 2 {
t.Errorf("report calls=%d want 2(每号上报一次)", n)
}
if n := stub.uids.Load(); n != 2 {
t.Errorf("report with X-User-Id=%d want 2(每号必带 userId)", n)
}
}
// TestRunActivityNowSkipsDisabledAndNoToken 禁用账号与无 token 账号跳过。
func TestRunActivityNowSkipsDisabledAndNoToken(t *testing.T) {
fastActivity(t)
stub := &reportStub{}
srv := httptest.NewServer(stub.handler())
defer srv.Close()
p := pool.New("")
p.Add(&auth.Auth{UID: "ok", AccessToken: "at", RefreshToken: "rt", ExpiresAt: 9999999999})
p.Add(&auth.Auth{UID: "dis", AccessToken: "at", RefreshToken: "rt", ExpiresAt: 9999999999})
p.Add(&auth.Auth{UID: "notoken", AccessToken: "", RefreshToken: "", ExpiresAt: 9999999999})
p.Disable("dis", "test")
up := &upstream.Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL}
s := New(Config{Pool: p, Upstream: up})
s.RunActivityNow()
if n := stub.calls.Load(); n != 1 {
t.Errorf("report calls=%d want 1(仅 ok 账号)", n)
}
}
// TestRunActivityNowErrorDoesNotAbort 单账号上报失败不影响后续遍历。
func TestRunActivityNowErrorDoesNotAbort(t *testing.T) {
fastActivity(t)
var okCalls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v2/report" {
// streak 自检等非 report 请求直接回 200(不带计数,只统计 /v2/report)。
w.Write([]byte(`{"code":0,"data":{}}`))
return
}
uid := r.Header.Get("X-User-Id")
if uid == "fail" {
w.WriteHeader(500)
w.Write([]byte(`boom`))
return
}
okCalls.Add(1)
w.Write([]byte(`{"code":0}`))
}))
defer srv.Close()
p := pool.New("")
p.Add(&auth.Auth{UID: "fail", AccessToken: "at", RefreshToken: "rt", ExpiresAt: 9999999999})
p.Add(&auth.Auth{UID: "ok", AccessToken: "at", RefreshToken: "rt", ExpiresAt: 9999999999})
up := &upstream.Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL}
s := New(Config{Pool: p, Upstream: up})
s.RunActivityNow() // 不应 panic
if n := okCalls.Load(); n != 1 {
t.Errorf("ok account report calls=%d want 1(失败账号不影响后续遍历)", n)
}
}
// ---------------------------------------------------------------------------
// P1:活跃上报后回读 streak 自检
// ---------------------------------------------------------------------------
// activityStreakStub 模拟 /v2/report(200 成功)+ /activity/growth/streak(days 可配)。
type activityStreakStub struct {
reportCalls atomic.Int32
days int // streak 返回的连登天数
streakErr bool // 让 streak 返回 500
noUserId bool // 待测:上报不带 userId(服务端 200 但静默丢弃)
streakHits atomic.Int32
}
func (s *activityStreakStub) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v2/report":
s.reportCalls.Add(1)
w.Write([]byte(`{"code":0,"msg":"OK"}`))
case "/activity/growth/streak":
s.streakHits.Add(1)
if s.streakErr {
w.WriteHeader(500)
w.Write([]byte(`boom`))
return
}
fmt.Fprintf(w, `{"code":0,"data":{"streak":{"days":%d}}}`, s.days)
default:
http.Error(w, "not found", 404)
}
})
}
// activityStreakScheduler 构造带 streak 自检 stub 的调度器。
func activityStreakScheduler(t *testing.T, srv *httptest.Server) (*Scheduler, *pool.Pool) {
t.Helper()
p := pool.New("")
p.Add(&auth.Auth{UID: "u1", AccessToken: "at", RefreshToken: "rt", ExpiresAt: 9999999999})
up := &upstream.Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL}
return New(Config{Pool: p, Upstream: up}), p
}
// TestRunActivityNowSelfCheckDaysNormal 上报成功后回读 streak:days>=1 → 无告警。
func TestRunActivityNowSelfCheckDaysNormal(t *testing.T) {
fastActivity(t)
stub := &activityStreakStub{days: 3}
srv := httptest.NewServer(stub.handler())
defer srv.Close()
p := pool.New("")
p.Add(&auth.Auth{UID: "u1", AccessToken: "at", RefreshToken: "rt", ExpiresAt: 9999999999})
up := &upstream.Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL}
s := New(Config{Pool: p, Upstream: up})
s.RunActivityNow()
if stub.reportCalls.Load() != 1 || stub.streakHits.Load() != 1 {
t.Errorf("report_calls=%d streak_hits=%d want 1/1", stub.reportCalls.Load(), stub.streakHits.Load())
}
// days>=1:checkActivityStreak 返回 false(无可疑)。
if s.checkActivityStreak(p.AuthByUID("u1")) {
t.Fatal("days>=1 不告警")
}
}
// TestRunActivityNowSelfCheckSilentDrop 上报 200 但 streak.days=0 → 告警(silent drop?)。
func TestRunActivityNowSelfCheckSilentDrop(t *testing.T) {
fastActivity(t)
stub := &activityStreakStub{days: 0}
srv := httptest.NewServer(stub.handler())
defer srv.Close()
p := pool.New("")
p.Add(&auth.Auth{UID: "u1", AccessToken: "at", RefreshToken: "rt", ExpiresAt: 9999999999})
up := &upstream.Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL}
s := New(Config{Pool: p, Upstream: up})
if !s.checkActivityStreak(p.AuthByUID("u1")) {
t.Fatal("days=0 应告警(上报 200 但 silent drop?)")
}
}
// TestRunActivityNowSelfCheckGETFailure 回读 GET 失败 → 告警但不影响主流程(上报已成功)。
func TestRunActivityNowSelfCheckGETFailure(t *testing.T) {
fastActivity(t)
stub := &activityStreakStub{days: 1, streakErr: true}
srv := httptest.NewServer(stub.handler())
defer srv.Close()
p := pool.New("")
p.Add(&auth.Auth{UID: "u1", AccessToken: "at", RefreshToken: "rt", ExpiresAt: 9999999999})
up := &upstream.Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL}
s := New(Config{Pool: p, Upstream: up})
// 直接断言 checkActivityStreak:GET 失败 → 告警。
if !s.checkActivityStreak(p.AuthByUID("u1")) {
t.Fatal("streak GET 失败应告警(reported but unverifiable)")
}
// 回读是只读 oracle:GET 失败不影响已发生的上报本轮走通(遍历继续)。
if stub.streakHits.Load() != 1 {
t.Errorf("streak_hits=%d want 1(GET 失败也打了 streak 请求)", stub.streakHits.Load())
}
}
// TestRunActivityNowSkipsSelfCheckOnReportFail 上报失败 → 不跑自检(SKIP,无意义回读)。
func TestRunActivityNowSkipsSelfCheckOnReportFail(t *testing.T) {
fastActivity(t)
var streakHits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v2/report":
w.WriteHeader(500)
w.Write([]byte(`boom`))
case "/activity/growth/streak":
streakHits.Add(1)
w.Write([]byte(`{"code":0,"data":{"streak":{"days":1}}}`))
}
}))
defer srv.Close()
p := pool.New("")
p.Add(&auth.Auth{UID: "u1", AccessToken: "at", RefreshToken: "rt", ExpiresAt: 9999999999})
up := &upstream.Client{HTTP: srv.Client(), ChatBaseCN: srv.URL, BillingBaseCN: srv.URL}
s := New(Config{Pool: p, Upstream: up})
s.RunActivityNow() // 不上报成功 → 无自检
if streakHits.Load() != 0 {
t.Errorf("streak hits=%d want 0(上报失败不跑自检)", streakHits.Load())
}
}
// TestRunCheckinDoesNotTriggerTravel 签到收尾不再跑旅行(旅行已剥离为独立排程)。
func TestRunCheckinDoesNotTriggerTravel(t *testing.T) {
fastTravel(t)
stub := &travelStub{buddy: "null"}
srv := billingAndGrowthServer(stub)
defer srv.Close()
s, _ := newTravelScheduler(t, srv, "u1")
s.RunCheckinNow()
// 签到不再顺带跑旅行:buddy/info 不应被调用。
if n := stub.infoCalls.Load(); n != 0 {
t.Errorf("buddy/info calls=%d want 0(旅行已从签到剥离)", n)
}
}
// TestNextWakeTravelIndependent 旅行有独立时点,与签到互不影响。
func TestNextWakeTravelIndependent(t *testing.T) {
s := New(Config{
CheckinHours: []int{21},
TravelHours: []int{9},
ActivityHours: []int{10},
KeepaliveHours: []int{22},
})
at, kinds := s.nextWake(time.Date(2026, 9, 11, 8, 0, 0, 0, time.Local))
if want := time.Date(2026, 9, 11, 9, 0, 0, 0, time.Local); !at.Equal(want) {
t.Errorf("next=%v want %v(旅行 09:00 独立时点)", at, want)
}
if len(kinds) != 1 || kinds[0] != taskTravel {
t.Errorf("kinds=%v want [travel]", kinds)
}
}
// TestNextWakeActivityIndependent 活跃上报有独立时点。
func TestNextWakeActivityIndependent(t *testing.T) {
s := New(Config{
CheckinHours: []int{21},
TravelHours: []int{9},
ActivityHours: []int{10},
KeepaliveHours: []int{22},
})
at, kinds := s.nextWake(time.Date(2026, 9, 11, 9, 30, 0, 0, time.Local))
if want := time.Date(2026, 9, 11, 10, 0, 0, 0, time.Local); !at.Equal(want) {
t.Errorf("next=%v want %v(活跃 10:00 独立时点)", at, want)
}
if len(kinds) != 1 || kinds[0] != taskActivity {
t.Errorf("kinds=%v want [activity]", kinds)
}
}
// TestNextWakeTravelDisabled 旅行禁用后排程里不再有旅行时点(签到照常)。
func TestNextWakeTravelDisabled(t *testing.T) {
s := New(Config{
CheckinHours: []int{9, 21},
TravelHours: []int{9},
TravelDisabled: true,
KeepaliveHours: []int{22},
})
at, kinds := s.nextWake(time.Date(2026, 9, 11, 8, 0, 0, 0, time.Local))
// 旅行禁用 → 09:00 旅行时点不应出现,最近的是 09:00 签到(同小时但签到未禁用)。
if want := time.Date(2026, 9, 11, 9, 0, 0, 0, time.Local); !at.Equal(want) {
t.Errorf("next=%v want %v", at, want)
}
if !hasKind(kinds, taskCheckin) {
t.Errorf("kinds=%v want 含 checkin", kinds)
}
if hasKind(kinds, taskTravel) {
t.Errorf("kinds=%v 不应含 travel(已禁用)", kinds)
}
}
// TestNextWakeActivityDisabled 活跃上报禁用后排程里不再有活跃时点。
func TestNextWakeActivityDisabled(t *testing.T) {
s := New(Config{
CheckinHours: []int{9, 21},
ActivityHours: []int{10},
ActivityDisabled: true,
KeepaliveHours: []int{22},
})
at, kinds := s.nextWake(time.Date(2026, 9, 11, 9, 30, 0, 0, time.Local))
if want := time.Date(2026, 9, 11, 21, 0, 0, 0, time.Local); !at.Equal(want) {
t.Errorf("next=%v want %v(活跃禁用 → 跳过 10:00)", at, want)
}
if hasKind(kinds, taskActivity) {
t.Errorf("kinds=%v 不应含 activity(已禁用)", kinds)
}
}
// TestCheckinDisabledTravelStillRuns 签到禁用时旅行/活跃照跑(验收标准 2)。
func TestCheckinDisabledTravelStillRuns(t *testing.T) {
s := New(Config{
CheckinHours: []int{9, 21},
CheckinDisabled: true,
TravelHours: []int{9},
ActivityHours: []int{10},
KeepaliveHours: []int{22},
})
at, kinds := s.nextWake(time.Date(2026, 9, 11, 8, 0, 0, 0, time.Local))
if want := time.Date(2026, 9, 11, 9, 0, 0, 0, time.Local); !at.Equal(want) {
t.Errorf("next=%v want %v(签到禁用,旅行 09:00 照跑)", at, want)
}
if hasKind(kinds, taskCheckin) {
t.Errorf("kinds=%v 不应含 checkin(已禁用)", kinds)
}
if !hasKind(kinds, taskTravel) {
t.Errorf("kinds=%v 应含 travel(签到禁用但旅行独立)", kinds)
}
}
// TestAllFourDisabledNoSpin 四类任务全禁用:Run 不空转。
func TestAllFourDisabledNoSpin(t *testing.T) {
s := New(Config{
CheckinDisabled: true,
TravelDisabled: true,
ActivityDisabled: true,
KeepaliveDisabled: true,
BlackcatDisabled: true,
CheckinHours: []int{9, 21},
TravelHours: []int{9},
ActivityHours: []int{10},
KeepaliveHours: []int{22},
})
at, kinds := s.nextWake(time.Now())
if !at.IsZero() || len(kinds) != 0 {
t.Errorf("at=%v kinds=%v want zero/nil(五类全禁用)", at, kinds)
}
}
// TestNextWakeSameHourTravelAndCheckin 旅行与签到配到同一小时时两类任务都要执行。
func TestNextWakeSameHourTravelAndCheckin(t *testing.T) {
s := New(Config{
CheckinHours: []int{9, 21},
TravelHours: []int{9},
ActivityHours: []int{10},
KeepaliveHours: []int{22},
})
at, kinds := s.nextWake(time.Date(2026, 9, 11, 8, 0, 0, 0, time.Local))
if want := time.Date(2026, 9, 11, 9, 0, 0, 0, time.Local); !at.Equal(want) {
t.Errorf("next=%v want %v", at, want)
}
if !hasKind(kinds, taskCheckin) || !hasKind(kinds, taskTravel) {
t.Errorf("kinds=%v want 含 checkin+travel(同 09:00 两任务)", kinds)
}
}
// TestRunDispatchesActivityAndTravel Run 到点分发 activity 与 travel(不真打上游,用空池)。
func TestRunDispatchesActivityAndTravel(t *testing.T) {
fastActivity(t)
fastTravel(t)
// 空 pool → RunActivityNow/RunTravelNow 遍历 0 账号即返回,不阻塞。
p := pool.New("")
up := &upstream.Client{}
s := New(Config{
Pool: p,
Upstream: up,
CheckinHours: []int{},
TravelHours: []int{},
ActivityHours: []int{},
KeepaliveHours: []int{},
})
// 四类全空 hours → nextWake 回落默认 → 会构造 timer,ctx 取消即返回。
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { s.Run(ctx); close(done) }()
time.Sleep(20 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Run 未在 ctx 取消后返回")
}
}
// ---------------------------------------------------------------------------
// 国际版活跃上报:global 账号不再被跳过(上游 a190252 同口径)
// ---------------------------------------------------------------------------
// withGlobalSwitch 临时打开 global realm 开关并复位(生产缺省即开,此辅助确保测试隔离)。
func withGlobalSwitch(t *testing.T) {
t.Helper()
old := auth.GlobalEnabled()
auth.SetGlobalEnabled(true)
t.Cleanup(func() { auth.SetGlobalEnabled(old) })
}
// globalAccount 构造一个 realm=global 的账号(BackfillRealmFor 落显式标识)。
func globalAccount(t *testing.T, uid string) *auth.Auth {
t.Helper()
a := &auth.Auth{UID: uid, AccessToken: "at", RefreshToken: "rt", ExpiresAt: 9999999999}
if _, err := auth.BackfillRealmFor(a, "global"); err != nil {
t.Fatalf("BackfillRealmFor(global): %v", err)
}
if !a.IsGlobal() {
t.Fatalf("%s IsGlobal()=false want true", uid)
}
return a
}
// TestRunActivityNowReportsGlobalAccount 纯 global 池:账号必须上报(曾因
// 「D4 门控:global 无活跃体系」被跳过 → 国际版永远点不亮连登、拿不到每日积分)。
func TestRunActivityNowReportsGlobalAccount(t *testing.T) {
withGlobalSwitch(t)
fastActivity(t)
var cnHits, globalHits atomic.Int32
cnSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/report" {
cnHits.Add(1)
}
w.Write([]byte(`{"code":0,"msg":"OK"}`))
}))
defer cnSrv.Close()
globalSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/report" {
globalHits.Add(1)
}
w.Write([]byte(`{"code":0,"msg":"OK"}`))
}))
defer globalSrv.Close()
p := pool.New("")
p.Add(globalAccount(t, "g1"))
// global base 单独指向另一台 server:断言路由按 realm 切,不碰 CN 端点。
up := &upstream.Client{
HTTP: cnSrv.Client(),
GlobalEnabled: true,
ChatBaseCN: cnSrv.URL,
BillingBaseCN: cnSrv.URL,
ChatBaseGlobal: globalSrv.URL,
BillingBaseGlobal: globalSrv.URL,
}
s := New(Config{Pool: p, Upstream: up})
s.RunActivityNow()
if n := globalHits.Load(); n == 0 {
t.Error("global 账号未上报:/v2/report 未打到 global base(国际版将永远点不亮连登)")
}
if n := cnHits.Load(); n != 0 {
t.Errorf("CN base hits=%d want 0(global 账号不应打到 CN 端点)", n)
}
}
// TestRunActivityNowMixedPoolReportsBoth 混池:CN 与 global 各上报一次,
// 且分别路由到各自 realm 的 base。
func TestRunActivityNowMixedPoolReportsBoth(t *testing.T) {
withGlobalSwitch(t)
fastActivity(t)
var cnHits, globalHits atomic.Int32
cnSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/report" {
cnHits.Add(1)
}
w.Write([]byte(`{"code":0,"msg":"OK"}`))
}))
defer cnSrv.Close()
globalSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/report" {
globalHits.Add(1)
}
w.Write([]byte(`{"code":0,"msg":"OK"}`))
}))
defer globalSrv.Close()
p := pool.New("")
p.Add(&auth.Auth{UID: "cn1", AccessToken: "at", RefreshToken: "rt", ExpiresAt: 9999999999})
p.Add(globalAccount(t, "g1"))
up := &upstream.Client{
HTTP: cnSrv.Client(),
GlobalEnabled: true,
ChatBaseCN: cnSrv.URL,
BillingBaseCN: cnSrv.URL,
ChatBaseGlobal: globalSrv.URL,
BillingBaseGlobal: globalSrv.URL,
}
s := New(Config{Pool: p, Upstream: up})
s.RunActivityNow()
if n := cnHits.Load(); n != 1 {
t.Errorf("CN report hits=%d want 1", n)
}
if n := globalHits.Load(); n != 1 {
t.Errorf("global report hits=%d want 1", n)
}
}
// TestRunActivityNowGlobalErrorDoesNotSkipCN global report 失败(404)时
// CN 账号照常上报:单账号失败只 break 该号,不影响遍历。
func TestRunActivityNowGlobalErrorDoesNotSkipCN(t *testing.T) {
withGlobalSwitch(t)
fastActivity(t)
var cnHits atomic.Int32
cnSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/report" {
cnHits.Add(1)
}
w.Write([]byte(`{"code":0,"msg":"OK"}`))
}))
defer cnSrv.Close()
// global 侧一律 404:模拟国际版端点不可用。
globalSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not found", 404)
}))
defer globalSrv.Close()
p := pool.New("")
p.Add(globalAccount(t, "g1"))
p.Add(&auth.Auth{UID: "cn1", AccessToken: "at", RefreshToken: "rt", ExpiresAt: 9999999999})
up := &upstream.Client{
HTTP: cnSrv.Client(),
GlobalEnabled: true,
ChatBaseCN: cnSrv.URL,
BillingBaseCN: cnSrv.URL,
ChatBaseGlobal: globalSrv.URL,
BillingBaseGlobal: globalSrv.URL,
}
s := New(Config{Pool: p, Upstream: up})
s.RunActivityNow() // 不应 panic
if n := cnHits.Load(); n != 1 {
t.Errorf("CN report hits=%d want 1(global 失败不应影响 CN 账号)", n)
}
}
// TestCheckinStillSkipsGlobal 回归护栏:本次只放开活跃上报,签到仍跳过 global
// (上游 a190252 明确「checkin/travel 门控不动」)。误删该 gate 会让国际版去打
// CN 专属签到端点。
func TestCheckinStillSkipsGlobal(t *testing.T) {
withGlobalSwitch(t)
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.Write([]byte(`{"code":0,"data":{}}`))
}))
defer srv.Close()
p := pool.New("")
p.Add(globalAccount(t, "g1"))
up := &upstream.Client{
HTTP: srv.Client(),
GlobalEnabled: true,
ChatBaseCN: srv.URL,
BillingBaseCN: srv.URL,
ChatBaseGlobal: srv.URL,
BillingBaseGlobal: srv.URL,
}
s := New(Config{Pool: p, Upstream: up})
s.RunCheckinNow()
if n := hits.Load(); n != 0 {
t.Errorf("checkin global 上游调用=%d want 0(签到仍应跳过 global)", n)
}
}