Ctrl K
ComunidadGuías…

internal/store/postgres/channel_member_cache_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package postgres

import (
	"context"
	"fmt"
	"sync"
	"sync/atomic"
	"testing"
	"time"

	"telesrv/internal/domain"
)

func TestChannelMemberCacheNilDisabled(t *testing.T) {
	if c := NewChannelMemberCache(0); c != nil {
		t.Fatalf("max<=0 should return nil (disabled), got %v", c)
	}
	var nilCache *ChannelMemberCache
	nilCache.put(domain.ChannelMember{ChannelID: 1, UserID: 2})
	nilCache.delete(1, 2)
	nilCache.deleteChannel(1)
	nilCache.flush()
	if _, ok := nilCache.get(1, 2); ok {
		t.Fatalf("nil cache get must return ok=false")
	}
}

func TestChannelMemberCachePutGetDeleteFlush(t *testing.T) {
	c := NewChannelMemberCache(16)
	member := domain.ChannelMember{
		ChannelID:      10,
		UserID:         20,
		Role:           domain.ChannelRoleAdmin,
		Status:         domain.ChannelMemberActive,
		ReadInboxMaxID: 9,
	}

	if _, ok := c.get(10, 20); ok {
		t.Fatalf("an empty cache should not hit")
	}
	c.put(member)
	got, ok := c.get(10, 20)
	if !ok || got.ChannelID != 10 || got.UserID != 20 || got.Role != domain.ChannelRoleAdmin || got.ReadInboxMaxID != 9 {
		t.Fatalf("put/get round trip failed: %+v ok=%v", got, ok)
	}

	c.delete(10, 20)
	if _, ok := c.get(10, 20); ok {
		t.Fatalf("should not hit after delete")
	}
	c.put(member)
	c.flush()
	if _, ok := c.get(10, 20); ok {
		t.Fatalf("should not hit after flush")
	}
}

func TestChannelMemberCachePutIfEpochRejectsStaleSnapshot(t *testing.T) {
	c := NewChannelMemberCache(16)
	epoch := c.cacheEpoch()
	c.delete(10, 20)
	c.putIfEpoch(domain.ChannelMember{
		ChannelID: 10,
		UserID:    20,
		Status:    domain.ChannelMemberActive,
	}, epoch)
	if _, ok := c.get(10, 20); ok {
		t.Fatal("stale materialized membership restored after invalidation")
	}
	freshEpoch := c.cacheEpoch()
	c.putIfEpoch(domain.ChannelMember{
		ChannelID: 10,
		UserID:    20,
		Status:    domain.ChannelMemberActive,
	}, freshEpoch)
	if member, ok := c.get(10, 20); !ok || member.Status != domain.ChannelMemberActive {
		t.Fatalf("fresh materialized membership = %+v ok=%v", member, ok)
	}
}

func TestChannelMemberCacheDeleteChannelAndCap(t *testing.T) {
	c := NewChannelMemberCache(2)
	c.put(domain.ChannelMember{ChannelID: 1, UserID: 10})
	c.put(domain.ChannelMember{ChannelID: 1, UserID: 11})
	c.deleteChannel(1)
	if _, ok := c.get(1, 10); ok {
		t.Fatalf("deleteChannel should invalidate members of the same channel")
	}
	if _, ok := c.get(1, 11); ok {
		t.Fatalf("deleteChannel should invalidate members of the same channel")
	}

	c.put(domain.ChannelMember{ChannelID: 2, UserID: 10})
	c.put(domain.ChannelMember{ChannelID: 2, UserID: 11})
	c.put(domain.ChannelMember{ChannelID: 2, UserID: 12}) // over cap=2: LRU evicts the oldest single entry (2,10)
	if _, ok := c.get(2, 10); ok {
		t.Fatalf("over the limit the oldest entry (2,10) should be evicted")
	}
	if _, ok := c.get(2, 11); !ok {
		t.Fatalf("single-entry LRU eviction should keep the next-newest entry (2,11), proving it is not a whole-table flush")
	}
	if _, ok := c.get(2, 12); !ok {
		t.Fatalf("the newest written entry (2,12) should be present after exceeding the limit")
	}
}

