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 }