Files
im_server/server/realtime_dispatcher_test.go
T

485 lines
14 KiB
Go

package main
import (
"encoding/json"
"sync"
"testing"
"time"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/types"
)
func TestRealtimeEnvelopeDeliversToUserSessions(t *testing.T) {
oldStore := globals.sessionStore
defer func() { globals.sessionStore = oldStore }()
tenantID := types.TenantID(1)
uid := types.Uid(1001)
globals.sessionStore = realtimeTestSessionStore(
realtimeTestSession("sid-1", tenantID, uid, ""),
realtimeTestSession("sid-2", tenantID, uid, ""),
realtimeTestSession("sid-other", tenantID, types.Uid(1002), ""),
)
msg := &ServerComMessage{
Data: &MsgServerData{Topic: uid.UserId(), From: types.Uid(1002).UserId(), SeqId: 12, Timestamp: time.Now()},
}
raw, err := json.Marshal(msg)
if err != nil {
t.Fatal(err)
}
deliverRealtimeFanoutEnvelope(&store.RealtimeFanoutEnvelope{
TenantID: tenantID,
Kind: realtimeKindMessage,
TargetType: realtimeTargetUser,
Target: uid.UserId(),
Message: raw,
})
for _, sid := range []string{"sid-1", "sid-2"} {
sess := globals.sessionStore.Get(sid)
select {
case got := <-sess.send:
if got.(*ServerComMessage).TenantID != tenantID {
t.Fatalf("tenant not restored for %s", sid)
}
default:
t.Fatalf("expected delivery to %s", sid)
}
}
if len(globals.sessionStore.Get("sid-other").send) != 0 {
t.Fatal("unexpected delivery to other user")
}
}
func TestRealtimeUserPresenceSkipsSessionsAttachedToTopic(t *testing.T) {
oldStore := globals.sessionStore
defer func() { globals.sessionStore = oldStore }()
tenantID := types.TenantID(1)
sender := types.Uid(1001)
recipient := types.Uid(1002)
topic := sender.P2PName(recipient)
if topic == "" {
t.Fatal("failed to create p2p topic")
}
visibleTopic, err := types.P2PNameForUser(recipient, topic)
if err != nil || visibleTopic == "" {
t.Fatalf("failed to create visible topic: %v", err)
}
globals.sessionStore = realtimeTestSessionStore(
realtimeTestSession("sid-attached", tenantID, recipient, visibleTopic),
realtimeTestSession("sid-me-only", tenantID, recipient, ""),
)
msg := &ServerComMessage{
Pres: &MsgServerPres{
Topic: "me",
Src: topic,
What: "msg",
SeqId: 35,
SkipTopic: topic,
},
}
raw, err := json.Marshal(msg)
if err != nil {
t.Fatal(err)
}
deliverRealtimeFanoutEnvelope(&store.RealtimeFanoutEnvelope{
TenantID: tenantID,
Kind: realtimeKindMessage,
TargetType: realtimeTargetUser,
Target: recipient.UserId(),
SkipTopic: topic,
Message: raw,
})
if len(globals.sessionStore.Get("sid-attached").send) != 0 {
t.Fatal("attached p2p session must not receive duplicate me presence")
}
select {
case got := <-globals.sessionStore.Get("sid-me-only").send:
pres := got.(*ServerComMessage).Pres
if pres == nil || pres.Topic != "me" || pres.What != "msg" || pres.Src != topic || pres.SeqId != 35 {
t.Fatalf("unexpected presence: %#v", pres)
}
default:
t.Fatal("me-only session must receive message presence")
}
}
func TestRealtimeGroupUnreadBatchPreservesDetachedMultiDeviceNotification(t *testing.T) {
oldStore := globals.sessionStore
defer func() { globals.sessionStore = oldStore }()
tenantID := types.TenantID(1)
recipient := types.Uid(1002)
other := types.Uid(1003)
topic := "grpUnreadBatchTest"
globals.sessionStore = realtimeTestSessionStore(
realtimeTestSession("sid-attached", tenantID, recipient, topic),
realtimeTestSession("sid-detached-1", tenantID, recipient, ""),
realtimeTestSession("sid-detached-2", tenantID, recipient, ""),
realtimeTestSession("sid-other", tenantID, other, ""),
)
payload := realtimeGroupUnreadMessage{
Topic: topic,
Actor: types.Uid(1001).UserId(),
SeqID: 51,
Recipients: []realtimeGroupUnreadRecipient{
{User: recipient.UserId(), Source: topic},
},
}
raw, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
deliverRealtimeFanoutEnvelope(&store.RealtimeFanoutEnvelope{
TenantID: tenantID,
Kind: realtimeKindGroupUnread,
TargetType: realtimeTargetUsers,
Target: topic,
Message: raw,
})
if len(globals.sessionStore.Get("sid-attached").send) != 0 {
t.Fatal("attached session must rely on the topic data broadcast")
}
for _, sid := range []string{"sid-detached-1", "sid-detached-2"} {
select {
case got := <-globals.sessionStore.Get(sid).send:
pres := got.(*ServerComMessage).Pres
if pres == nil || pres.What != "msg" || pres.Src != topic || pres.SeqId != 51 || pres.SkipTopic != topic {
t.Fatalf("%s got unexpected presence: %#v", sid, pres)
}
default:
t.Fatalf("detached session %s must receive unread presence", sid)
}
}
if len(globals.sessionStore.Get("sid-other").send) != 0 {
t.Fatal("non-recipient session received unread presence")
}
}
func TestRealtimeP2PTopicFallbackRewritesVisibleTopic(t *testing.T) {
oldStore := globals.sessionStore
oldHub := globals.hub
defer func() {
globals.sessionStore = oldStore
globals.hub = oldHub
}()
tenantID := types.TenantID(1)
uid1 := types.Uid(1001)
uid2 := types.Uid(1002)
topic := uid1.P2PName(uid2)
if topic == "" {
t.Fatal("failed to create p2p topic")
}
globals.hub = nil
globals.sessionStore = realtimeTestSessionStore(
realtimeTestSession("sid-u1", tenantID, uid1, uid2.UserId()),
realtimeTestSession("sid-u2", tenantID, uid2, uid1.UserId()),
)
msg := &ServerComMessage{
Data: &MsgServerData{Topic: topic, From: uid1.UserId(), SeqId: 13, Timestamp: time.Now()},
}
raw, err := json.Marshal(msg)
if err != nil {
t.Fatal(err)
}
deliverRealtimeFanoutEnvelope(&store.RealtimeFanoutEnvelope{
TenantID: tenantID,
Kind: realtimeKindMessage,
TargetType: realtimeTargetTopic,
Target: topic,
Message: raw,
})
expectTopic := map[string]string{"sid-u1": uid2.UserId(), "sid-u2": uid1.UserId()}
for sid, visibleTopic := range expectTopic {
sess := globals.sessionStore.Get(sid)
select {
case got := <-sess.send:
data := got.(*ServerComMessage).Data
if data.Topic != visibleTopic {
t.Fatalf("%s got topic %q, want %q", sid, data.Topic, visibleTopic)
}
default:
t.Fatalf("expected p2p delivery to %s", sid)
}
}
}
func TestRealtimeSessionControlStaleEvictDropped(t *testing.T) {
oldStore := globals.sessionStore
defer func() { globals.sessionStore = oldStore }()
tenantID := types.TenantID(1)
uid := types.Uid(1001)
globals.sessionStore = realtimeTestSessionStore(
realtimeTestPlatformSession("sid-live", tenantID, uid, "web"),
)
envelope := func(ts time.Time) *store.RealtimeFanoutEnvelope {
msg := &ServerComMessage{
Ctrl: &MsgServerCtrl{
Code: 205,
Text: "evicted",
Timestamp: ts,
Params: map[string]any{"reason": "replaced_by_new_login"},
},
}
raw, err := json.Marshal(msg)
if err != nil {
t.Fatal(err)
}
return &store.RealtimeFanoutEnvelope{
TenantID: tenantID,
Kind: realtimeKindSessionControl,
TargetType: realtimeTargetPlatf,
Target: uid.UserId() + ":web",
SkipSid: "sid-new",
Message: raw,
}
}
// A stale replay (old skipSid != current session sid) must not evict.
deliverRealtimeFanoutEnvelope(envelope(time.Now().Add(-2 * fanoutSessionControlStaleLimit)))
if globals.sessionStore.Get("sid-live") == nil {
t.Fatal("stale evict must not terminate the session")
}
// A fresh evict must evict as usual.
deliverRealtimeFanoutEnvelope(envelope(time.Now()))
if globals.sessionStore.Get("sid-live") != nil {
t.Fatal("fresh evict must terminate the session")
}
}
func TestRealtimePlatformEvictFanoutCoalescesToLatestSession(t *testing.T) {
oldPublish := publishRealtimeFanout
t.Cleanup(func() {
publishRealtimeFanout = oldPublish
resetPlatformEvictFanoutForTest()
})
resetPlatformEvictFanoutForTest()
type call struct {
target string
skipSid string
dedupKey string
}
calls := make(chan call, 3)
publishRealtimeFanout = func(_ string, _ types.TenantID, kind, targetType, target, skipSid, dedupKey string, _ any) error {
if kind == realtimeKindSessionControl && targetType == realtimeTargetPlatf {
calls <- call{target: target, skipSid: skipSid, dedupKey: dedupKey}
}
return nil
}
tenantID := types.TenantID(1)
uid := types.Uid(1001)
publishRealtimeEvictUserPlatform(tenantID, uid, "web", "replaced_by_new_login", "sid-old")
publishRealtimeEvictUserPlatform(tenantID, uid, "web", "replaced_by_new_login", "sid-new")
publishRealtimeEvictUserPlatform(tenantID, uid, "web", "replaced_by_new_login", "sid-final")
select {
case got := <-calls:
t.Fatalf("evict fanout published before debounce: %#v", got)
case <-time.After(platformEvictFanoutDebounce / 2):
}
select {
case got := <-calls:
if got.target != uid.UserId()+":web" {
t.Fatalf("target = %q", got.target)
}
if got.skipSid != "sid-final" {
t.Fatalf("skipSid = %q, want latest", got.skipSid)
}
if got.dedupKey != "evict:platform:"+uid.UserId()+":web:sid-final" {
t.Fatalf("dedupKey = %q", got.dedupKey)
}
case <-time.After(2 * platformEvictFanoutDebounce):
t.Fatal("timed out waiting for coalesced evict fanout")
}
select {
case got := <-calls:
t.Fatalf("expected one coalesced fanout, got extra %#v", got)
default:
}
}
func TestRealtimeP2PTopicFallbackRewritesVisibleInfoTopic(t *testing.T) {
oldStore := globals.sessionStore
oldHub := globals.hub
defer func() {
globals.sessionStore = oldStore
globals.hub = oldHub
}()
tenantID := types.TenantID(1)
uid1 := types.Uid(1001)
uid2 := types.Uid(1002)
topic := uid1.P2PName(uid2)
if topic == "" {
t.Fatal("failed to create p2p topic")
}
globals.hub = nil
globals.sessionStore = realtimeTestSessionStore(
realtimeTestSession("sid-u1", tenantID, uid1, uid2.UserId()),
realtimeTestSession("sid-u2", tenantID, uid2, uid1.UserId()),
)
msg := &ServerComMessage{
Info: &MsgServerInfo{Topic: topic, From: uid1.UserId(), What: "read", SeqId: 13},
}
raw, err := json.Marshal(msg)
if err != nil {
t.Fatal(err)
}
deliverRealtimeFanoutEnvelope(&store.RealtimeFanoutEnvelope{
TenantID: tenantID,
Kind: realtimeKindMessage,
TargetType: realtimeTargetTopic,
Target: topic,
Message: raw,
})
expectTopic := map[string]string{"sid-u1": uid2.UserId(), "sid-u2": uid1.UserId()}
for sid, visibleTopic := range expectTopic {
sess := globals.sessionStore.Get(sid)
select {
case got := <-sess.send:
info := got.(*ServerComMessage).Info
if info.Topic != visibleTopic {
t.Fatalf("%s got info topic %q, want %q", sid, info.Topic, visibleTopic)
}
if info.What != "read" || info.SeqId != 13 || info.From != uid1.UserId() {
t.Fatalf("%s got unexpected info %#v", sid, info)
}
default:
t.Fatalf("expected p2p info delivery to %s", sid)
}
}
}
func resetPlatformEvictFanoutForTest() {
platformEvictFanout.Lock()
defer platformEvictFanout.Unlock()
for key, pending := range platformEvictFanout.pending {
if pending.timer != nil {
pending.timer.Stop()
}
delete(platformEvictFanout.pending, key)
}
}
func TestRealtimeCallEventRoutesToLocalTopic(t *testing.T) {
oldHub := globals.hub
defer func() { globals.hub = oldHub }()
tenantID := types.TenantID(1)
caller := types.Uid(1001)
callee := types.Uid(1002)
topic := caller.P2PName(callee)
if topic == "" {
t.Fatal("failed to create p2p topic")
}
topics := &sync.Map{}
topics.Store(types.TopicKey{TenantID: tenantID, Topic: topic}, &Topic{name: topic, tenantID: tenantID})
globals.hub = &Hub{
topics: topics,
routeCli: make(chan *ClientComMessage, 1),
}
payload := json.RawMessage(`{"callId":"call-1"}`)
event := realtimeCallEventMessage{
Topic: topic,
From: callee.UserId(),
Sid: "callee-sid",
Event: constCallEventAccept,
Seq: 12,
Payload: payload,
}
raw, err := json.Marshal(event)
if err != nil {
t.Fatal(err)
}
deliverRealtimeFanoutEnvelope(&store.RealtimeFanoutEnvelope{
TenantID: tenantID,
Kind: realtimeKindCallEvent,
TargetType: realtimeTargetTopic,
Target: topic,
Message: raw,
})
select {
case got := <-globals.hub.routeCli:
if got.TenantID != tenantID || got.RcptTo != topic || got.AsUser != callee.UserId() {
t.Fatalf("unexpected routed message: tenant=%d rcpt=%q as=%q", got.TenantID, got.RcptTo, got.AsUser)
}
if got.Note == nil || got.Note.What != "call" || got.Note.Event != constCallEventAccept || got.Note.SeqId != 12 {
t.Fatalf("unexpected call note: %#v", got.Note)
}
if string(got.Note.Payload) != string(payload) {
t.Fatalf("payload = %s, want %s", got.Note.Payload, payload)
}
if got.sess == nil || got.sess.uid != callee || got.sess.sid != "redis-call:callee-sid" {
t.Fatalf("unexpected synthetic session: %#v", got.sess)
}
default:
t.Fatal("expected call event to be routed to local topic")
}
}
func TestRealtimeInfoDedupKeySeparatesReadRecv(t *testing.T) {
read := realtimeMessageDedupKey("p2pABC", &ServerComMessage{
Info: &MsgServerInfo{From: "usrA", What: "read", SeqId: 12},
})
recv := realtimeMessageDedupKey("p2pABC", &ServerComMessage{
Info: &MsgServerInfo{From: "usrA", What: "recv", SeqId: 12},
})
if read == recv {
t.Fatalf("read and recv info dedup keys must differ: %q", read)
}
if read != "info:p2pABC:read:usrA::12" {
t.Fatalf("unexpected read dedup key %q", read)
}
}
func realtimeTestSessionStore(sessions ...*Session) *SessionStore {
ss := NewSessionStore(time.Minute)
for _, sess := range sessions {
ss.sessCache[sess.sid] = sess
}
return ss
}
func realtimeTestSession(sid string, tenantID types.TenantID, uid types.Uid, sub string) *Session {
sess := &Session{
sid: sid,
tenantID: tenantID,
uid: uid,
send: make(chan any, 4),
stop: make(chan any, 1),
subs: make(map[string]*Subscription),
}
if sub != "" {
sess.subs[sub] = &Subscription{}
}
return sess
}
func realtimeTestPlatformSession(sid string, tenantID types.TenantID, uid types.Uid, platf string) *Session {
sess := realtimeTestSession(sid, tenantID, uid, "")
sess.platf = platf
return sess
}