Files
MailUI4Agents/server/internal/push/push_test.go
JianFeeeee bfc9d87b24 test(push): 补 pi 建议的那一格 —— DailyLimit=1 时失败后仍要能发
上一提交补的是**内部计数器**(dayCount == 0)。pi 在报告里建议的是
**用户看得见的行为**:DailyLimit=1,一次 accessToken 失败后第二次
仍然要能发出去。

两层都要钉的理由:计数器对而行为错是可能的 —— 那会让运维收到
「达到每日推送上限」这种**误导性文案**,真实原因却是上一次网络抖动。

变异验证:把 reserveDaily 挪回 accessToken 之前 ⇒ 两格同时红。

    --- FAIL: TestHMSAccessTokenFailureDoesNotBurnQuota
    --- FAIL: TestHMSQuotaSurvivesTokenFailureWithLimitOne
2026-09-28 09:50:24 +08:00

739 lines
26 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package push
import (
"bytes"
"context"
"encoding/json"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/repo"
)
/*
推送通道的判据(2026-09-15)。
用户对这件事的要求是**可选**:「不能写死推送方式,因为我们是自部署后端」
「即推送密钥应当是可选项」。所以第一组判据钉的不是"推得出去",而是
**没配凭证时它必须彻底不存在**(不查库、不占 goroutine、不刷日志)。
第二组钉华为那条路的具体形状 —— 那些常量是拿真凭证打真接口问出来的
(见 hms.go 的注释),判据把形状钉住,避免以后"顺手改一下"就悄悄失效。
*/
// ─── 夹具 ────────────────────────────────────────────────────────────────
type fakeNotifier struct {
name string
max int
mu sync.Mutex
calls [][]string
last NewMail
}
func (f *fakeNotifier) Name() string { return f.name }
func (f *fakeNotifier) MaxTokensPerRequest() int { return f.max }
func (f *fakeNotifier) Send(_ context.Context, tokens []string, n NewMail) error {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, append([]string(nil), tokens...))
f.last = n
return nil
}
func (f *fakeNotifier) sizes() []int {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]int, 0, len(f.calls))
for _, c := range f.calls {
out = append(out, len(c))
}
return out
}
// withProviders 临时替换全局通道表(测试之间互不影响)。
func withProviders(t *testing.T, ps ...Notifier) {
t.Helper()
mu.Lock()
saved := providers
providers = nil
mu.Unlock()
for _, p := range ps {
Register(p)
}
t.Cleanup(func() {
mu.Lock()
providers = saved
mu.Unlock()
})
}
func setupPushDB(t *testing.T) {
t.Helper()
dir := t.TempDir()
if err := db.Connect(context.Background(), filepath.Join(dir, "t.db")); err != nil {
t.Fatalf("connect: %v", err)
}
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Cleanup(db.Close)
}
// ─── 一、可选性:没配凭证 = 彻底不存在 ──────────────────────────────────
// 没注册任何通道时,推送必须彻底不存在:不分发、不查库、不起 goroutine。
//
// 判据分两侧(只验一侧的判据是假的):
// - 坏样本:没通道时 shouldDispatch 必须为 false;
// - 干净样本:注了通道且有收件人时必须为 true —— 否则“永远返回 false”也能骗过上面一条。
//
// 另外真的拿 nil 库调一次 NotifyNewMail 做冒烟(任何 repo 调用都会 panic)。
// 它**不是**主判据:分发在 goroutine 里,与 t.Cleanup 赛跑 —— 2026-09-15 变异验证
// 实测过:只写这一点时,去掉 push.go 的 Enabled() 早退仍然绿(判据在错误的理由上通过)。
func TestNoProvidersTouchesNothing(t *testing.T) {
withProviders(t) // 一个都不注册
saved := db.DB
db.DB = nil
t.Cleanup(func() { db.DB = saved })
if Enabled() {
t.Fatal("一个通道都没注册时 Enabled() 必须是 false")
}
if names := Names(); len(names) != 0 {
t.Fatalf("没注册通道时不该有名字,实际 %v", names)
}
if shouldDispatch(NewMail{Recipients: []string{"alice"}}) {
t.Fatal("没配任何推送通道时分发必须被跳过(零开销)")
}
if shouldDispatch(NewMail{}) {
t.Fatal("没有收件人时不该分发")
}
// 冒烟:走完 NotifyNewMail 不该碰库
NotifyNewMail(context.Background(), NewMail{
MailID: "m1", SessionID: "s1", From: "bob", Subject: "你好",
Recipients: []string{"alice"},
})
NotifyNewMail(context.Background(), NewMail{MailID: "m2"})
// 干净样本:注了通道 + 有收件人 → 必须分发
withProviders(t, &fakeNotifier{name: "fake", max: 10})
if !shouldDispatch(NewMail{Recipients: []string{"alice"}}) {
t.Fatal("配了通道且有收件人时必须分发(否则这条判据挡不住“永远不分发”的实现)")
}
if shouldDispatch(NewMail{}) {
t.Fatal("注了通道但没有收件人时仍不该分发")
}
}
/*
配置式多厂商接入的判据(2026-09-15 用户的第二条要求)。
原话:「我们要支持多厂商配置式接入统一推送服务,推送密钥(文件)应当是配置项,
用户可为不同的推送服务配置对应的凭证,因为我们的设计中是每个用户各自部署服务器」。
所以这里钉四件事:
1. 配置能真的造出多个通道(而不是只有一个 HMS 硬编码路径);
2. **密钥文件**能作为配置项用(app_secret_file),且权限过松会提醒;
3. **单项配错不能拖垮服务**:未知类型 / 密钥读不到 / 被 disabled → 只跳过那一条;
4. 没配文件 = 没推送,且不是错误(自部署实例的默认形态)。
*/
func writePushConfig(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "push.json")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("PUSH_CONFIG", path)
return path
}
func writeSecret(t *testing.T, mode os.FileMode) string {
t.Helper()
p := filepath.Join(t.TempDir(), "hms.secret")
if err := os.WriteFile(p, []byte("sec-from-file\n"), mode); err != nil {
t.Fatal(err)
}
return p
}
// 未知的 type 只跳过自己;同实例可以同时接多个厂商。
func TestConfigFileDrivesMultipleProviders(t *testing.T) {
RegisterType("test-echo", func(cfg ProviderConfig) (Notifier, error) {
return &fakeNotifier{name: nameOf(cfg), max: 3}, nil
})
secret := writeSecret(t, 0o600)
writePushConfig(t, `{
"providers": [
{"type":"hms","app_id":"app-1","app_secret_file":`+jsonStr(secret)+`},
{"type":"test-echo","name":"echo-a"},
{"type":"vendor-not-implemented","app_id":"x"},
{"type":"hms","name":"hms-disabled","enabled":false,"app_id":"a","app_secret":"s"}
]
}`)
ps := LoadProviders()
names := map[string]Notifier{}
for _, p := range ps {
names[p.Name()] = p
}
if len(ps) != 2 {
t.Fatalf("应启用 2 个通道(hms + test-echo),实际 %d: %v", len(ps), names)
}
if _, ok := names["hms"]; !ok {
t.Fatalf("缺 hms 通道: %v", names)
}
if _, ok := names["echo-a"]; !ok {
t.Fatalf("Name 覆盖没生效(应叫 echo-a): %v", names)
}
// 密钥来自**文件**(配置项),而不是内联
h, _ := names["hms"].(*HMS)
if h == nil || h.AppSecret != "sec-from-file" {
t.Fatalf("app_secret_file 没被读进来: %+v", h)
}
// 缺省 = 测试消息开(未上架应用只有这个模式收得到)
if !h.TestMessage {
t.Fatal("test_message 缺省必须为 true(未上架应用)")
}
if h.DailyLimit != 1000 {
t.Fatalf("daily_limit 缺省应为 1000(华为测试消息的项目级限制),实际 %d", h.DailyLimit)
}
}
// 密钥文件权限过松要提醒(一个 0644 的密钥文件是真实的配置错误),但不拦启动。
func TestConfigWarnsOnLooseSecretFile(t *testing.T) {
secret := writeSecret(t, 0o644)
writePushConfig(t, `{"providers":[{"type":"hms","app_id":"a","app_secret_file":`+jsonStr(secret)+`}]}`)
var buf bytes.Buffer
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(os.Stderr) })
if ps := LoadProviders(); len(ps) != 1 {
t.Fatalf("权限过松只该提醒不该拒绝,实际 %d 个通道", len(ps))
}
log.SetOutput(os.Stderr)
if !strings.Contains(buf.String(), "权限") {
t.Fatalf("应提醒密钥文件权限过松,实际日志:%s", buf.String())
}
}
// 单项配错不能拖垮其他项:第一个密钥文件读不到,第二个仍必须启用。
func TestConfigBadEntryDoesNotKillOthers(t *testing.T) {
RegisterType("test-echo", func(cfg ProviderConfig) (Notifier, error) {
return &fakeNotifier{name: nameOf(cfg), max: 3}, nil
})
writePushConfig(t, `{
"providers": [
{"type":"hms","app_id":"a","app_secret_file":"/nonexistent/secret"},
{"type":"hms","name":"hms-ok","app_id":"b","app_secret":"inline"}
]
}`)
ps := LoadProviders()
if len(ps) != 1 || ps[0].Name() != "hms-ok" {
t.Fatalf("坏条目应只跳自己,实际 %v", ps)
}
}
// 没配配置文件 = 没推送,而且不是错误。
func TestNoConfigMeansNoProviders(t *testing.T) {
t.Setenv("PUSH_CONFIG", filepath.Join(t.TempDir(), "does-not-exist.json"))
if ps := LoadProviders(); len(ps) != 0 {
t.Fatalf("没配置文件时应 0 个通道,实际 %d", len(ps))
}
}
// 环境变量是**可选覆盖**:配置里没有同类型条目时补一条;已有则以配置为准。
func TestEnvIsOverrideNotTheMainPath(t *testing.T) {
t.Setenv("HMS_APP_ID", "env-app")
t.Setenv("HMS_APP_SECRET", "env-secret")
t.Setenv("HMS_TEST_MESSAGE", "false")
// 1) 配置文件里没有 hms 条目 → 用环境变量补一条
writePushConfig(t, `{"providers":[]}`)
ps := LoadProviders()
if len(ps) != 1 || ps[0].Name() != "hms" {
t.Fatalf("环境变量应能补出一条 hms 通道,实际 %v", ps)
}
if h := ps[0].(*HMS); h.TestMessage {
t.Fatal("HMS_TEST_MESSAGE=false 没生效")
}
// 2) 配置文件里已有 hms 条目 → 环境变量不得再补一条(避免两个同名通道)
writePushConfig(t, `{"providers":[{"type":"hms","name":"hms-from-file","app_id":"f","app_secret":"s"}]}`)
ps = LoadProviders()
if len(ps) != 1 || ps[0].Name() != "hms-from-file" {
t.Fatalf("配置文件优先,环境变量不该再加一条,实际 %v", ps)
}
}
// 密钥文件优先于内联密钥(用户明确要求「推送密钥(文件)应当是配置项」)。
//
// 为什么这条值得单独写:两者都给是**常见**情况(配置里留着旧的内联密钥做参考,
// 同时切到文件)。不明确优先关系的结果是「改了文件却没生效」这种最难查的静默失效。
// 第一版这条判据缺失,变异验证(把优先级反转)居然全绿 —— 因此补上。
func TestSecretFileBeatsInlineSecret(t *testing.T) {
secret := writeSecret(t, 0o600)
writePushConfig(t, `{"providers":[{"type":"hms","app_id":"a","app_secret":"inline-wrong","app_secret_file":`+jsonStr(secret)+`}]}`)
ps := LoadProviders()
if len(ps) != 1 {
t.Fatalf("应有 1 个通道,实际 %d", len(ps))
}
if h := ps[0].(*HMS); h.AppSecret != "sec-from-file" {
t.Fatalf("密钥文件应优先于内联密钥,实际用了 %q", h.AppSecret)
}
}
// 厂自定义文件表(files)也能当密钥配置项用:不是每个厂商都叫 app_secret。
func TestGenericFilesMapWorksAsSecretSource(t *testing.T) {
secret := writeSecret(t, 0o600)
writePushConfig(t, `{"providers":[{"type":"hms","app_id":"a","files":{"app_secret":`+jsonStr(secret)+`}}]}`)
ps := LoadProviders()
if len(ps) != 1 {
t.Fatalf("files.app_secret 应被当作密钥文件配置项,实际 %d 个通道", len(ps))
}
if h := ps[0].(*HMS); h.AppSecret != "sec-from-file" {
t.Fatalf("files 里的密钥文件没被读进来: %q", h.AppSecret)
}
}
// 缺密钥的厂商条目由**它自己的 Factory** 判失败(而不是通用配置层):
// 通用层要求密钥就会把 Web Push 这类不用 app_secret 的厂商误杀(判据抓出来过)。
func TestMissingSecretIsFactoryBusiness(t *testing.T) {
RegisterType("test-nosecret", func(cfg ProviderConfig) (Notifier, error) {
return &fakeNotifier{name: nameOf(cfg), max: 1}, nil
})
writePushConfig(t, `{"providers":[
{"type":"test-nosecret","name":"no-secret-needed"},
{"type":"hms","app_id":"a"}
]}`)
ps := LoadProviders()
if len(ps) != 1 || ps[0].Name() != "no-secret-needed" {
t.Fatalf("不需要密钥的厂商应能建起来;需要密钥而没给的 hms 应被自己的 Factory 判失败。实际 %v", ps)
}
}
func jsonStr(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
// ─── 二、分发:按 provider 分组、按通道声明的上限分批 ────────────────────
func TestDispatchBatchesByProviderLimit(t *testing.T) {
setupPushDB(t)
ctx := context.Background()
// 23 个 token,通道声明的上限是 10 → 必须切成 10/10/3
for i := 0; i < 23; i++ {
if err := repo.UpsertPushToken(ctx, "fake", tokName(i), "alice", "", ""); err != nil {
t.Fatal(err)
}
}
f := &fakeNotifier{name: "fake", max: 10}
withProviders(t, f)
dispatch(ctx, NewMail{MailID: "m-1", SessionID: "s-1", From: "bob", Subject: "主题", Recipients: []string{"alice"}})
if got := f.sizes(); len(got) != 3 || got[0] != 10 || got[1] != 10 || got[2] != 3 {
t.Fatalf("分批不对:期望 [10 10 3],实际 %v(超限会被华为拒:token count should within 1 and 1,000;测试消息另限 10)", got)
}
if f.last.MailID != "m-1" || f.last.SessionID != "s-1" || f.last.From != "bob" {
t.Fatalf("推给通道的邮件内容不对: %+v", f.last)
}
}
func TestDispatchGroupsByProvider(t *testing.T) {
setupPushDB(t)
ctx := context.Background()
if err := repo.UpsertPushToken(ctx, "a", "t-a1", "alice", "", ""); err != nil {
t.Fatal(err)
}
if err := repo.UpsertPushToken(ctx, "b", "t-b1", "bob", "", ""); err != nil {
t.Fatal(err)
}
fa := &fakeNotifier{name: "a", max: 10}
fb := &fakeNotifier{name: "b", max: 10}
withProviders(t, fa, fb)
dispatch(ctx, NewMail{MailID: "m", Recipients: []string{"alice", "bob"}})
if len(fa.calls) != 1 || len(fa.calls[0]) != 1 || fa.calls[0][0] != "t-a1" {
t.Fatalf("通道 a 应只拿到自己的 token,实际 %v", fa.calls)
}
if len(fb.calls) != 1 || len(fb.calls[0]) != 1 || fb.calls[0][0] != "t-b1" {
t.Fatalf("通道 b 应只拿到自己的 token,实际 %v", fb.calls)
}
}
func TestDispatchSkipsRecipientsWithoutTokens(t *testing.T) {
setupPushDB(t)
f := &fakeNotifier{name: "fake", max: 10}
withProviders(t, f)
// 谁都没登记过:不该有任何发送,也不该报错
dispatch(context.Background(), NewMail{MailID: "m", Recipients: []string{"nobody"}})
if len(f.calls) != 0 {
t.Fatalf("没人登记过就不该发,实际 %v", f.calls)
}
}
func tokName(i int) string {
return "tok-" + strings.Repeat("0", 2-len(itoa(i))) + itoa(i)
}
func itoa(i int) string {
if i == 0 {
return "0"
}
var b []byte
for i > 0 {
b = append([]byte{byte('0' + i%10)}, b...)
i /= 10
}
return string(b)
}
// ─── 三、华为通道的具体形状 ─────────────────────────────────────────────
// hmsStub 起一个假的华为端点:/token 发令牌,其余路径收推送。
type hmsStub struct {
srv *httptest.Server
mu sync.Mutex
tokenReq int
pushReqs []map[string]any
authHdrs []string
code string
// failToken 让 /token 返回 400(模拟 accessToken 失败)。
//
// 为什么需要它(2026-09-28):审查报告指出「额度扣在投递之前」——
// accessToken 失败时**一条都没发出去**、额度却已经扣了。
// 而当时那批判据**测不出这件事**:造不出 token 失败这条路。
// 修完代码后补这一格,就是为了让「注释说修了」与「代码真修了」不再能分家。
failToken bool
}
func newHMSStub(t *testing.T) *hmsStub {
t.Helper()
s := &hmsStub{code: "80000000"}
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/token" {
s.mu.Lock()
s.tokenReq++
fail := s.failToken
s.mu.Unlock()
if fail {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_, _ = io.WriteString(w, `{"error":"invalid_token"}`)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"access_token":"tok-abc","expires_in":3600}`)
return
}
body, _ := io.ReadAll(r.Body)
var m map[string]any
_ = json.Unmarshal(body, &m)
s.mu.Lock()
s.pushReqs = append(s.pushReqs, m)
s.authHdrs = append(s.authHdrs, r.Header.Get("Authorization"))
code := s.code
s.mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = io.WriteString(w, `{"code":"`+code+`","msg":"stub"}`)
}))
t.Cleanup(s.srv.Close)
return s
}
func (s *hmsStub) hms() *HMS {
return &HMS{
AppID: "app-1", AppSecret: "sec-1", TestMessage: true, DailyLimit: 1000,
Endpoint: s.srv.URL, TokenURL: s.srv.URL + "/token", Client: s.srv.Client(),
}
}
func (s *hmsStub) count() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.pushReqs)
}
// 推送请求的形状:v1 端点 + message.token + notification + data(字符串) + testMessage。
// 形状不对时华为回的是参数类错误(实测 80300010),而这条判据把它钉死在本地。
func TestHMSSendShape(t *testing.T) {
s := newHMSStub(t)
err := s.hms().Send(context.Background(), []string{"tok-1", "tok-2"}, NewMail{
MailID: "mail-1", SessionID: "sess-1", From: "bob", Subject: "标题",
})
if err != nil {
t.Fatalf("成功码应被当成功: %v", err)
}
if s.count() != 1 {
t.Fatalf("应发出 1 个请求,实际 %d", s.count())
}
req := s.pushReqs[0]
if req["testMessage"] != true {
t.Fatalf("未上架应用必须带 testMessage=true,实际 %v", req["testMessage"])
}
msg, ok := req["message"].(map[string]any)
if !ok {
t.Fatalf("缺 message 字段: %v", req)
}
tokens, _ := msg["token"].([]any)
if len(tokens) != 2 || tokens[0] != "tok-1" || tokens[1] != "tok-2" {
t.Fatalf("token 列表不对: %v", msg["token"])
}
notif, _ := msg["notification"].(map[string]any)
if notif == nil || !strings.Contains(str(notif["title"]), "标题") || notif["body"] != "bob" {
t.Fatalf("通知内容不对: %v", notif)
}
// data 必须是**字符串**(华为这套要求序列化后的 JSON 字符串)
dataStr, ok := msg["data"].(string)
if !ok {
t.Fatalf("data 必须是字符串,实际 %T", msg["data"])
}
var data map[string]string
if err := json.Unmarshal([]byte(dataStr), &data); err != nil {
t.Fatalf("data 不是合法 JSON 字符串: %v", err)
}
if data["type"] != "new_mail" || data["mail_id"] != "mail-1" || data["session_id"] != "sess-1" || data["action"] != "open_mail" {
t.Fatalf("data 的跳转契约不对(客户端按它跳转): %v", data)
}
if s.authHdrs[0] != "Bearer tok-abc" {
t.Fatalf("Authorization 头不对: %q", s.authHdrs[0])
}
}
// 访问令牌要缓存:两次发送只换一次令牌(华为给 3600 秒,我们的实现提前 5 分钟刷新)。
func TestHMSCachesAccessToken(t *testing.T) {
s := newHMSStub(t)
h := s.hms()
for i := 0; i < 2; i++ {
if err := h.Send(context.Background(), []string{"tok-1"}, NewMail{MailID: "m"}); err != nil {
t.Fatal(err)
}
}
s.mu.Lock()
defer s.mu.Unlock()
if s.tokenReq != 1 {
t.Fatalf("两次发送应只换一次令牌,实际换了 %d 次", s.tokenReq)
}
}
func TestHMSTestMessageCanBeDisabled(t *testing.T) {
s := newHMSStub(t)
h := s.hms()
h.TestMessage = false // 上架之后
if err := h.Send(context.Background(), []string{"tok-1"}, NewMail{MailID: "m"}); err != nil {
t.Fatal(err)
}
if _, present := s.pushReqs[0]["testMessage"]; present {
t.Fatal("HMS_TEST_MESSAGE=false 时不该带 testMessage")
}
}
// 华为回「这些 token 全无效」时必须把它们从库里清掉:
// 否则每次发信都向死 token 发(白吃项目级额度),而用户永远收不到。
func TestHMSSendPrunesAllInvalidTokens(t *testing.T) {
setupPushDB(t)
ctx := context.Background()
if err := repo.UpsertPushToken(ctx, "hms", "dead-1", "alice", "", ""); err != nil {
t.Fatal(err)
}
if err := repo.UpsertPushToken(ctx, "hms", "dead-2", "alice", "", ""); err != nil {
t.Fatal(err)
}
s := newHMSStub(t)
s.code = "80300007" // 实测码:All the tokens are invalid
h := s.hms()
err := h.Send(ctx, []string{"dead-1", "dead-2"}, NewMail{MailID: "m"})
if err == nil {
t.Fatal("无效 token 必须报错(不能静默当成功)")
}
if !strings.Contains(err.Error(), "80300007") {
t.Fatalf("错误里应带上华为的 code,便于排查: %v", err)
}
left, _ := repo.ListPushTokensOf(ctx, "alice")
if len(left) != 0 {
t.Fatalf("无效 token 没被清理: %+v", left)
}
}
// 认证/参数类错误不该清 token(那是我们自己的问题,不是设备的问题)。
func TestHMSSendKeepsTokensOnOtherErrors(t *testing.T) {
setupPushDB(t)
ctx := context.Background()
if err := repo.UpsertPushToken(ctx, "hms", "good-1", "alice", "", ""); err != nil {
t.Fatal(err)
}
s := newHMSStub(t)
s.code = "80200001" // Authentication Error
if err := s.hms().Send(ctx, []string{"good-1"}, NewMail{MailID: "m"}); err == nil {
t.Fatal("认证失败必须报错")
}
left, _ := repo.ListPushTokensOf(ctx, "alice")
if len(left) != 1 {
t.Fatal("认证类错误不该删 token(设备是好的,错在我们)")
}
}
// 每日上限只是**兜底**(华为对未上架应用的测试消息限 1000 条/天/项目)。
// 到线就停手,不把额度打光换来一串失败响应。
func TestHMSDailyLimitStopsSending(t *testing.T) {
s := newHMSStub(t)
h := s.hms()
h.DailyLimit = 1
if err := h.Send(context.Background(), []string{"tok-1"}, NewMail{MailID: "m1"}); err != nil {
t.Fatal(err)
}
err := h.Send(context.Background(), []string{"tok-2"}, NewMail{MailID: "m2"})
if err == nil {
t.Fatal("超过每日上限必须拒绝发送")
}
if !strings.Contains(err.Error(), "上限") {
t.Fatalf("错误信息应说明是上限问题: %v", err)
}
if s.count() != 1 {
t.Fatalf("到线后不该再打网络,实际打了 %d 次", s.count())
}
}
// 额度按**消息条数**计,不是按设备数(2026-09-26 修的行为)。
//
// 原实现传的是 len(tokens),于是 3 个设备收 1 封邮件就吃掉 3 条额度 ——
// 而华为是按 messages:send 的调用次数计的,实际只发了 1 条。
// 钉住这个形状,免得下次又退回按 token 数。
func TestHMSDailyLimitCountsMessagesNotTokens(t *testing.T) {
s := newHMSStub(t)
h := s.hms()
h.DailyLimit = 2
// 一次 3 个设备 = **一条**消息(华为的计数口径)⇒ 应只扣 1。
if err := h.Send(context.Background(), []string{"t1", "t2", "t3"}, NewMail{MailID: "m1"}); err != nil {
t.Fatal(err)
}
// 还有 1 条额度可用(2-1=1),第二次单设备发送应当仍然通过。
if err := h.Send(context.Background(), []string{"t4"}, NewMail{MailID: "m2"}); err != nil {
t.Fatalf("第二条额度不该被 3 个设备用光: %v", err)
}
// 两条用完 ⇒ 第三条必须被拒。
if err := h.Send(context.Background(), []string{"t5"}, NewMail{MailID: "m3"}); err == nil {
t.Fatal("两条消息用完后必须拒绝")
}
if s.count() != 2 {
t.Fatalf("应有且仅有 2 次网络调用(= 2 条消息),实际 %d 次", s.count())
}
}
func str(v any) string {
s, _ := v.(string)
return s
}
// ── 2026-09-28:额度必须在「确认能发」之后才扣 ────────────────────────
//
// 这格判据是**补的**,而它本该在 2026-09-26 那次修复时就存在。
//
// 当时发生的事:审查报告 §二.1 写了两条(①按 token 数扣 ②扣在投递之前),
// 实现只改了 ①,而 ② **被写进了注释、代码没动** —— `reserveDaily` 仍在
// `accessToken` 之前。注释与代码分家,而当时那批判据测不出来:
// 造不出「accessToken 失败」这条路。
//
// 抓住它的是 pi:它在一次邮件驱动的回合里自己发现了这处不一致。
func TestHMSAccessTokenFailureDoesNotBurnQuota(t *testing.T) {
s := newHMSStub(t)
h := s.hms()
h.DailyLimit = 5
s.mu.Lock()
s.failToken = true
s.mu.Unlock()
// 三次都应该在拿 token 这一步就失败
for i := 0; i < 3; i++ {
if err := h.Send(context.Background(), []string{"tok-1"}, NewMail{MailID: "m1"}); err == nil {
t.Fatalf("第 %d 次:accessToken 失败时 Send 必须返回错误", i+1)
}
}
s.mu.Lock()
pushes := len(s.pushReqs)
s.failToken = false
s.mu.Unlock()
if pushes != 0 {
t.Fatalf("accessToken 失败时不该发出任何推送,实际发了 %d 次", pushes)
}
// ★ 核心断言:额度**一点没扣**。
// 若预留仍在 accessToken 之前,这里的 used 就是 3,
// DailyLimit=5 也会只剩 2 次机会 —— 而这三次什么都没发出去。
h.dayMu.Lock()
used := h.dayCount
h.dayMu.Unlock()
if used != 0 {
t.Fatalf("★ accessToken 失败不该扣额度,实际已扣 %d 条("+
"一次网络抖动静默烧配额就是这么来的)", used)
}
// 恢复正常后仍能发送 ⇒ 额度没被那三次失败吃掉
if err := h.Send(context.Background(), []string{"tok-1"}, NewMail{MailID: "m-ok"}); err != nil {
t.Fatalf("恢复后必须能发送: %v", err)
}
h.dayMu.Lock()
used = h.dayCount
h.dayMu.Unlock()
if used != 1 {
t.Fatalf("成功这一次应恰好计 1 条,实际 %d", used)
}
}
// TestHMSQuotaSurvivesTokenFailureWithLimitOne 用 pi 建议的形状再钉一遍:
// `DailyLimit=1` 时,一次 accessToken 失败之后**第二次仍然要能发出去**。
//
// 为什么单独一格而不是并进上面那格:上面断言的是内部计数器(dayCount),
// 这格断言的是**用户看得见的行为** —— 失败后收到的下一个错误不能是
// 「达到每日推送上限」这种误导性文案(它会让运维以为配额用完了,
// 而真实原因是上一次网络抖动)。计数器对、行为错是可能的,
// 所以两层都要钉。
func TestHMSQuotaSurvivesTokenFailureWithLimitOne(t *testing.T) {
s := newHMSStub(t)
h := s.hms()
h.DailyLimit = 1
s.mu.Lock()
s.failToken = true
s.mu.Unlock()
err1 := h.Send(context.Background(), []string{"tok-1"}, NewMail{MailID: "m1"})
if err1 == nil {
t.Fatal("accessToken 失败时必须返回错误")
}
s.mu.Lock()
s.failToken = false
s.mu.Unlock()
err2 := h.Send(context.Background(), []string{"tok-2"}, NewMail{MailID: "m2"})
if err2 != nil {
if strings.Contains(err2.Error(), "上限") {
t.Fatalf("★ 第二次被当成配额用尽拒绝(%v)—— 那次失败烧掉了当日配额,"+
"而用户看到的文案会指向错误的原因", err2)
}
t.Fatalf("恢复正常后第二次必须发出去,实际: %v", err2)
}
}