func TestChannelMemberCacheSingleflightsColdLoad(t *testing.T) {
	c := NewChannelMemberCache(16)
	var calls atomic.Int32
	started := make(chan struct{})
	release := make(chan struct{})
	var once sync.Once
	load := func() (domain.ChannelMember, error) {
		calls.Add(1)
		once.Do(func() { close(started) })
		<-release
		return domain.ChannelMember{ChannelID: 7, UserID: 100, Status: domain.ChannelMemberActive}, nil
	}

	const goroutines = 16
	var wg sync.WaitGroup
	errs := make(chan error, goroutines)
	wg.Add(goroutines)
	for i := 0; i < goroutines; i++ {
		go func() {
			defer wg.Done()
			member, err := c.getOrLoad(context.Background(), 7, 100, load)
			if err != nil {
				errs <- err
				return
			}
			if member.ChannelID != 7 || member.UserID != 100 || member.Status != domain.ChannelMemberActive {
				errs <- fmt.Errorf("member = %+v, want active 7/100", member)
			}
		}()
	}
	<-started
	time.Sleep(20 * time.Millisecond)
	close(release)
	wg.Wait()
	close(errs)
	for err := range errs {
		if err != nil {
			t.Fatal(err)
		}
	}
	if got := calls.Load(); got != 1 {
		t.Fatalf("load calls = %d, want 1", got)
	}
	if _, err := c.getOrLoad(context.Background(), 7, 100, load); err != nil {
		t.Fatalf("cached getOrLoad: %v", err)
	}
	if got := calls.Load(); got != 1 {
		t.Fatalf("cache hit called load again: calls=%d", got)
	}
}

func TestReadModelChangeListenerInvalidatesChannelCaches(t *testing.T) {
	rows := NewChannelRowCache(16)
	members := NewChannelMemberCache(16)
	fullBots := &fakeChannelFullBotReadModelCache{}
	botMembers := &fakeChannelBotMemberReadModelCache{}
	mediaCounts := &fakeChannelMediaCountReadModelCache{}
	rows.put(domain.Channel{ID: 7, Title: "old"})
	members.put(domain.ChannelMember{ChannelID: 7, UserID: 100, Status: domain.ChannelMemberActive})
	members.put(domain.ChannelMember{ChannelID: 7, UserID: 200, Status: domain.ChannelMemberActive})
	members.put(domain.ChannelMember{ChannelID: 8, UserID: 100, Status: domain.ChannelMemberActive})

	listener := NewReadModelChangeListener("", ReadModelCacheSet{
		ChannelRows:        rows,
		ChannelMembers:     members,
		ChannelFullBots:    fullBots,
		ChannelBotMembers:  botMembers,
		ChannelMediaCounts: mediaCounts,
	}, nil)
	listener.handlePayload(`{"model":"channel_member","owner_user_id":100,"peer_type":"channel","peer_id":7,"version":2}`)
	if _, ok := members.get(7, 100); ok {
		t.Fatalf("a channel_member event should invalidate the specified member")
	}
	if _, ok := members.get(7, 200); !ok {
		t.Fatalf("a channel_member event should not invalidate other members of the same channel")
	}
	if got := fullBots.channelsSnapshot(); len(got) != 1 || got[0] != 7 {
		t.Fatalf("channel_member should invalidate full bot info: %+v", got)
	}
	if got := botMembers.channelsSnapshot(); len(got) != 1 || got[0] != 7 {
		t.Fatalf("channel_member should invalidate bot member ids: %+v", got)
	}
	if got := mediaCounts.viewerSnapshot(); len(got) != 1 || got[0] != [2]int64{100, 7} {
		t.Fatalf("channel_member should invalidate the media count for that viewer: %+v", got)
	}

	listener.handlePayload(`{"model":"channel_base","owner_user_id":0,"peer_type":"channel","peer_id":7,"version":3}`)
	if _, ok := rows.get(7); ok {
		t.Fatalf("a channel_base event should invalidate the channel row")
	}
	if _, ok := members.get(7, 200); ok {
		t.Fatalf("a channel_base event should invalidate all members of that channel")
	}
	if _, ok := members.get(8, 100); !ok {
		t.Fatalf("a channel_base event should not invalidate members of other channels")
	}
	if got := fullBots.channelsSnapshot(); len(got) != 2 || got[1] != 7 {
		t.Fatalf("channel_base should invalidate full bot info: %+v", got)
	}
	if got := botMembers.channelsSnapshot(); len(got) != 2 || got[1] != 7 {
		t.Fatalf("channel_base should invalidate bot member ids: %+v", got)
	}

	listener.handlePayload(`{"model":"channel_participants","owner_user_id":0,"peer_type":"channel","peer_id":7,"version":4}`)
	if got := botMembers.channelsSnapshot(); len(got) != 3 || got[2] != 7 {
		t.Fatalf("channel_participants should invalidate bot member ids: %+v", got)
	}

	listener.handlePayload(`{"model":"channel_media_counts","owner_user_id":0,"peer_type":"channel","peer_id":7,"version":5}`)
	if got := mediaCounts.channelSnapshot(); len(got) != 1 || got[0] != 7 {
		t.Fatalf("channel_media_counts should invalidate the media count for that channel: %+v", got)
	}
}

