用户要求:推送密钥必须是可选项(自部署后端不能写死推送方式),且要支持 多厂商配置式接入 —— 每个用户各自部署服务器、自己选厂商、自己配凭证。 所以落地成: · 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 的「实现状态」一节。
217 lines
6.7 KiB
Go
217 lines
6.7 KiB
Go
package repo
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/agentmail/gateway/internal/db"
|
||
)
|
||
|
||
/*
|
||
推送登记的判据(2026-09-15)。
|
||
|
||
推送是**可选通道**(用户要求「不能写死推送方式…推送密钥应当是可选项」),
|
||
所以这里钉住的不是"能不能推",而是登记本身的四个语义:
|
||
|
||
1. 重复登记是**刷新**,不是插入新行 —— 客户端每次启动都会登记,表不能随之膨胀;
|
||
2. 同一个 token 换人登录是**转移**(否则上一任用户的通知推到同一台设备上);
|
||
3. 注销要认**注册者**(不能凭一个 token 字符串掐掉别人的设备推送);
|
||
4. 无效 token 能按值清理(否则每次发信白吃华为的项目级额度)。
|
||
*/
|
||
|
||
func TestPushTokenUpsertIsRefreshNotInsert(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
for i := 0; i < 3; i++ {
|
||
if err := UpsertPushToken(ctx, "hms", "tok-1", "alice", "s-1", "我的手机"); err != nil {
|
||
t.Fatalf("第 %d 次登记失败: %v", i+1, err)
|
||
}
|
||
}
|
||
got, err := ListPushTokensOf(ctx, "alice")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(got) != 1 {
|
||
t.Fatalf("同一 token 登记 3 次应只有 1 行,实际 %d 行(表会随客户端启动次数膨胀)", len(got))
|
||
}
|
||
if got[0].Provider != "hms" || got[0].Token != "tok-1" || got[0].DeviceName != "我的手机" {
|
||
t.Fatalf("登记内容不对: %+v", got[0])
|
||
}
|
||
|
||
// 刷新要更新 session_id(客户端换了会话,点通知该回到新会话)
|
||
if err := UpsertPushToken(ctx, "hms", "tok-1", "alice", "s-2", "我的手机"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got, _ = ListPushTokensOf(ctx, "alice")
|
||
if len(got) != 1 || got[0].SessionID != "s-2" {
|
||
t.Fatalf("重复登记应刷新 session_id,实际 %+v", got)
|
||
}
|
||
}
|
||
|
||
func TestPushTokenIsTransferredOnRelogin(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
if err := UpsertPushToken(ctx, "hms", "shared-device", "alice", "", "同一台手机"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// bob 在同一台设备上登录并登记同一个 token
|
||
if err := UpsertPushToken(ctx, "hms", "shared-device", "bob", "", "同一台手机"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
aliceTokens, _ := ListPushTokensOf(ctx, "alice")
|
||
if len(aliceTokens) != 0 {
|
||
t.Fatalf("换人登录后 alice 不该还持有这台设备:%+v(否则 alice 的新邮件会推到 bob 手里的设备上)", aliceTokens)
|
||
}
|
||
bobTokens, _ := ListPushTokensOf(ctx, "bob")
|
||
if len(bobTokens) != 1 {
|
||
t.Fatalf("bob 应持有这台设备,实际 %+v", bobTokens)
|
||
}
|
||
}
|
||
|
||
func TestPushTokenPerProviderSameValueCoexist(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
// 不同 provider 的 token 空间是独立的,同一个字符串不该互相覆盖。
|
||
if err := UpsertPushToken(ctx, "hms", "same-string", "alice", "", ""); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := UpsertPushToken(ctx, "webpush", "same-string", "alice", "", ""); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got, _ := ListPushTokensOf(ctx, "alice")
|
||
if len(got) != 2 {
|
||
t.Fatalf("两个 provider 的同名 token 应并存(provider 是维度的一部分),实际 %d 行", len(got))
|
||
}
|
||
}
|
||
|
||
func TestDeletePushTokenRequiresOwner(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
if err := UpsertPushToken(ctx, "hms", "tok-1", "alice", "", ""); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// bob 拿着同一个 token 字符串来注销:必须无效
|
||
deleted, err := DeletePushToken(ctx, "hms", "tok-1", "bob")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if deleted {
|
||
t.Fatal("bob 不该能注销 alice 的设备(token 字符串不是凭证)")
|
||
}
|
||
if got, _ := ListPushTokensOf(ctx, "alice"); len(got) != 1 {
|
||
t.Fatal("alice 的登记被误删了")
|
||
}
|
||
|
||
deleted, err = DeletePushToken(ctx, "hms", "tok-1", "alice")
|
||
if err != nil || !deleted {
|
||
t.Fatalf("本人注销应成功,deleted=%v err=%v", deleted, err)
|
||
}
|
||
// 再注销一次:不是错误,只是 deleted=false
|
||
deleted, err = DeletePushToken(ctx, "hms", "tok-1", "alice")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if deleted {
|
||
t.Fatal("第二次注销不该报告删到了行")
|
||
}
|
||
}
|
||
|
||
func TestListPushTokensOfOwnersBatches(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
if err := UpsertPushToken(ctx, "hms", "t-a", "alice", "", ""); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := UpsertPushToken(ctx, "hms", "t-b", "bob", "", ""); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := UpsertPushToken(ctx, "hms", "t-c", "carol", "", ""); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
got, err := ListPushTokensOfOwners(ctx, []string{"alice", "bob"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(got) != 2 {
|
||
t.Fatalf("应取回 alice+bob 两个 token,实际 %d", len(got))
|
||
}
|
||
for _, tk := range got {
|
||
if tk.OwnerName == "carol" {
|
||
t.Fatal("不该取回未请求的 carol 的登记")
|
||
}
|
||
}
|
||
|
||
// 空名单不该拼出 `IN ()` 这种非法 SQL
|
||
if got, err := ListPushTokensOfOwners(ctx, nil); err != nil || got != nil {
|
||
t.Fatalf("空名单应直接返回 nil, nil,实际 %v / %v", got, err)
|
||
}
|
||
}
|
||
|
||
func TestDeletePushTokensByValueIgnoresOwner(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
for _, o := range []string{"alice", "bob"} {
|
||
if err := UpsertPushToken(ctx, "hms", "dead-"+o, o, "", ""); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
n, err := DeletePushTokensByValue(ctx, "hms", []string{"dead-alice", "dead-bob", "not-there"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n != 2 {
|
||
t.Fatalf("应删掉 2 行,实际 %d", n)
|
||
}
|
||
if left, _ := ListPushTokensOfOwners(ctx, []string{"alice", "bob"}); len(left) != 0 {
|
||
t.Fatalf("清理不干净: %+v", left)
|
||
}
|
||
// 别的 provider 不该被误删
|
||
if err := UpsertPushToken(ctx, "webpush", "dead-alice", "alice", "", ""); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n, _ := DeletePushTokensByValue(ctx, "hms", []string{"dead-alice"}); n != 0 {
|
||
t.Fatal("按 hms 清理时误删了 webpush 的登记")
|
||
}
|
||
}
|
||
|
||
func TestPruneStalePushTokens(t *testing.T) {
|
||
setupTestDB(t)
|
||
ctx := context.Background()
|
||
|
||
if err := UpsertPushToken(ctx, "hms", "fresh", "alice", "", ""); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := UpsertPushToken(ctx, "hms", "stale", "bob", "", ""); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 把 stale 那条改老:UPDATE 直接写 91 天前的时刻(与 db.go 的 NOW() 同格式)
|
||
old := time.Now().UTC().Add(-91 * 24 * time.Hour).Format("2006-01-02 15:04:05.000000")
|
||
if _, err := db.DB.ExecContext(ctx, `UPDATE push_tokens SET updated_at = $1 WHERE token = 'stale'`, old); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
n, err := PruneStalePushTokens(ctx)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if n != 1 {
|
||
t.Fatalf("应清掉 1 条过期登记,实际 %d", n)
|
||
}
|
||
left, _ := ListPushTokensOf(ctx, "alice")
|
||
if len(left) != 1 {
|
||
t.Fatal("新鲜的登记被误删了")
|
||
}
|
||
if gone, _ := ListPushTokensOf(ctx, "bob"); len(gone) != 0 {
|
||
t.Fatal("过期登记没被清掉")
|
||
}
|
||
}
|