Files
im_server/server/push/jpush/push_jpush_test.go
T

353 lines
12 KiB
Go

package jpush
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/tinode/chat/server/push"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
type testThirdPartyConfigStore map[string]string
func (s testThirdPartyConfigStore) Get(_ string, keys ...string) (map[string]string, error) {
result := make(map[string]string, len(keys))
for _, key := range keys {
if value, ok := s[key]; ok {
result[key] = value
}
}
return result, nil
}
type testDevicePushTokenStore struct {
devices map[types.Uid][]types.DeviceDef
calls int
}
func (s *testDevicePushTokenStore) Upsert(types.TenantID, *types.DevicePushToken) (*types.DevicePushToken, error) {
return nil, nil
}
func (s *testDevicePushTokenStore) GetAllByProvider(_ types.TenantID, _ string, uids ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) {
s.calls++
result := make(map[types.Uid][]types.DeviceDef, len(uids))
count := 0
for _, uid := range uids {
devices := s.devices[uid]
result[uid] = devices
count += len(devices)
}
return result, count, nil
}
func TestNormalizeConfigLoadsThirdPartyConfig(t *testing.T) {
oldConfig := store.ThirdPartyConfig
store.ThirdPartyConfig = testThirdPartyConfigStore{
"jpush_enabled": "1",
"jpush_app_key": "app-key",
"jpush_master_secret": "master",
"jpush_api_domain": "https://push.example.com/",
"jpush_push_path": "v3/push",
"jpush_workers": "4",
"jpush_device_cache_ttl_seconds": "45",
"jpush_default_ttl_seconds": "123",
"jpush_call_ttl_seconds": "45",
"jpush_use_alias": "false",
"jpush_classification": "1",
}
defer func() { store.ThirdPartyConfig = oldConfig }()
var config configType
loadThirdPartyConfig(&config)
config, err := normalizeConfig(config)
if err != nil {
t.Fatalf("normalize config: %v", err)
}
if !config.Enabled || config.AppKey != "app-key" || config.MasterSecret != "master" {
t.Fatalf("unexpected credentials: %#v", config)
}
if config.APIDomain != "https://push.example.com" || config.PushPath != "/v3/push" {
t.Fatalf("unexpected endpoint: %#v", config)
}
if config.Workers != 4 || config.DeviceCacheTTLSeconds != 45 {
t.Fatalf("unexpected worker/cache config: %#v", config)
}
if config.DefaultTTLSeconds != 123 || config.CallTTLSeconds != 45 || config.UseAlias == nil || *config.UseAlias {
t.Fatalf("unexpected ttl/use_alias: %#v", config)
}
}
func TestPreparePayloadsUsesAliasAndNormalizesP2PTopic(t *testing.T) {
caller := types.Uid(42)
callee := types.Uid(43)
topic := caller.P2PName(callee)
config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60}
payloads, uids := preparePayloads(&push.Receipt{
TenantID: 7,
To: map[types.Uid]push.Recipient{
callee: {Unread: 3},
},
Payload: push.Payload{
What: push.ActMsg,
Topic: topic,
From: caller.UserId(),
SeqId: 12,
ContentType: "text/plain",
Content: map[string]any{"text": "hello"},
Timestamp: time.Date(2026, 9, 4, 10, 0, 0, 0, time.UTC),
},
}, config)
if len(payloads) != 1 || len(uids) != 1 || uids[0] != callee {
t.Fatalf("unexpected payload count: %d %#v", len(payloads), uids)
}
payload := payloads[0]
if got := payload.Audience["alias"][0]; got != aliasForUid(callee) {
t.Fatalf("unexpected alias: %s", got)
}
if got := payload.Notification.Android.Extras["topic"]; got != topic {
t.Fatalf("topic was not kept canonical for callee: got %s want %s", got, topic)
}
if payload.Notification.IOS.Badge != "3" {
t.Fatalf("unexpected badge: %s", payload.Notification.IOS.Badge)
}
}
func TestAliasForUidUsesJPushSafeCharacters(t *testing.T) {
uid := types.ParseUserId("usrd6Ag5opa-ww")
if uid.IsZero() {
t.Fatal("test user id did not parse")
}
if got, want := aliasForUid(uid), "usrd6Ag5opa.ww"; got != want {
t.Fatalf("unexpected alias: got %s want %s", got, want)
}
}
func TestPreparePayloadsCanUseRegistrationID(t *testing.T) {
resetDeviceCache()
t.Cleanup(resetDeviceCache)
oldTokens := store.DevicePushTokens
uid := types.Uid(42)
store.DevicePushTokens = &testDevicePushTokenStore{devices: map[types.Uid][]types.DeviceDef{
uid: {
{DeviceId: "online-token", Platform: "ios"},
{DeviceId: "offline-token", Platform: "android"},
},
}}
defer func() { store.DevicePushTokens = oldTokens }()
useAlias := false
config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60, UseAlias: &useAlias}
payloads, _ := preparePayloads(&push.Receipt{
TenantID: 7,
To: map[types.Uid]push.Recipient{
uid: {Devices: []string{"online-token"}},
},
Payload: push.Payload{What: push.ActMsg, Topic: "grpTopic", SeqId: 9, Timestamp: time.Now()},
}, config)
if len(payloads) != 1 {
t.Fatalf("unexpected payload count: %d", len(payloads))
}
got := payloads[0].Audience["registration_id"]
if len(got) != 1 || got[0] != "offline-token" {
t.Fatalf("unexpected registration ids: %#v", got)
}
}
func TestPreparePayloadsDoesNotDropRecipientWithDeliveredSession(t *testing.T) {
resetDeviceCache()
t.Cleanup(resetDeviceCache)
oldTokens := store.DevicePushTokens
uid := types.Uid(42)
store.DevicePushTokens = &testDevicePushTokenStore{devices: map[types.Uid][]types.DeviceDef{
uid: {{DeviceId: "push-token", Platform: "ios"}},
}}
defer func() { store.DevicePushTokens = oldTokens }()
useAlias := false
config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60, UseAlias: &useAlias}
payloads, _ := preparePayloads(&push.Receipt{
TenantID: 7,
To: map[types.Uid]push.Recipient{
uid: {Delivered: 1},
},
Payload: push.Payload{What: push.ActMsg, Topic: "grpTopic", SeqId: 9, Timestamp: time.Now()},
}, config)
if len(payloads) != 1 {
t.Fatalf("unexpected payload count: %d", len(payloads))
}
if payloads[0].Message == nil || payloads[0].Message.Extras["silent"] != "true" {
t.Fatalf("expected silent payload for delivered recipient: %#v", payloads[0])
}
}
func TestPreparePayloadsCachesEmptyDeviceLookups(t *testing.T) {
resetDeviceCache()
t.Cleanup(resetDeviceCache)
oldTokens := store.DevicePushTokens
tokens := &testDevicePushTokenStore{devices: map[types.Uid][]types.DeviceDef{}}
store.DevicePushTokens = tokens
t.Cleanup(func() { store.DevicePushTokens = oldTokens })
useAlias := false
config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60,
DeviceCacheTTLSeconds: 30, UseAlias: &useAlias}
receipt := &push.Receipt{
TenantID: 7,
To: map[types.Uid]push.Recipient{42: {}, 43: {}},
Payload: push.Payload{What: push.ActMsg, Topic: "grpTopic", SeqId: 9, Timestamp: time.Now()},
}
preparePayloads(receipt, config)
preparePayloads(receipt, config)
if tokens.calls != 1 {
t.Fatalf("device store calls=%d want=1", tokens.calls)
}
}
func TestPreparePayloadsCoalescesConcurrentDeviceLookups(t *testing.T) {
resetDeviceCache()
t.Cleanup(resetDeviceCache)
oldTokens := store.DevicePushTokens
tokens := &testDevicePushTokenStore{devices: map[types.Uid][]types.DeviceDef{}}
store.DevicePushTokens = tokens
t.Cleanup(func() { store.DevicePushTokens = oldTokens })
useAlias := false
config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60,
DeviceCacheTTLSeconds: 30, UseAlias: &useAlias}
receipt := &push.Receipt{
TenantID: 7,
To: map[types.Uid]push.Recipient{42: {}, 43: {}},
Payload: push.Payload{What: push.ActMsg, Topic: "grpTopic", SeqId: 9, Timestamp: time.Now()},
}
var wg sync.WaitGroup
for range 20 {
wg.Add(1)
go func() {
defer wg.Done()
preparePayloads(receipt, config)
}()
}
wg.Wait()
if tokens.calls != 1 {
t.Fatalf("device store calls=%d want=1", tokens.calls)
}
}
func TestBuildPayloadUsesShortTTLForCalls(t *testing.T) {
expiresAt := time.Now().Add(30 * time.Second).UTC().Format(time.RFC3339Nano)
config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 60}
payload := buildPayload(map[string][]string{"alias": {"usr1"}}, "p2pTopic", map[string]string{
"type": "call_invite",
"topic": "p2pTopic",
"seq": "30",
"call_type": "audio",
"caller_display": "张三",
"expires_at": expiresAt,
}, 0, config)
if payload.Options.TimeToLive > 30 {
t.Fatalf("unexpected ttl: %d", payload.Options.TimeToLive)
}
if payload.Notification.Android.Title != "语音通话" {
t.Fatalf("unexpected title: %s", payload.Notification.Android.Title)
}
if payload.Notification.IOS.Category != callCategory {
t.Fatalf("unexpected iOS category: %s", payload.Notification.IOS.Category)
}
if payload.Notification.IOS.ContentAvailable != 1 {
t.Fatalf("unexpected iOS content-available: %d", payload.Notification.IOS.ContentAvailable)
}
if payload.Notification.IOS.Alert.Body != "张三邀请你语音通话" {
t.Fatalf("unexpected body: %s", payload.Notification.IOS.Alert.Body)
}
}
func TestPushDataExtractsCallFields(t *testing.T) {
expiresAt := time.Date(2026, 9, 4, 10, 0, 30, 0, time.UTC)
data, err := pushData(push.Payload{
What: push.ActMsg,
Topic: "p2pTopic",
From: "usrCaller",
SeqId: 30,
Webrtc: "started",
ExpiresAt: &expiresAt,
Content: map[string]any{
"callId": "call_1",
"callType": "audio",
"caller_display": "张三",
},
Timestamp: time.Date(2026, 9, 4, 10, 0, 0, 0, time.UTC),
})
if err != nil {
t.Fatalf("push data: %v", err)
}
if data["type"] != "call_invite" || data["call_id"] != "call_1" || data["call_type"] != "audio" || data["caller_id"] != "usrCaller" {
t.Fatalf("unexpected call data: %#v", data)
}
if data["expires_at"] != expiresAt.Format(time.RFC3339Nano) {
t.Fatalf("unexpected expires_at: %#v", data)
}
if data["caller_display"] != "张三" {
t.Fatalf("unexpected caller_display: %#v", data)
}
}
func TestPreparePayloadsDropsExpiredCallInvite(t *testing.T) {
uid := types.Uid(42)
expiresAt := time.Now().Add(-time.Second)
config := &configType{Title: "MeetingIM", DefaultTTLSeconds: 86400, CallTTLSeconds: 30}
payloads, uids := preparePayloads(&push.Receipt{
TenantID: 7,
To: map[types.Uid]push.Recipient{
uid: {},
},
Payload: push.Payload{
What: push.ActMsg,
Topic: "p2pTopic",
SeqId: 30,
Webrtc: "started",
ExpiresAt: &expiresAt,
Timestamp: time.Now().Add(-time.Minute),
},
}, config)
if len(payloads) != 0 || len(uids) != 0 {
t.Fatalf("expired call invite should be dropped: payloads=%d uids=%#v", len(payloads), uids)
}
}
func TestSendPayloadUsesBasicAuth(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
if r.URL.Path != "/v3/push" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
var payload jpushPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("decode payload: %v", err)
}
if payload.Audience["alias"][0] != "usr1" {
t.Fatalf("unexpected audience: %#v", payload.Audience)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"sendno":"0","msg_id":"1"}`))
}))
defer srv.Close()
config := &configType{AppKey: "key", MasterSecret: "secret", APIDomain: srv.URL, PushPath: "/v3/push"}
err := sendPayload(&jpushPayload{
Platform: "all",
Audience: map[string][]string{"alias": {"usr1"}},
Message: &jpushMessage{MsgContent: "hello"},
Options: jpushOptions{TimeToLive: 60},
}, config, srv.Client())
if err != nil {
t.Fatalf("send payload: %v", err)
}
wantAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("key:secret"))
if gotAuth != wantAuth {
t.Fatalf("unexpected auth: got %s want %s", gotAuth, wantAuth)
}
}