用户要求:推送密钥必须是可选项(自部署后端不能写死推送方式),且要支持 多厂商配置式接入 —— 每个用户各自部署服务器、自己选厂商、自己配凭证。 所以落地成: · 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 的「实现状态」一节。
605 lines
21 KiB
Go
605 lines
21 KiB
Go
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
|
||
}
|