func TestReadModelChangeListenerInvalidatesPrivateMediaCountCache(t *testing.T) {
	privateCounts := &fakePrivateMediaCountReadModelCache{}
	listener := NewReadModelChangeListener("", ReadModelCacheSet{
		PrivateMediaCounts: privateCounts,
	}, nil)

	listener.handlePayload(`{"model":"private_media_counts","owner_user_id":100,"peer_type":"user","peer_id":200,"version":2}`)
	if got := privateCounts.keysSnapshot(); len(got) != 1 || got[0] != [2]int64{100, 200} {
		t.Fatalf("private_media_counts should invalidate the owner+peer media count: %+v", got)
	}
}

func TestReadModelChangeListenerInvalidatesAccountSettingsCache(t *testing.T) {
	settings := &fakeAccountSettingsReadModelCache{}
	listener := NewReadModelChangeListener("", ReadModelCacheSet{
		AccountSettings: settings,
	}, nil)

	listener.handlePayload(`{"model":"account_settings","owner_user_id":100,"peer_type":"user","peer_id":100,"version":2}`)
	if len(settings.invalidated) != 1 || settings.invalidated[0] != 100 {
		t.Fatalf("account_settings invalidations = %v, want [100]", settings.invalidated)
	}
	if len(settings.warmed) != 1 || settings.warmed[0] != 100 {
		t.Fatalf("account_settings warmups = %v, want [100]", settings.warmed)
	}

	listener.flush("test")
	if settings.flushes != 1 {
		t.Fatalf("account_settings flushes = %d, want 1", settings.flushes)
	}
}

type fakeAccountSettingsReadModelCache struct {
	invalidated []int64
	warmed      []int64
	flushes     int
}

func (f *fakeAccountSettingsReadModelCache) InvalidateAccountSettingsReadModel(userID int64) {
	f.invalidated = append(f.invalidated, userID)
}

func (f *fakeAccountSettingsReadModelCache) FlushAccountSettingsReadModel() {
	f.flushes++
}

func (f *fakeAccountSettingsReadModelCache) WarmAccountSettingsReadModel(_ context.Context, userID int64) error {
	f.warmed = append(f.warmed, userID)
	return nil
}

