owpengram-server/internal/store/postgres/channel_member_cache_integration_test.go

523 lines
24 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package postgres
import (
"context"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"telesrv/internal/domain"
)
// TestChannelMemberCacheInvalidatesOnReadModelNotify 验证channel_members 触发器(0120)
// → 统一 read-model LISTEN/NOTIFY → ChannelMemberCache 失效 → 下一次读回填新成员态。
func TestChannelMemberCacheInvalidatesOnReadModelNotify(t *testing.T) {
pool := testPool(t)
dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN")
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{
AccessHash: 881,
Phone: "+1888" + suffix + "01",
FirstName: "MemberCacheOwner",
})
if err != nil {
t.Fatalf("create owner: %v", err)
}
rowCache := NewChannelRowCache(1000)
memberCache := NewChannelMemberCache(1000)
channels := NewChannelStore(pool,
WithChannelRowCache(rowCache),
WithChannelMemberCache(memberCache))
var channelID int64
t.Cleanup(func() {
if channelID != 0 {
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
}
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
})
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "MemberCacheChan " + suffix,
Megagroup: true,
Date: 1700001700,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
channelID = created.Channel.ID
if _, err := channels.GetChannel(ctx, owner.ID, channelID); err != nil {
t.Fatalf("warm GetChannel: %v", err)
}
if _, ok := rowCache.get(channelID); !ok {
t.Fatalf("GetChannel 后频道行应已缓存")
}
if _, ok := memberCache.get(channelID, owner.ID); !ok {
t.Fatalf("GetChannel 后成员行应已缓存")
}
lctx, cancel := context.WithCancel(ctx)
defer cancel()
listener := NewReadModelChangeListener(dsn, ReadModelCacheSet{
ChannelRows: rowCache,
ChannelMembers: memberCache,
}, nil)
go listener.Run(lctx)
if !waitUntil(2*time.Second, func() bool {
_, rowOK := rowCache.get(channelID)
_, memberOK := memberCache.get(channelID, owner.ID)
return !rowOK && !memberOK
}) {
t.Fatalf("read model listener 未在预期内连接并 flush 缓存")
}
if view, err := channels.GetChannel(ctx, owner.ID, channelID); err != nil {
t.Fatalf("re-warm GetChannel: %v", err)
} else if view.Self.Rank != "" {
t.Fatalf("初始 rank = %q, want empty", view.Self.Rank)
}
if _, ok := memberCache.get(channelID, owner.ID); !ok {
t.Fatalf("re-warm 后成员行应已缓存")
}
const newRank = "cache-rank"
if _, err := pool.Exec(ctx, `
UPDATE channel_members
SET rank = $3, updated_at = now()
WHERE channel_id = $1 AND user_id = $2`, channelID, owner.ID, newRank); err != nil {
t.Fatalf("update member rank: %v", err)
}
if !waitUntil(3*time.Second, func() bool {
_, ok := memberCache.get(channelID, owner.ID)
return !ok
}) {
t.Fatalf("UPDATE channel_members 后成员缓存未在预期内被 read-model NOTIFY 失效")
}
view, err := channels.GetChannel(ctx, owner.ID, channelID)
if err != nil {
t.Fatalf("post-invalidation GetChannel: %v", err)
}
if view.Self.Rank != newRank {
t.Fatalf("失效后 rank = %q, want %q", view.Self.Rank, newRank)
}
}
func TestChannelDialogLightInvalidatesOnChannelMemberAndDialogNotify(t *testing.T) {
pool := testPool(t)
dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN")
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner := createTestUser(t, ctx, users, "+1888"+suffix+"91", "DialogLightChannelOwner", "")
channels := NewChannelStore(pool)
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
CreatorUserID: owner.ID,
Title: "DialogLightChannel " + suffix,
Megagroup: true,
Date: 1700003300,
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
peer := domain.Peer{Type: domain.PeerTypeChannel, ID: created.Channel.ID}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", created.Channel.ID)
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
})
versionBeforeMember, hashBeforeMember := readDialogLightVersionHash(t, ctx, pool, owner.ID, peer)
dialogCache := &fakeDialogReadModelCache{}
lctx, cancel := context.WithCancel(ctx)
defer cancel()
listener := NewReadModelChangeListener(dsn, ReadModelCacheSet{Dialogs: dialogCache}, nil)
go listener.Run(lctx)
if !waitUntil(2*time.Second, func() bool { return dialogCache.flushCount() == 1 }) {
t.Fatalf("read model listener 未在预期内连接并 flush dialog cache")
}
if _, err := pool.Exec(ctx, `
UPDATE channel_members
SET rank = $3, updated_at = now()
WHERE channel_id = $1 AND user_id = $2`, created.Channel.ID, owner.ID, "dialog-light-rank"); err != nil {
t.Fatalf("update channel member: %v", err)
}
if !waitUntil(3*time.Second, func() bool {
return countDialogInvalidation(dialogCache, owner.ID, peer) > 0
}) {
owners, peers := dialogCache.entriesSnapshot()
t.Fatalf("channel member 写入未触发 dialog_light 失效: owners=%v peers=%+v want owner=%d peer=%+v", owners, peers, owner.ID, peer)
}
versionAfterMember, hashAfterMember := readDialogLightVersionHash(t, ctx, pool, owner.ID, peer)
if versionAfterMember <= versionBeforeMember {
t.Fatalf("channel member did not bump dialog_light version: before=%d after=%d", versionBeforeMember, versionAfterMember)
}
if hashAfterMember == 0 || hashAfterMember == hashBeforeMember {
t.Fatalf("channel member did not refresh dialog_light hash: before=%d after=%d", hashBeforeMember, hashAfterMember)
}
beforeDialogInvalidation := countDialogInvalidation(dialogCache, owner.ID, peer)
versionBeforeDialog, hashBeforeDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, peer)
changed, err := channels.SetChannelDialogUnreadMark(ctx, owner.ID, created.Channel.ID, true)
if err != nil {
t.Fatalf("set channel unread mark: %v", err)
}
if !changed {
t.Fatalf("set channel unread mark changed = false, want true")
}
if !waitUntil(3*time.Second, func() bool {
return countDialogInvalidation(dialogCache, owner.ID, peer) > beforeDialogInvalidation
}) {
owners, peers := dialogCache.entriesSnapshot()
t.Fatalf("channel dialog 写入未触发 dialog_light 失效: owners=%v peers=%+v want owner=%d peer=%+v", owners, peers, owner.ID, peer)
}
versionAfterDialog, hashAfterDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, peer)
if versionAfterDialog <= versionBeforeDialog {
t.Fatalf("channel dialog did not bump dialog_light version: before=%d after=%d", versionBeforeDialog, versionAfterDialog)
}
if hashAfterDialog == 0 || hashAfterDialog == hashBeforeDialog {
t.Fatalf("channel dialog did not refresh dialog_light hash: before=%d after=%d", hashBeforeDialog, hashAfterDialog)
}
}
// TestReadModelChangeListenerInvalidatesProjectionCachesFromNotify 验证 0120/0121
// 的 account/profile 投影触发器能穿过统一 listener 分发到对应缓存。
func TestReadModelChangeListenerInvalidatesProjectionCachesFromNotify(t *testing.T) {
pool := testPool(t)
dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN")
ctx := context.Background()
suffix := randomSuffix(t)
users := NewUserStore(pool)
owner, err := users.Create(ctx, domain.User{
AccessHash: 991,
Phone: "+1991" + suffix + "01",
FirstName: "ProjectionOwner",
})
if err != nil {
t.Fatalf("create owner: %v", err)
}
target, err := users.Create(ctx, domain.User{
AccessHash: 992,
Phone: "+1992" + suffix + "02",
FirstName: "ProjectionTarget",
})
if err != nil {
t.Fatalf("create target: %v", err)
}
photoID := time.Now().UnixNano()
targetPhotoID := photoID + 1
t.Cleanup(func() {
_, _ = pool.Exec(ctx, "DELETE FROM account_privacy_rules WHERE owner_user_id = ANY($1::bigint[])", []int64{owner.ID, target.ID})
_, _ = pool.Exec(ctx, "DELETE FROM profile_photos WHERE owner_peer_type = 'user' AND owner_peer_id = ANY($1::bigint[])", []int64{owner.ID, target.ID})
_, _ = pool.Exec(ctx, "DELETE FROM photos WHERE id = ANY($1::bigint[])", []int64{photoID, targetPhotoID})
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{owner.ID, target.ID})
})
contactsCache := &fakeContactReadModelCache{}
dialogCache := &fakeDialogReadModelCache{}
privacyCache := &fakePrivacyReadModelCache{}
photoCache := &fakeProfilePhotoReadModelCache{}
lctx, cancel := context.WithCancel(ctx)
defer cancel()
listener := NewReadModelChangeListener(dsn, ReadModelCacheSet{
Contacts: contactsCache,
Dialogs: dialogCache,
Privacy: privacyCache,
ProfilePhotos: photoCache,
}, nil)
go listener.Run(lctx)
if !waitUntil(2*time.Second, func() bool {
return contactsCache.flushCount() == 1 && dialogCache.flushCount() == 1 && privacyCache.flushCount() == 1 && photoCache.flushCount() == 1
}) {
t.Fatalf("read model listener 未在预期内连接并 flush account/dialog/photo caches")
}
dialogs := NewDialogStore(pool)
dialogPeer := domain.Peer{Type: domain.PeerTypeUser, ID: target.ID}
if err := dialogs.Upsert(ctx, owner.ID, domain.Dialog{Peer: dialogPeer, TopMessage: 1, TopMessageDate: 1700002999}); err != nil {
t.Fatalf("upsert dialog: %v", err)
}
if !waitUntil(3*time.Second, func() bool {
return countDialogInvalidation(dialogCache, owner.ID, dialogPeer) > 0
}) {
owners, peers := dialogCache.entriesSnapshot()
t.Fatalf("dialog 写入未触发 dialog_light 失效: owners=%v peers=%+v want owner=%d peer=%+v", owners, peers, owner.ID, dialogPeer)
}
versionBeforeContactDialog, hashBeforeContactDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, dialogPeer)
contacts := NewContactStore(pool)
beforeContactDialogFanout := countDialogInvalidation(dialogCache, owner.ID, dialogPeer)
if _, err := contacts.Upsert(ctx, owner.ID, domain.ContactInput{ContactUserID: target.ID, FirstName: "Target"}); err != nil {
t.Fatalf("upsert contact: %v", err)
}
if !waitUntil(3*time.Second, func() bool {
return containsInt64(contactsCache.idsSnapshot(), owner.ID)
}) {
t.Fatalf("contacts 写入未触发 contact_account 失效: ids=%v owner=%d", contactsCache.idsSnapshot(), owner.ID)
}
var versionBeforeProfile, hashBeforeProfile int64
if err := pool.QueryRow(ctx, `
SELECT version, hash
FROM read_model_versions
WHERE model = 'contact_account' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $1`, owner.ID).Scan(&versionBeforeProfile, &hashBeforeProfile); err != nil {
t.Fatalf("read contact_account version/hash before profile update: %v", err)
}
if !waitUntil(3*time.Second, func() bool {
return countDialogInvalidation(dialogCache, owner.ID, dialogPeer) > beforeContactDialogFanout
}) {
owners, peers := dialogCache.entriesSnapshot()
t.Fatalf("contact 写入未 fan-out 到 dialog_light 失效: owners=%v peers=%+v owner=%d peer=%+v", owners, peers, owner.ID, dialogPeer)
}
versionAfterContactDialog, hashAfterContactDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, dialogPeer)
if versionAfterContactDialog <= versionBeforeContactDialog {
t.Fatalf("contact did not bump dialog_light version: before=%d after=%d", versionBeforeContactDialog, versionAfterContactDialog)
}
if hashAfterContactDialog == 0 || hashAfterContactDialog == hashBeforeContactDialog {
t.Fatalf("contact did not refresh dialog_light hash: before=%d after=%d", hashBeforeContactDialog, hashAfterContactDialog)
}
beforeUserFanout := countInt64(contactsCache.idsSnapshot(), owner.ID)
beforeUserDialogFanout := countDialogInvalidation(dialogCache, owner.ID, dialogPeer)
versionBeforeProfileDialog, hashBeforeProfileDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, dialogPeer)
if _, err := pool.Exec(ctx, "UPDATE users SET first_name = $2, updated_at = now() WHERE id = $1", target.ID, "ProjectionTargetRenamed"); err != nil {
t.Fatalf("update target user profile field: %v", err)
}
if !waitUntil(3*time.Second, func() bool {
return countInt64(contactsCache.idsSnapshot(), owner.ID) > beforeUserFanout
}) {
t.Fatalf("users profile 写入未 fan-out 到 contact_account 失效: ids=%v owner=%d", contactsCache.idsSnapshot(), owner.ID)
}
if !waitUntil(3*time.Second, func() bool {
return countDialogInvalidation(dialogCache, owner.ID, dialogPeer) > beforeUserDialogFanout
}) {
owners, peers := dialogCache.entriesSnapshot()
t.Fatalf("users profile 写入未 fan-out 到 dialog_light 失效: owners=%v peers=%+v owner=%d peer=%+v", owners, peers, owner.ID, dialogPeer)
}
versionAfterProfileDialog, hashAfterProfileDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, dialogPeer)
if versionAfterProfileDialog <= versionBeforeProfileDialog {
t.Fatalf("profile update did not bump dialog_light version: before=%d after=%d", versionBeforeProfileDialog, versionAfterProfileDialog)
}
if hashAfterProfileDialog == 0 || hashAfterProfileDialog == hashBeforeProfileDialog {
t.Fatalf("profile update did not refresh dialog_light hash: before=%d after=%d", hashBeforeProfileDialog, hashAfterProfileDialog)
}
var versionBeforeLastSeen, hashBeforeLastSeen int64
if err := pool.QueryRow(ctx, `
SELECT version, hash
FROM read_model_versions
WHERE model = 'contact_account' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $1`, owner.ID).Scan(&versionBeforeLastSeen, &hashBeforeLastSeen); err != nil {
t.Fatalf("read contact_account version before last_seen update: %v", err)
}
if versionBeforeLastSeen <= versionBeforeProfile {
t.Fatalf("profile update did not bump contact_account version: before=%d after=%d", versionBeforeProfile, versionBeforeLastSeen)
}
if hashBeforeLastSeen == 0 || hashBeforeLastSeen == hashBeforeProfile {
t.Fatalf("profile update did not refresh contact_account hash: before=%d after=%d", hashBeforeProfile, hashBeforeLastSeen)
}
if _, err := pool.Exec(ctx, "UPDATE users SET last_seen_at = GREATEST(last_seen_at, $2), updated_at = now() WHERE id = $1", target.ID, int64(1700003999)); err != nil {
t.Fatalf("update target last_seen: %v", err)
}
var versionAfterLastSeen, hashAfterLastSeen int64
if err := pool.QueryRow(ctx, `
SELECT version, hash
FROM read_model_versions
WHERE model = 'contact_account' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $1`, owner.ID).Scan(&versionAfterLastSeen, &hashAfterLastSeen); err != nil {
t.Fatalf("read contact_account version after last_seen update: %v", err)
}
if versionAfterLastSeen != versionBeforeLastSeen {
t.Fatalf("last_seen update bumped contact_account version: before=%d after=%d", versionBeforeLastSeen, versionAfterLastSeen)
}
if hashAfterLastSeen != hashBeforeLastSeen {
t.Fatalf("last_seen update refreshed contact_account hash: before=%d after=%d", hashBeforeLastSeen, hashAfterLastSeen)
}
if _, err := contacts.Block(ctx, owner.ID, target.ID, 1700003000); err != nil {
t.Fatalf("block contact: %v", err)
}
if !waitUntil(3*time.Second, func() bool {
return countInt64(contactsCache.idsSnapshot(), owner.ID) >= 2
}) {
t.Fatalf("block 写入未触发 contact_blocklist 失效: ids=%v owner=%d", contactsCache.idsSnapshot(), owner.ID)
}
privacy := NewPrivacyStore(pool)
if err := privacy.SetPrivacyRules(ctx, domain.PrivacyRules{
OwnerUserID: owner.ID,
Key: domain.PrivacyKeyProfilePhoto,
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}},
}); err != nil {
t.Fatalf("set privacy: %v", err)
}
if !waitUntil(3*time.Second, func() bool {
return containsInt64(privacyCache.idsSnapshot(), owner.ID)
}) {
t.Fatalf("privacy 写入未触发 privacy_rules 失效: ids=%v owner=%d", privacyCache.idsSnapshot(), owner.ID)
}
media := NewMediaStore(pool)
versionBeforeTargetPrivacy, hashBeforeTargetPrivacy := readContactAccountVersionHash(t, ctx, pool, owner.ID)
versionBeforeTargetPrivacyDialog, hashBeforeTargetPrivacyDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, dialogPeer)
beforeTargetPrivacyFanout := countInt64(contactsCache.idsSnapshot(), owner.ID)
beforeTargetPrivacyDialogFanout := countDialogInvalidation(dialogCache, owner.ID, dialogPeer)
if err := privacy.SetPrivacyRules(ctx, domain.PrivacyRules{
OwnerUserID: target.ID,
Key: domain.PrivacyKeyProfilePhoto,
Rules: []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}},
}); err != nil {
t.Fatalf("set target privacy: %v", err)
}
if !waitUntil(3*time.Second, func() bool {
return countInt64(contactsCache.idsSnapshot(), owner.ID) > beforeTargetPrivacyFanout
}) {
t.Fatalf("target privacy 写入未 fan-out 到 contact_account 失效: ids=%v owner=%d", contactsCache.idsSnapshot(), owner.ID)
}
if !waitUntil(3*time.Second, func() bool {
return countDialogInvalidation(dialogCache, owner.ID, dialogPeer) > beforeTargetPrivacyDialogFanout
}) {
owners, peers := dialogCache.entriesSnapshot()
t.Fatalf("target privacy 写入未 fan-out 到 dialog_light 失效: owners=%v peers=%+v owner=%d peer=%+v", owners, peers, owner.ID, dialogPeer)
}
versionAfterTargetPrivacy, hashAfterTargetPrivacy := readContactAccountVersionHash(t, ctx, pool, owner.ID)
if versionAfterTargetPrivacy <= versionBeforeTargetPrivacy {
t.Fatalf("target privacy did not bump contact_account version: before=%d after=%d", versionBeforeTargetPrivacy, versionAfterTargetPrivacy)
}
if hashAfterTargetPrivacy == 0 || hashAfterTargetPrivacy == hashBeforeTargetPrivacy {
t.Fatalf("target privacy did not refresh contact_account hash: before=%d after=%d", hashBeforeTargetPrivacy, hashAfterTargetPrivacy)
}
versionAfterTargetPrivacyDialog, hashAfterTargetPrivacyDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, dialogPeer)
if versionAfterTargetPrivacyDialog <= versionBeforeTargetPrivacyDialog {
t.Fatalf("target privacy did not bump dialog_light version: before=%d after=%d", versionBeforeTargetPrivacyDialog, versionAfterTargetPrivacyDialog)
}
if hashAfterTargetPrivacyDialog == 0 || hashAfterTargetPrivacyDialog == hashBeforeTargetPrivacyDialog {
t.Fatalf("target privacy did not refresh dialog_light hash: before=%d after=%d", hashBeforeTargetPrivacyDialog, hashAfterTargetPrivacyDialog)
}
if err := media.PutPhoto(ctx, domain.Photo{ID: targetPhotoID, AccessHash: 994, FileReference: []byte("target-ref"), Date: 1700003003, DCID: 2}); err != nil {
t.Fatalf("put target photo: %v", err)
}
versionBeforeTargetPhoto, hashBeforeTargetPhoto := readContactAccountVersionHash(t, ctx, pool, owner.ID)
versionBeforeTargetPhotoDialog, hashBeforeTargetPhotoDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, dialogPeer)
beforeTargetPhotoFanout := countInt64(contactsCache.idsSnapshot(), owner.ID)
beforeTargetPhotoDialogFanout := countDialogInvalidation(dialogCache, owner.ID, dialogPeer)
if err := media.AddProfilePhotoKind(ctx, domain.PeerTypeUser, target.ID, domain.ProfilePhotoKindProfile, targetPhotoID, 1700003004); err != nil {
t.Fatalf("add target profile photo: %v", err)
}
if !waitUntil(3*time.Second, func() bool {
return countInt64(contactsCache.idsSnapshot(), owner.ID) > beforeTargetPhotoFanout
}) {
t.Fatalf("target profile photo 写入未 fan-out 到 contact_account 失效: ids=%v owner=%d", contactsCache.idsSnapshot(), owner.ID)
}
if !waitUntil(3*time.Second, func() bool {
return countDialogInvalidation(dialogCache, owner.ID, dialogPeer) > beforeTargetPhotoDialogFanout
}) {
owners, peers := dialogCache.entriesSnapshot()
t.Fatalf("target profile photo 写入未 fan-out 到 dialog_light 失效: owners=%v peers=%+v owner=%d peer=%+v", owners, peers, owner.ID, dialogPeer)
}
versionAfterTargetPhoto, hashAfterTargetPhoto := readContactAccountVersionHash(t, ctx, pool, owner.ID)
if versionAfterTargetPhoto <= versionBeforeTargetPhoto {
t.Fatalf("target profile photo did not bump contact_account version: before=%d after=%d", versionBeforeTargetPhoto, versionAfterTargetPhoto)
}
if hashAfterTargetPhoto == 0 || hashAfterTargetPhoto == hashBeforeTargetPhoto {
t.Fatalf("target profile photo did not refresh contact_account hash: before=%d after=%d", hashBeforeTargetPhoto, hashAfterTargetPhoto)
}
versionAfterTargetPhotoDialog, hashAfterTargetPhotoDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, dialogPeer)
if versionAfterTargetPhotoDialog <= versionBeforeTargetPhotoDialog {
t.Fatalf("target profile photo did not bump dialog_light version: before=%d after=%d", versionBeforeTargetPhotoDialog, versionAfterTargetPhotoDialog)
}
if hashAfterTargetPhotoDialog == 0 || hashAfterTargetPhotoDialog == hashBeforeTargetPhotoDialog {
t.Fatalf("target profile photo did not refresh dialog_light hash: before=%d after=%d", hashBeforeTargetPhotoDialog, hashAfterTargetPhotoDialog)
}
versionBeforeDraftDialog, hashBeforeDraftDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, dialogPeer)
beforeDraftDialogFanout := countDialogInvalidation(dialogCache, owner.ID, dialogPeer)
if err := dialogs.SaveDraft(ctx, owner.ID, domain.DialogDraft{Peer: dialogPeer, Date: 1700003005, Message: "dialog light draft"}); err != nil {
t.Fatalf("save dialog draft: %v", err)
}
if !waitUntil(3*time.Second, func() bool {
return countDialogInvalidation(dialogCache, owner.ID, dialogPeer) > beforeDraftDialogFanout
}) {
owners, peers := dialogCache.entriesSnapshot()
t.Fatalf("dialog draft 写入未触发 dialog_light 失效: owners=%v peers=%+v owner=%d peer=%+v", owners, peers, owner.ID, dialogPeer)
}
versionAfterDraftDialog, hashAfterDraftDialog := readDialogLightVersionHash(t, ctx, pool, owner.ID, dialogPeer)
if versionAfterDraftDialog <= versionBeforeDraftDialog {
t.Fatalf("draft did not bump dialog_light version: before=%d after=%d", versionBeforeDraftDialog, versionAfterDraftDialog)
}
if hashAfterDraftDialog == 0 || hashAfterDraftDialog == hashBeforeDraftDialog {
t.Fatalf("draft did not refresh dialog_light hash: before=%d after=%d", hashBeforeDraftDialog, hashAfterDraftDialog)
}
if err := media.PutPhoto(ctx, domain.Photo{ID: photoID, AccessHash: 993, FileReference: []byte("ref"), Date: 1700003001, DCID: 2}); err != nil {
t.Fatalf("put photo: %v", err)
}
if err := media.AddProfilePhotoKind(ctx, domain.PeerTypeUser, owner.ID, domain.ProfilePhotoKindProfile, photoID, 1700003002); err != nil {
t.Fatalf("add profile photo: %v", err)
}
if !waitUntil(3*time.Second, func() bool {
for _, ownerPeer := range photoCache.ownersSnapshot() {
if ownerPeer == (domain.Peer{Type: domain.PeerTypeUser, ID: owner.ID}) {
return true
}
}
return false
}) {
t.Fatalf("profile photo 写入未触发 profile_photo 失效: owners=%+v want user %d", photoCache.ownersSnapshot(), owner.ID)
}
}
func readContactAccountVersionHash(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ownerID int64) (int64, int64) {
t.Helper()
var version, hash int64
if err := pool.QueryRow(ctx, `
SELECT version, hash
FROM read_model_versions
WHERE model = 'contact_account' AND owner_user_id = $1 AND peer_type = 'user' AND peer_id = $1`, ownerID).Scan(&version, &hash); err != nil {
t.Fatalf("read contact_account version/hash: %v", err)
}
return version, hash
}
func readDialogLightVersionHash(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ownerID int64, peer domain.Peer) (int64, int64) {
t.Helper()
var version, hash int64
if err := pool.QueryRow(ctx, `
SELECT version, hash
FROM read_model_versions
WHERE model = 'dialog_light' AND owner_user_id = $1 AND peer_type = $2 AND peer_id = $3`, ownerID, peer.Type, peer.ID).Scan(&version, &hash); err != nil {
t.Fatalf("read dialog_light version/hash: %v", err)
}
return version, hash
}
func containsInt64(values []int64, target int64) bool {
return countInt64(values, target) > 0
}
func countInt64(values []int64, target int64) int {
n := 0
for _, value := range values {
if value == target {
n++
}
}
return n
}
func countDialogInvalidation(cache *fakeDialogReadModelCache, ownerID int64, peer domain.Peer) int {
owners, peers := cache.entriesSnapshot()
n := 0
for i, owner := range owners {
if owner == ownerID && i < len(peers) && peers[i] == peer {
n++
}
}
return n
}