package store import ( "context" "encoding/json" "errors" "strconv" "strings" "testing" "time" "github.com/tinode/chat/server/store/types" ) type testGroupMessageRuntime struct { claims map[string]*groupMessageUniqueRecord seqs map[string]int64 shortStates map[string]bool shortValues map[string]string objects map[string][]byte sets map[string]map[string]bool deletedPrefixes []string deletedObjects []string } func newTestGroupMessageRuntime() *testGroupMessageRuntime { return &testGroupMessageRuntime{ claims: make(map[string]*groupMessageUniqueRecord), seqs: make(map[string]int64), shortStates: make(map[string]bool), shortValues: make(map[string]string), objects: make(map[string][]byte), sets: make(map[string]map[string]bool), } } func (r *testGroupMessageRuntime) Close() error { return nil } func (r *testGroupMessageRuntime) Claim(tenantID types.TenantID, topic string, from types.Uid, clientMsgID string) (*groupMessageUniqueRecord, bool, error) { key := topic + ":" + from.UserId() + ":" + clientMsgID if rec := r.claims[key]; rec != nil { return rec, false, nil } rec := &groupMessageUniqueRecord{Status: "pending", CreatedAt: time.Now().UTC()} r.claims[key] = rec return rec, true, nil } func (r *testGroupMessageRuntime) NextSeq(_ types.TenantID, topic string, baseline int64, candidateMonth string) (int64, string, error) { if r.seqs[topic] < baseline { r.seqs[topic] = baseline } r.seqs[topic]++ return r.seqs[topic], candidateMonth, nil } func (r *testGroupMessageRuntime) PutShardRoute(types.TenantID, string, GroupMessageShardRoute) error { return nil } func (r *testGroupMessageRuntime) GetShardRoutes(types.TenantID, string) ([]GroupMessageShardRoute, bool, error) { return nil, false, nil } func (r *testGroupMessageRuntime) DeleteShardRoutes(types.TenantID, string) error { return nil } func (r *testGroupMessageRuntime) PeekSeq(_ types.TenantID, topic string) (int64, error) { return r.seqs[topic], nil } func (r *testGroupMessageRuntime) BumpSeqFloor(_ types.TenantID, topic string, seq int64) error { if r.seqs[topic] < seq { r.seqs[topic] = seq } return nil } func (r *testGroupMessageRuntime) MarkDone(_ types.TenantID, topic string, from types.Uid, clientMsgID string, seq int64, msgID string) error { key := topic + ":" + from.UserId() + ":" + clientMsgID r.claims[key] = &groupMessageUniqueRecord{Status: "done", Seq: seq, MessageID: msgID, CreatedAt: time.Now().UTC()} return nil } func (r *testGroupMessageRuntime) MarkPending(_ types.TenantID, topic string, from types.Uid, clientMsgID string, seq int64) error { key := topic + ":" + from.UserId() + ":" + clientMsgID r.claims[key] = &groupMessageUniqueRecord{Status: "pending", Seq: seq, CreatedAt: time.Now().UTC()} return nil } func (r *testGroupMessageRuntime) MarkFailed(_ types.TenantID, topic string, from types.Uid, clientMsgID string) error { key := topic + ":" + from.UserId() + ":" + clientMsgID r.claims[key] = &groupMessageUniqueRecord{Status: "failed", CreatedAt: time.Now().UTC()} return nil } func (r *testGroupMessageRuntime) CacheHotMessage(types.TenantID, *types.Message) error { return nil } func (r *testGroupMessageRuntime) CachePendingHotMessage(types.TenantID, *types.Message) error { return nil } func (r *testGroupMessageRuntime) GetHotMessages(types.TenantID, string, *types.QueryOpt, int) ([]types.Message, bool, error) { return nil, false, nil } func (r *testGroupMessageRuntime) InvalidateHotMessages(types.TenantID, string, []types.Range) error { return nil } func (r *testGroupMessageRuntime) MarkUserOnline(types.TenantID, types.Uid, string, string, string, string) error { return nil } func (r *testGroupMessageRuntime) RefreshUserPresence(types.TenantID, types.Uid, string, string) error { return nil } func (r *testGroupMessageRuntime) MarkUserOffline(types.TenantID, types.Uid, string, string) (int, error) { return 0, nil } func (r *testGroupMessageRuntime) GetUserPresence(types.TenantID, types.Uid) (*UserPresence, error) { return nil, nil } func (r *testGroupMessageRuntime) MarkTopicMemberOnline(types.TenantID, string, types.Uid, string) error { return nil } func (r *testGroupMessageRuntime) MarkTopicMemberOffline(types.TenantID, string, types.Uid, string) error { return nil } func (r *testGroupMessageRuntime) GetTopicNodeOnlineMembers(types.TenantID, string, string) ([]types.Uid, error) { return nil, nil } func (r *testGroupMessageRuntime) PublishGroupFanout(string, any) error { return nil } func (r *testGroupMessageRuntime) StartGroupFanoutSubscriber(context.Context, string, func([]byte)) error { return nil } func (r *testGroupMessageRuntime) PublishRealtimeFanout(string, types.TenantID, string, string, string, string, string, any) error { return nil } func (r *testGroupMessageRuntime) StartRealtimeFanoutSubscriber(context.Context, string, func(*RealtimeFanoutEnvelope)) error { return nil } func (r *testGroupMessageRuntime) CacheObject(tenantID types.TenantID, namespace, id string, value any) error { payload, err := json.Marshal(value) if err != nil { return err } r.objects[strconv.FormatInt(int64(tenantID), 10)+":"+namespace+":"+id] = payload return nil } func (r *testGroupMessageRuntime) GetCachedObject(tenantID types.TenantID, namespace, id string, value any) (bool, error) { payload, ok := r.objects[strconv.FormatInt(int64(tenantID), 10)+":"+namespace+":"+id] if !ok { return false, nil } return true, json.Unmarshal(payload, value) } func (r *testGroupMessageRuntime) DeleteCachedObject(tenantID types.TenantID, namespace, id string) error { r.deletedObjects = append(r.deletedObjects, namespace+":"+id) delete(r.objects, strconv.FormatInt(int64(tenantID), 10)+":"+namespace+":"+id) return nil } func (r *testGroupMessageRuntime) DeleteCachedObjectsByPrefix(tenantID types.TenantID, namespace, prefix string) error { r.deletedPrefixes = append(r.deletedPrefixes, prefix) cachePrefix := strconv.FormatInt(int64(tenantID), 10) + ":" + namespace + ":" + prefix for key := range r.objects { if strings.HasPrefix(key, cachePrefix) { delete(r.objects, key) } } return nil } func (r *testGroupMessageRuntime) AddCachedSetMember(tenantID types.TenantID, namespace, id, member string) error { key := strconv.FormatInt(int64(tenantID), 10) + ":" + namespace + ":" + id if r.sets[key] == nil { r.sets[key] = make(map[string]bool) } r.sets[key][member] = true return nil } func (r *testGroupMessageRuntime) RemoveCachedSetMember(tenantID types.TenantID, namespace, id, member string) error { key := strconv.FormatInt(int64(tenantID), 10) + ":" + namespace + ":" + id delete(r.sets[key], member) return nil } func (r *testGroupMessageRuntime) CachedSetContains(tenantID types.TenantID, namespace, id, member string) (bool, error) { key := strconv.FormatInt(int64(tenantID), 10) + ":" + namespace + ":" + id return r.sets[key][member], nil } func (r *testGroupMessageRuntime) AllowRate(types.TenantID, string, int, time.Duration) (bool, int, time.Duration, error) { return true, 0, 0, nil } func (r *testGroupMessageRuntime) ClaimShortState(namespace, id string, _ time.Duration) (bool, error) { key := namespace + ":" + id if r.shortStates[key] { return false, nil } r.shortStates[key] = true return true, nil } func (r *testGroupMessageRuntime) ClaimShortStateValue(namespace, id, value string, _ time.Duration) (bool, bool, error) { key := namespace + ":" + id if existing, ok := r.shortValues[key]; ok { return false, existing == value, nil } r.shortValues[key] = value return true, false, nil } func (r *testGroupMessageRuntime) SetShortStateValue(namespace, id, value string, _ time.Duration) error { r.shortValues[namespace+":"+id] = value return nil } func (r *testGroupMessageRuntime) GetShortStateValue(namespace, id string) (string, bool, error) { value, ok := r.shortValues[namespace+":"+id] return value, ok, nil } func (r *testGroupMessageRuntime) DeleteShortState(namespace, id string) error { delete(r.shortStates, namespace+":"+id) delete(r.shortValues, namespace+":"+id) return nil } func (r *testGroupMessageRuntime) ShortStateExists(namespace, id string) (bool, error) { return r.shortStates[namespace+":"+id], nil } func (r *testGroupMessageRuntime) StoreLoginSession([]byte, LoginSession, time.Duration) error { return nil } func TestLoginTokenRevocation(t *testing.T) { oldRuntime := groupMsgRuntime groupMsgRuntime = newTestGroupMessageRuntime() t.Cleanup(func() { groupMsgRuntime = oldRuntime }) token := []byte("current-device-token") if revoked, enabled, err := IsLoginTokenRevoked(7, token); err != nil || !enabled || revoked { t.Fatalf("unexpected initial state: revoked=%v enabled=%v err=%v", revoked, enabled, err) } if enabled, err := RevokeLoginToken(7, token, time.Hour); err != nil || !enabled { t.Fatalf("revoke token: enabled=%v err=%v", enabled, err) } if revoked, enabled, err := IsLoginTokenRevoked(7, token); err != nil || !enabled || !revoked { t.Fatalf("revoked token was accepted: revoked=%v enabled=%v err=%v", revoked, enabled, err) } if revoked, _, err := IsLoginTokenRevoked(7, []byte("other-device-token")); err != nil || revoked { t.Fatalf("other device token was revoked: revoked=%v err=%v", revoked, err) } } func TestGroupMemberPageCacheRoundTripAndInvalidate(t *testing.T) { oldRuntime := groupMsgRuntime runtime := newTestGroupMessageRuntime() groupMsgRuntime = runtime t.Cleanup(func() { groupMsgRuntime = oldRuntime }) opts := types.GroupMemberListOptions{ Role: "member", Query: " alice ", Cursor: "42", Limit: 20, Mentionable: true, } key := groupMemberPageCacheID("grpAlpha", opts) if !strings.HasPrefix(key, "grpAlpha|") { t.Fatalf("cache key must be topic-prefixed for invalidation: %q", key) } CacheGroupMemberPage(7, key, groupMemberPageCache{ Items: []groupMemberCache{groupMemberCacheFromType(&types.GroupMember{ ID: 43, Topic: "grpAlpha", UserID: types.Uid(100), Role: types.GroupMemberRoleMember, })}, NextCursor: "43", }) cached, ok := GetCachedGroupMemberPage(7, key) if !ok || cached.NextCursor != "43" || len(cached.Items) != 1 || cached.Items[0].UserID != types.Uid(100).UserId() { t.Fatalf("unexpected cached page: ok=%v page=%#v", ok, cached) } invalidateGroupMemberPages(7, "grpAlpha") if _, ok := GetCachedGroupMemberPage(7, key); ok { t.Fatalf("group member page cache survived invalidation") } if len(runtime.deletedPrefixes) != 1 || runtime.deletedPrefixes[0] != "grpAlpha|" { t.Fatalf("unexpected invalidation prefixes: %#v", runtime.deletedPrefixes) } } func TestRealtimeFanoutDedupClaimIDIsNodeScoped(t *testing.T) { tenantID := types.TenantID(7) dedupKey := "data:p2pABC:42" nodeA := realtimeFanoutDedupClaimID(tenantID, "pod-a", dedupKey) nodeB := realtimeFanoutDedupClaimID(tenantID, "pod-b", dedupKey) if nodeA == nodeB { t.Fatalf("dedup claim id must be node-scoped: %q", nodeA) } if got, want := realtimeFanoutDedupClaimID(tenantID, "", dedupKey), "7:local:data:p2pABC:42"; got != want { t.Fatalf("unexpected local fallback claim id: got %q want %q", got, want) } } func TestMessageRuntimeSeqAndClientMidIdempotency(t *testing.T) { oldRuntime := groupMsgRuntime runtime := newTestGroupMessageRuntime() groupMsgRuntime = runtime t.Cleanup(func() { groupMsgRuntime = oldRuntime }) tenantID := types.TenantID(7) topic := types.Uid(1).P2PName(types.Uid(2)) from := types.Uid(1) claim, claimed, ready, err := ClaimMessageUnique(tenantID, topic, from, "mid-1") if err != nil || !ready || !claimed || claim == nil || claim.Status != "pending" { t.Fatalf("unexpected first claim: claim=%#v claimed=%v ready=%v err=%v", claim, claimed, ready, err) } seq, month, ready, err := ReserveMessageSeq(tenantID, topic, 184, "202609") if err != nil || !ready || seq != 185 || month != "202609" { t.Fatalf("unexpected first seq: seq=%d month=%s ready=%v err=%v", seq, month, ready, err) } if err = MarkMessageUniqueDone(tenantID, topic, from, "mid-1", seq, ""); err != nil { t.Fatalf("mark done: %v", err) } claim, claimed, ready, err = ClaimMessageUnique(tenantID, topic, from, "mid-1") if err != nil || !ready || claimed || claim == nil || claim.Seq != 185 { t.Fatalf("unexpected duplicate claim: claim=%#v claimed=%v ready=%v err=%v", claim, claimed, ready, err) } claim, claimed, ready, err = ClaimMessageUnique(tenantID, topic, from, "mid-2") if err != nil || !ready || !claimed || claim == nil { t.Fatalf("unexpected second claim: claim=%#v claimed=%v ready=%v err=%v", claim, claimed, ready, err) } seq, month, ready, err = ReserveMessageSeq(tenantID, topic, 184, "202609") if err != nil || !ready || seq != 186 || month != "202609" { t.Fatalf("unexpected second seq: seq=%d month=%s ready=%v err=%v", seq, month, ready, err) } } func TestCanSendP2PUsesWarmRelationshipCache(t *testing.T) { oldRuntime := groupMsgRuntime runtime := newTestGroupMessageRuntime() groupMsgRuntime = runtime t.Cleanup(func() { groupMsgRuntime = oldRuntime }) tenantID := types.TenantID(7) userID := types.Uid(42) peerID := types.Uid(43) cacheFriendPair(tenantID, userID, peerID) allowed, relationship, err := (relationsMapper{}).CanSendP2P(tenantID, userID, peerID) if err != nil || !allowed || relationship != types.RelationshipFriend { t.Fatalf("warm friend cache was not used: allowed=%v relationship=%q err=%v", allowed, relationship, err) } cacheBlockOneWay(tenantID, peerID, userID) allowed, relationship, err = (relationsMapper{}).CanSendP2P(tenantID, userID, peerID) if err != nil || allowed || relationship != types.RelationshipBlockedByPeer { t.Fatalf("reverse block cache was not enforced: allowed=%v relationship=%q err=%v", allowed, relationship, err) } } func TestBumpMessageSeqFloorNeverMovesBackward(t *testing.T) { oldRuntime := groupMsgRuntime runtime := newTestGroupMessageRuntime() groupMsgRuntime = runtime t.Cleanup(func() { groupMsgRuntime = oldRuntime }) tenantID := types.TenantID(7) topic := types.Uid(1).P2PName(types.Uid(2)) seq, _, ready, err := ReserveMessageSeq(tenantID, topic, 0, "") if err != nil || !ready || seq != 1 { t.Fatalf("unexpected first seq: seq=%d ready=%v err=%v", seq, ready, err) } if err = BumpMessageSeqFloor(tenantID, topic, 5); err != nil { t.Fatalf("bump floor: %v", err) } if current, err := runtime.PeekSeq(tenantID, topic); err != nil || current != 5 { t.Fatalf("floor was not applied: current=%d err=%v", current, err) } if err = BumpMessageSeqFloor(tenantID, topic, 3); err != nil { t.Fatalf("lower bump: %v", err) } if current, err := runtime.PeekSeq(tenantID, topic); err != nil || current != 5 { t.Fatalf("floor must never move backward: current=%d err=%v", current, err) } // The next allocation must continue above the bumped floor. if seq, _, _, err := ReserveMessageSeq(tenantID, topic, 0, ""); err != nil || seq != 6 { t.Fatalf("unexpected seq after bump: seq=%d err=%v", seq, err) } } func TestInvalidateGroupMemberPagesUsesTopicKeyPrefix(t *testing.T) { oldRuntime := groupMsgRuntime runtime := newTestGroupMessageRuntime() groupMsgRuntime = runtime t.Cleanup(func() { groupMsgRuntime = oldRuntime }) invalidateGroupMemberPages(7, "grpAlpha") if len(runtime.deletedPrefixes) != 1 || runtime.deletedPrefixes[0] != "grpAlpha|" { t.Fatalf("unexpected deleted prefixes: %#v", runtime.deletedPrefixes) } } func TestInvalidateGroupSettingsUsesTopicCacheKey(t *testing.T) { oldRuntime := groupMsgRuntime runtime := newTestGroupMessageRuntime() groupMsgRuntime = runtime t.Cleanup(func() { groupMsgRuntime = oldRuntime }) invalidateGroupSettings(7, " grpAlpha ") if len(runtime.deletedObjects) != 1 || runtime.deletedObjects[0] != "group_settings:grpAlpha" { t.Fatalf("unexpected deleted objects: %#v", runtime.deletedObjects) } } func TestGetGroupMuteUsesPositiveAndNegativeCache(t *testing.T) { oldRuntime := groupMsgRuntime runtime := newTestGroupMessageRuntime() groupMsgRuntime = runtime t.Cleanup(func() { groupMsgRuntime = oldRuntime }) tenantID := types.TenantID(7) topic := "grpAlpha" uid := types.Uid(42) cacheID := groupMemberCacheID(topic, uid) expires := time.Now().UTC().Add(time.Minute) want := &types.GroupMute{TenantID: tenantID, Topic: topic, UserID: uid, Status: types.GroupMuteStatusActive, ExpiresAt: &expires} if err := CacheObject(tenantID, cacheNamespaceGroupMute, cacheID, groupMuteCacheValue{Found: true, Mute: want}); err != nil { t.Fatalf("cache positive mute: %v", err) } got, err := (groupBusinessMapper{}).GetMute(tenantID, topic, uid) if err != nil || got == nil || got.UserID != uid || got.Status != types.GroupMuteStatusActive { t.Fatalf("positive cache result: mute=%#v err=%v", got, err) } if err = CacheObject(tenantID, cacheNamespaceGroupMute, cacheID, groupMuteCacheValue{}); err != nil { t.Fatalf("cache negative mute: %v", err) } if got, err = (groupBusinessMapper{}).GetMute(tenantID, topic, uid); !errors.Is(err, types.ErrNotFound) || got != nil { t.Fatalf("negative cache result: mute=%#v err=%v", got, err) } } func TestTopicAndSubscriptionCacheTTLIsShort(t *testing.T) { runtime := &redisGroupMessageRuntime{cfg: redisConfig{CacheTTL: 300, ShortTTL: 600}} for _, namespace := range []string{cacheNamespaceTopic, cacheNamespaceSub} { if got := runtime.cacheTTL(namespace); got != time.Minute { t.Fatalf("cache TTL for %s = %v, want 1m", namespace, got) } } if got := runtime.cacheTTL(cacheNamespaceGroupSettings); got != 5*time.Minute { t.Fatalf("cache TTL for group settings = %v, want 5m", got) } } func TestInvalidateSubscriptionCacheWithZeroUserDeletesTopicPrefix(t *testing.T) { oldRuntime := groupMsgRuntime runtime := newTestGroupMessageRuntime() groupMsgRuntime = runtime t.Cleanup(func() { groupMsgRuntime = oldRuntime }) invalidateSubscriptionCache(7, "grpAlpha", types.ZeroUid) want := []string{"grpAlpha:", "chnAlpha:"} if len(runtime.deletedPrefixes) != len(want) || runtime.deletedPrefixes[0] != want[0] || runtime.deletedPrefixes[1] != want[1] { t.Fatalf("unexpected deleted prefixes: %#v", runtime.deletedPrefixes) } }