// TestReadModelChangeListenerBotFullFlushesChannelFullBots regression: a bot profile change (bot_full event,
// migration 0013) must flush channelFullBotInfoCache (otherwise the group info page's bot bio/commands stay stale across instances until TTL);
// a regular user's user_base event must not flush (otherwise the cache is effectively useless).
func TestReadModelChangeListenerBotFullFlushesChannelFullBots(t *testing.T) {
	fullBots := &fakeChannelFullBotReadModelCache{}
	listener := NewReadModelChangeListener("", ReadModelCacheSet{
		ChannelFullBots: fullBots,
	}, nil)

	listener.handlePayload(`{"model":"bot_full","owner_user_id":1780243210,"peer_type":"user","peer_id":1780243210,"version":2}`)
	if got := fullBots.flushCount(); got != 1 {
		t.Fatalf("bot_full should flush channelFullBotInfoCache: flushes=%d", got)
	}

	listener.handlePayload(`{"model":"user_base","owner_user_id":500,"peer_type":"user","peer_id":500,"version":3}`)
	if got := fullBots.flushCount(); got != 1 {
		t.Fatalf("user_base (regular user) should not flush channelFullBots: flushes=%d", got)
	}
}

type fakeChannelFullBotReadModelCache struct {
	mu       sync.Mutex
	channels []int64
	flushes  int
}

func (f *fakeChannelFullBotReadModelCache) InvalidateChannelFullBotInfoReadModel(channelID int64) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.channels = append(f.channels, channelID)
}

func (f *fakeChannelFullBotReadModelCache) FlushChannelFullBotInfoReadModel() {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.flushes++
}

func (f *fakeChannelFullBotReadModelCache) channelsSnapshot() []int64 {
	f.mu.Lock()
	defer f.mu.Unlock()
	return append([]int64(nil), f.channels...)
}

func (f *fakeChannelFullBotReadModelCache) flushCount() int {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.flushes
}

type fakeChannelBotMemberReadModelCache struct {
	mu       sync.Mutex
	channels []int64
	flushes  int
}

func (f *fakeChannelBotMemberReadModelCache) InvalidateActiveBotMemberIDsReadModel(channelID int64) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.channels = append(f.channels, channelID)
}

func (f *fakeChannelBotMemberReadModelCache) FlushActiveBotMemberIDsReadModel() {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.flushes++
}

func (f *fakeChannelBotMemberReadModelCache) channelsSnapshot() []int64 {
	f.mu.Lock()
	defer f.mu.Unlock()
	return append([]int64(nil), f.channels...)
}

type fakeChannelMediaCountReadModelCache struct {
	mu       sync.Mutex
	channels []int64
	viewers  [][2]int64
	flushes  int
}

func (f *fakeChannelMediaCountReadModelCache) InvalidateChannelMediaCountReadModel(channelID int64) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.channels = append(f.channels, channelID)
}

func (f *fakeChannelMediaCountReadModelCache) InvalidateChannelMediaCountReadModelForViewer(userID, channelID int64) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.viewers = append(f.viewers, [2]int64{userID, channelID})
}

func (f *fakeChannelMediaCountReadModelCache) FlushChannelMediaCountReadModel() {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.flushes++
}

func (f *fakeChannelMediaCountReadModelCache) channelSnapshot() []int64 {
	f.mu.Lock()
	defer f.mu.Unlock()
	return append([]int64(nil), f.channels...)
}

func (f *fakeChannelMediaCountReadModelCache) viewerSnapshot() [][2]int64 {
	f.mu.Lock()
	defer f.mu.Unlock()
	return append([][2]int64(nil), f.viewers...)
}

func (f *fakeChannelMediaCountReadModelCache) flushCount() int {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.flushes
}

type fakePrivateMediaCountReadModelCache struct {
	mu      sync.Mutex
	keys    [][2]int64
	flushes int
}

