feat(push): 可选、配置式、多厂商的推送通道(HMS 为首个实现)

用户要求:推送密钥必须是可选项(自部署后端不能写死推送方式),且要支持
多厂商配置式接入 —— 每个用户各自部署服务器、自己选厂商、自己配凭证。
所以落地成:

· internal/push:通道抽象 + 工厂表(RegisterType),加厂商不改配置层与端点形状;
  HMS 只是第一个实现(internal/push/hms.go)
· 配置在 PUSH_CONFIG(默认 <AGENTMAIL_DATA_DIR>/push.json),一项一个厂商,
  凭证走文件(app_secret_file / files.*,建议 600);环境变量只是可选覆盖
· 没配 = 整条推送路径连一次查库都不发生(shouldDispatch 早退);
  单项配错(未知类型/密钥读不到/enabled:false)只跳过那一条,不影响启动
· push_tokens 表带 provider 维度 + 三个 /me/devices/push-token 端点;
  没配推送时端点照存并回 enabled:false(登记成功 != 服务端开了推送)
· notify.Recipients 末尾异步挂钩:收件人名单直接用 SSE 那份 seen(两条通道
  共用同一份"谁该收到"的判据);失败只记日志,绝不拖住收信

HMS 的形状是拿真凭证打线上接口问出来的(v1 + message.token[] + testMessage;
payload/target 形状 v1 不认、v2 要服务账号 JWT)。未上架应用必须 test_message=true,
单批 ≤10 token(MaxTokensPerRequest 声明)、每日 1000 条兜底(项目级额度)。
实测:App ID + App Secret 能换到 access_token(3600s);形状被线上服务接受。

判据:repo 6 条 + push 12 条 + handler 3 组,全部做过**变异验证** ——
过程中抓出两条假判据(异步分发与 t.Cleanup 赛跑而假绿;密钥文件优先级没被覆盖)
并补掉。Go 全量测试与 go vet 干净。

★ 未验:端到端真机送达(需要真机 token + 客户端按 com.jianf.agentmail 重编并签名,
签名指纹还要在 AGC 登记)—— 从未真正发出过一条能到达设备的推送。
详见 docs/HMS-PUSH-PLAN.md 的「实现状态」一节。
This commit is contained in:
2026-09-15 11:21:00 +08:00
parent b806a05bfa
commit 46fa7fa729
14 changed files with 2349 additions and 0 deletions

View File

@ -0,0 +1,604 @@
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
}
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++
s.mu.Unlock()
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())
}
}
func str(v any) string {
s, _ := v.(string)
return s
}