package jpush import ( "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "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 } func (s *testDevicePushTokenStore) Upsert(types.TenantID, *types.DevicePushToken) (*types.DevicePushToken, error) { return nil, nil } func (s *testDevicePushTokenStore) GetAllByProvider(types.TenantID, string, ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) { count := 0 for _, devices := range s.devices { count += len(devices) } return s.devices, 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_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.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) { 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) { 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 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) } }