func (f *fakePrivateMediaCountReadModelCache) InvalidatePrivateMediaCountReadModel(userID, peerID int64) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.keys = append(f.keys, [2]int64{userID, peerID})
}

func (f *fakePrivateMediaCountReadModelCache) FlushPrivateMediaCountReadModel() {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.flushes++
}

func (f *fakePrivateMediaCountReadModelCache) keysSnapshot() [][2]int64 {
	f.mu.Lock()
	defer f.mu.Unlock()
	return append([][2]int64(nil), f.keys...)
}

func (f *fakePrivateMediaCountReadModelCache) flushCount() int {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.flushes
}

type fakeContactReadModelCache struct {
	mu      sync.Mutex
	ids     []int64
	flushes int
}

func (f *fakeContactReadModelCache) InvalidateViewers(ids ...int64) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.ids = append(f.ids, ids...)
}

func (f *fakeContactReadModelCache) FlushReadModelCache() {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.flushes++
}

func (f *fakeContactReadModelCache) idsSnapshot() []int64 {
	f.mu.Lock()
	defer f.mu.Unlock()
	return append([]int64(nil), f.ids...)
}

func (f *fakeContactReadModelCache) flushCount() int {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.flushes
}

type fakeDialogReadModelCache struct {
	mu      sync.Mutex
	keys    []domain.Peer
	owners  []int64
	flushes int
}

func (f *fakeDialogReadModelCache) InvalidateDialog(ownerUserID int64, peer domain.Peer) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.owners = append(f.owners, ownerUserID)
	f.keys = append(f.keys, peer)
}

func (f *fakeDialogReadModelCache) FlushReadModelCache() {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.flushes++
}

func (f *fakeDialogReadModelCache) entriesSnapshot() ([]int64, []domain.Peer) {
	f.mu.Lock()
	defer f.mu.Unlock()
	return append([]int64(nil), f.owners...), append([]domain.Peer(nil), f.keys...)
}

func (f *fakeDialogReadModelCache) flushCount() int {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.flushes
}

type fakePrivacyReadModelCache struct {
	mu      sync.Mutex
	ids     []int64
	warmed  []int64
	flushes int
}

func (f *fakePrivacyReadModelCache) InvalidateOwners(ids ...int64) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.ids = append(f.ids, ids...)
}

func (f *fakePrivacyReadModelCache) WarmOwners(_ context.Context, ids ...int64) error {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.warmed = append(f.warmed, ids...)
	return nil
}

func (f *fakePrivacyReadModelCache) FlushReadModelCache() {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.flushes++
}

func (f *fakePrivacyReadModelCache) idsSnapshot() []int64 {
	f.mu.Lock()
	defer f.mu.Unlock()
	return append([]int64(nil), f.ids...)
}

func (f *fakePrivacyReadModelCache) warmedSnapshot() []int64 {
	f.mu.Lock()
	defer f.mu.Unlock()
	return append([]int64(nil), f.warmed...)
}

func (f *fakePrivacyReadModelCache) flushCount() int {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.flushes
}

type fakeProfilePhotoReadModelCache struct {
	mu      sync.Mutex
	owners  []domain.Peer
	flushes int
}

func (f *fakeProfilePhotoReadModelCache) InvalidateOwner(ownerType domain.PeerType, ownerID int64) {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.owners = append(f.owners, domain.Peer{Type: ownerType, ID: ownerID})
}

func (f *fakeProfilePhotoReadModelCache) FlushReadModelCache() {
	f.mu.Lock()
	defer f.mu.Unlock()
	f.flushes++
}

func (f *fakeProfilePhotoReadModelCache) ownersSnapshot() []domain.Peer {
	f.mu.Lock()
	defer f.mu.Unlock()
	return append([]domain.Peer(nil), f.owners...)
}

func (f *fakeProfilePhotoReadModelCache) flushCount() int {
	f.mu.Lock()
	defer f.mu.Unlock()
	return f.flushes
}

