Files
MailUI4Agents/server/internal/push/push_test.go
JianFeeeee 46fa7fa729 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 的「实现状态」一节。
2026-09-15 11:21:00 +08:00

605 lines
21 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
}
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
}