func TestReadModelChangeListenerInvalidatesAccountCaches(t *testing.T) {
	contacts := &fakeContactReadModelCache{}
	dialogs := &fakeDialogReadModelCache{}
	privacy := &fakePrivacyReadModelCache{}
	photos := &fakeProfilePhotoReadModelCache{}
	channelMedia := &fakeChannelMediaCountReadModelCache{}
	privateMedia := &fakePrivateMediaCountReadModelCache{}
	listener := NewReadModelChangeListener("", ReadModelCacheSet{
		Contacts:           contacts,
		Dialogs:            dialogs,
		Privacy:            privacy,
		ProfilePhotos:      photos,
		ChannelMediaCounts: channelMedia,
		PrivateMediaCounts: privateMedia,
	}, nil)

	listener.flush()
	if contacts.flushes != 1 || dialogs.flushes != 1 || privacy.flushes != 1 || photos.flushes != 1 ||
		channelMedia.flushCount() != 1 || privateMedia.flushCount() != 1 {
		t.Fatalf("flush counts contacts/dialogs/privacy/photos/channelMedia/privateMedia = %d/%d/%d/%d/%d/%d, want all 1",
			contacts.flushes, dialogs.flushes, privacy.flushes, photos.flushes, channelMedia.flushCount(), privateMedia.flushCount())
	}

	listener.handlePayload(`{"model":"contact_account","owner_user_id":11,"peer_type":"user","peer_id":11,"version":2}`)
	listener.handlePayload(`{"model":"contact_blocklist","owner_user_id":12,"peer_type":"user","peer_id":12,"version":3}`)
	if len(contacts.ids) != 2 || contacts.ids[0] != 11 || contacts.ids[1] != 12 {
		t.Fatalf("contact invalidations = %v, want [11 12]", contacts.ids)
	}

	listener.handlePayload(`{"model":"privacy_rules","owner_user_id":21,"peer_type":"user","peer_id":21,"version":4}`)
	if len(privacy.ids) != 1 || privacy.ids[0] != 21 {
		t.Fatalf("privacy invalidations = %v, want [21]", privacy.ids)
	}
	if warmed := privacy.warmedSnapshot(); len(warmed) != 1 || warmed[0] != 21 {
		t.Fatalf("privacy warms = %v, want [21]", warmed)
	}

	listener.handlePayload(`{"model":"dialog_light","owner_user_id":22,"peer_type":"user","peer_id":32,"version":4}`)
	if len(dialogs.owners) != 1 || dialogs.owners[0] != 22 || dialogs.keys[0] != (domain.Peer{Type: domain.PeerTypeUser, ID: 32}) {
		t.Fatalf("dialog invalidations owners=%v keys=%+v, want owner 22 user 32", dialogs.owners, dialogs.keys)
	}

	listener.handlePayload(`{"model":"channel_member","owner_user_id":23,"peer_type":"channel","peer_id":33,"version":5}`)
	if len(dialogs.owners) != 2 || dialogs.owners[1] != 23 || dialogs.keys[1] != (domain.Peer{Type: domain.PeerTypeChannel, ID: 33}) {
		t.Fatalf("channel member dialog invalidations owners=%v keys=%+v, want owner 23 channel 33", dialogs.owners, dialogs.keys)
	}

	listener.handlePayload(`{"model":"profile_photo","owner_user_id":0,"peer_type":"user","peer_id":31,"version":5}`)
	listener.handlePayload(`{"model":"profile_photo","owner_user_id":0,"peer_type":"channel","peer_id":41,"version":6}`)
	if len(photos.owners) != 2 ||
		photos.owners[0] != (domain.Peer{Type: domain.PeerTypeUser, ID: 31}) ||
		photos.owners[1] != (domain.Peer{Type: domain.PeerTypeChannel, ID: 41}) {
		t.Fatalf("photo invalidations = %+v, want user 31 and channel 41", photos.owners)
	}
}