owpengram-server/internal/app/privacy/service_test.go
2026-09-01 12:06:31 +03:00

537 lines
21 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 privacy
import (
"context"
"errors"
"testing"
"time"
"telesrv/internal/domain"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
type countingBaseUsers struct {
calls int
users map[int64]domain.User
}
func (p *countingBaseUsers) PrivacyBaseUsers(_ context.Context, userIDs []int64) ([]domain.User, error) {
p.calls++
out := make([]domain.User, 0, len(userIDs))
for _, userID := range userIDs {
if user, ok := p.users[userID]; ok {
out = append(out, user)
}
}
return out, nil
}
type countingMemberships struct {
calls int
batchCalls int
batchRequests []map[int64][]int64
active map[int64]map[int64]bool
}
type countingSparseContacts struct {
store.ContactStore
sparseCalls int
getMany int
requested map[int64][]int64
}
func (c *countingSparseContacts) GetMany(ctx context.Context, ownerUserID int64, viewerUserIDs []int64) (map[int64]domain.Contact, error) {
c.getMany++
return c.ContactStore.GetMany(ctx, ownerUserID, viewerUserIDs)
}
func (c *countingSparseContacts) ContactProjectionForViewerUserIDs(ctx context.Context, requested map[int64][]int64) (domain.ContactProjectionBatch, error) {
c.sparseCalls++
c.requested = make(map[int64][]int64, len(requested))
for viewerID, targetIDs := range requested {
c.requested[viewerID] = append([]int64(nil), targetIDs...)
}
return c.ContactStore.(store.SparseContactProjectionStore).ContactProjectionForViewerUserIDs(ctx, requested)
}
func (p *countingMemberships) FilterActiveChannelMemberPairs(_ context.Context, requested map[int64][]int64) (map[int64][]int64, error) {
p.batchCalls++
cloned := make(map[int64][]int64, len(requested))
out := make(map[int64][]int64, len(requested))
for channelID, userIDs := range requested {
cloned[channelID] = append([]int64(nil), userIDs...)
for _, userID := range userIDs {
if p.active[channelID][userID] {
out[channelID] = append(out[channelID], userID)
}
}
}
p.batchRequests = append(p.batchRequests, cloned)
return out, nil
}
func (p *countingMemberships) FilterActiveChannelMemberIDs(_ context.Context, channelID int64, userIDs []int64) ([]int64, error) {
p.calls++
out := make([]int64, 0, len(userIDs))
for _, userID := range userIDs {
if p.active[channelID][userID] {
out = append(out, userID)
}
}
return out, nil
}
func TestDefaultPrivacyRules(t *testing.T) {
ctx := context.Background()
svc := NewService(memory.NewPrivacyStore(), memory.NewContactStore())
phone, err := svc.GetRules(ctx, 1001, domain.PrivacyKeyPhoneNumber)
if err != nil {
t.Fatalf("phone rules: %v", err)
}
if len(phone.Rules) != 1 || phone.Rules[0].Kind != domain.PrivacyRuleDisallowAll {
t.Fatalf("phone default = %+v, want disallow all", phone.Rules)
}
birthday, err := svc.GetRules(ctx, 1001, domain.PrivacyKeyBirthday)
if err != nil {
t.Fatalf("birthday rules: %v", err)
}
if len(birthday.Rules) != 1 || birthday.Rules[0].Kind != domain.PrivacyRuleAllowContacts {
t.Fatalf("birthday default = %+v, want allow contacts", birthday.Rules)
}
profile, err := svc.GetRules(ctx, 1001, domain.PrivacyKeyProfilePhoto)
if err != nil {
t.Fatalf("profile rules: %v", err)
}
if len(profile.Rules) != 1 || profile.Rules[0].Kind != domain.PrivacyRuleAllowAll {
t.Fatalf("profile default = %+v, want allow all", profile.Rules)
}
}
func TestCanSeeAnonymousHonorsPublicOnlyRules(t *testing.T) {
ctx := context.Background()
store := memory.NewPrivacyStore()
svc := NewService(store, nil)
const ownerID int64 = 1001
if visible, err := svc.CanSeeAnonymous(ctx, ownerID, domain.PrivacyKeyAbout); err != nil || !visible {
t.Fatalf("default anonymous about visibility = %v, err=%v; want true", visible, err)
}
if _, err := svc.SetRules(ctx, ownerID, domain.PrivacyKeyProfilePhoto, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil {
t.Fatalf("set contacts-only profile photo: %v", err)
}
if visible, err := svc.CanSeeAnonymous(ctx, ownerID, domain.PrivacyKeyProfilePhoto); err != nil || visible {
t.Fatalf("contacts-only anonymous photo visibility = %v, err=%v; want false", visible, err)
}
if _, err := svc.SetRules(ctx, ownerID, domain.PrivacyKeyProfilePhoto, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleDisallowUsers, UserIDs: []int64{2002}},
{Kind: domain.PrivacyRuleAllowAll},
}); err != nil {
t.Fatalf("set public profile photo: %v", err)
}
if visible, err := svc.CanSeeAnonymous(ctx, ownerID, domain.PrivacyKeyProfilePhoto); err != nil || !visible {
t.Fatalf("allow-all anonymous photo visibility = %v, err=%v; want true", visible, err)
}
}
func TestAddAllowUserOverridesDisallowAll(t *testing.T) {
ctx := context.Background()
svc := NewService(memory.NewPrivacyStore(), memory.NewContactStore())
if _, err := svc.SetRules(ctx, 1001, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil {
t.Fatalf("set rules: %v", err)
}
allowed, err := svc.CanSee(ctx, 1001, 1002, domain.PrivacyKeyPhoneNumber)
if err != nil {
t.Fatalf("can see before: %v", err)
}
if allowed {
t.Fatal("viewer should not see phone before exception")
}
if _, changed, err := svc.AddAllowUser(ctx, 1001, domain.PrivacyKeyPhoneNumber, 1002); err != nil {
t.Fatalf("add allow: %v", err)
} else if !changed {
t.Fatal("first add allow should report changed")
}
allowed, err = svc.CanSee(ctx, 1001, 1002, domain.PrivacyKeyPhoneNumber)
if err != nil {
t.Fatalf("can see after: %v", err)
}
if !allowed {
t.Fatal("viewer should see phone after allow-user exception")
}
}
func TestExplicitDisallowUserWins(t *testing.T) {
rules := domain.PrivacyRules{
Key: domain.PrivacyKeyProfilePhoto,
Rules: []domain.PrivacyRule{
{Kind: domain.PrivacyRuleAllowAll},
{Kind: domain.PrivacyRuleDisallowUsers, UserIDs: []int64{1002}},
},
}
if Evaluate(rules, domain.PrivacyContext{OwnerUserID: 1001, ViewerUserID: 1002}) {
t.Fatal("explicit disallow user should win over allow all")
}
}
// TestCanSeeBatchEquivalentToCanSee 锁定批量 privacy 评估与逐 CanSee 字节等价projectBatch
// fan-out N+1 优化的正确性前提):覆盖默认规则/allow-all/disallow-all/allow-contacts(含联系人)/self。
func TestCanSeeBatchEquivalentToCanSee(t *testing.T) {
ctx := context.Background()
contacts := memory.NewContactStore()
svc := NewService(memory.NewPrivacyStore(), contacts)
const viewer = int64(1002)
owners := []int64{1001, 1003, 1004, 1005, viewer}
if _, err := svc.SetRules(ctx, 1003, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}}); err != nil {
t.Fatalf("set 1003 phone: %v", err)
}
if _, err := svc.SetRules(ctx, 1004, domain.PrivacyKeyStatusTimestamp, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil {
t.Fatalf("set 1004 status: %v", err)
}
if _, err := svc.SetRules(ctx, 1005, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil {
t.Fatalf("set 1005 phone: %v", err)
}
// owner 1005 把 viewer 加为联系人GetReverseContacts(viewer,[1005]) 命中 → allow-contacts 可见)。
if _, err := contacts.Upsert(ctx, 1005, domain.ContactInput{ContactUserID: viewer}); err != nil {
t.Fatalf("upsert contact: %v", err)
}
keys := []domain.PrivacyKey{domain.PrivacyKeyPhoneNumber, domain.PrivacyKeyStatusTimestamp, domain.PrivacyKeyProfilePhoto}
batch, err := svc.CanSeeBatch(ctx, owners, viewer, keys)
if err != nil {
t.Fatalf("CanSeeBatch: %v", err)
}
for _, owner := range owners {
for _, k := range keys {
want, err := svc.CanSee(ctx, owner, viewer, k)
if err != nil {
t.Fatalf("CanSee(%d,%d,%v): %v", owner, viewer, k, err)
}
got, ok := batch[owner][k]
if !ok {
t.Fatalf("CanSeeBatch missing owner=%d key=%v", owner, k)
}
if got != want {
t.Fatalf("CanSeeBatch[%d][%v]=%v != CanSee=%v (must be equivalent)", owner, k, got, want)
}
}
}
}
// TestCanSeeMatrixEquivalentToCanSee 锁定 owners×viewers×keys 矩阵评估与逐 CanSee 字节等价
// ForViewers fan-out 模板化把 privacy 查询降到 O(owner) 的正确性前提)。覆盖多 owner 多 viewer
// 不同规则、联系人方向owner 把 viewer 加为联系人才命中 allow-contacts、selfowner==viewer
func TestCanSeeMatrixEquivalentToCanSee(t *testing.T) {
ctx := context.Background()
contacts := memory.NewContactStore()
svc := NewService(memory.NewPrivacyStore(), contacts)
owners := []int64{6001, 6002, 6003, 6004}
viewers := []int64{7001, 7002, 6002} // 6002 既是 owner 又是 viewer → 命中 self 分支
if _, err := svc.SetRules(ctx, 6002, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowAll}}); err != nil {
t.Fatalf("set 6002 phone: %v", err)
}
if _, err := svc.SetRules(ctx, 6003, domain.PrivacyKeyStatusTimestamp, []domain.PrivacyRule{{Kind: domain.PrivacyRuleDisallowAll}}); err != nil {
t.Fatalf("set 6003 status: %v", err)
}
if _, err := svc.SetRules(ctx, 6004, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{{Kind: domain.PrivacyRuleAllowContacts}}); err != nil {
t.Fatalf("set 6004 phone: %v", err)
}
// owner 6004 把 viewer 7001 加为联系人owner→viewer 方向 = privacy 的 ViewerIsContact
if _, err := contacts.Upsert(ctx, 6004, domain.ContactInput{ContactUserID: 7001}); err != nil {
t.Fatalf("upsert contact: %v", err)
}
keys := []domain.PrivacyKey{domain.PrivacyKeyPhoneNumber, domain.PrivacyKeyStatusTimestamp, domain.PrivacyKeyProfilePhoto}
matrix, err := svc.CanSeeMatrix(ctx, owners, viewers, keys)
if err != nil {
t.Fatalf("CanSeeMatrix: %v", err)
}
for _, owner := range owners {
for _, viewer := range viewers {
for _, k := range keys {
want, err := svc.CanSee(ctx, owner, viewer, k)
if err != nil {
t.Fatalf("CanSee(%d,%d,%v): %v", owner, viewer, k, err)
}
got, ok := matrix[owner][viewer][k]
if !ok {
t.Fatalf("CanSeeMatrix missing owner=%d viewer=%d key=%v", owner, viewer, k)
}
if got != want {
t.Fatalf("CanSeeMatrix[%d][%d][%v]=%v != CanSee=%v (must be equivalent)", owner, viewer, k, got, want)
}
}
}
}
}
func TestCanSeeMatrixLoadsOwnerViewerContactsInOneSparseBatch(t *testing.T) {
ctx := context.Background()
inner := memory.NewContactStore()
contacts := &countingSparseContacts{ContactStore: inner}
svc := NewService(memory.NewPrivacyStore(), contacts)
owners := []int64{6101, 6102}
viewers := []int64{7101, 7102}
for _, owner := range owners {
if _, err := svc.SetRules(ctx, owner, domain.PrivacyKeyPhoneNumber, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleAllowContacts},
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("set owner %d rules: %v", owner, err)
}
}
if _, err := inner.Upsert(ctx, owners[0], domain.ContactInput{ContactUserID: viewers[0]}); err != nil {
t.Fatalf("upsert contact: %v", err)
}
matrix, err := svc.CanSeeMatrix(ctx, owners, viewers, []domain.PrivacyKey{domain.PrivacyKeyPhoneNumber})
if err != nil {
t.Fatalf("CanSeeMatrix: %v", err)
}
if contacts.sparseCalls != 1 || contacts.getMany != 0 {
t.Fatalf("contact reads = sparse %d / GetMany %d, want 1 / 0", contacts.sparseCalls, contacts.getMany)
}
for _, owner := range owners {
if got := len(contacts.requested[owner]); got != len(viewers) {
t.Fatalf("requested owner %d viewers = %v, want %v", owner, contacts.requested[owner], viewers)
}
}
if !matrix[owners[0]][viewers[0]][domain.PrivacyKeyPhoneNumber] {
t.Fatal("owner contact relation was not applied")
}
if matrix[owners[0]][viewers[1]][domain.PrivacyKeyPhoneNumber] ||
matrix[owners[1]][viewers[0]][domain.PrivacyKeyPhoneNumber] ||
matrix[owners[1]][viewers[1]][domain.PrivacyKeyPhoneNumber] {
t.Fatalf("unexpected non-contact visibility: %+v", matrix)
}
}
func TestViewerFactsReadModelBatchesCachesAndInvalidates(t *testing.T) {
ctx := context.Background()
rules := memory.NewPrivacyStore()
users := &countingBaseUsers{users: map[int64]domain.User{
2001: {ID: 2001, PremiumUntil: 2000},
2002: {ID: 2002, Bot: true},
}}
svc := NewService(rules, memory.NewContactStore()).ConfigureReadModels(users, nil)
svc.now = func() time.Time { return time.Unix(1000, 0) }
if _, err := svc.SetRules(ctx, 1001, domain.PrivacyKeyNoPaidMessages, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleAllowPremium},
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("set premium rules: %v", err)
}
if _, err := svc.SetRules(ctx, 1002, domain.PrivacyKeyNoPaidMessages, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleAllowBots},
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("set bot rules: %v", err)
}
got, err := svc.CanSeeMatrix(
ctx,
[]int64{1001, 1002},
[]int64{2001, 2002},
[]domain.PrivacyKey{domain.PrivacyKeyNoPaidMessages},
)
if err != nil {
t.Fatalf("CanSeeMatrix: %v", err)
}
if !got[1001][2001][domain.PrivacyKeyNoPaidMessages] ||
got[1001][2002][domain.PrivacyKeyNoPaidMessages] ||
got[1002][2001][domain.PrivacyKeyNoPaidMessages] ||
!got[1002][2002][domain.PrivacyKeyNoPaidMessages] {
t.Fatalf("unexpected premium/bot visibility matrix: %+v", got)
}
if users.calls != 1 {
t.Fatalf("base user cold loads = %d, want one batched load", users.calls)
}
if premium, err := svc.ViewerIsPremium(ctx, 2001); err != nil || !premium {
t.Fatalf("warm ViewerIsPremium = %v, err=%v; want true", premium, err)
}
if users.calls != 1 {
t.Fatalf("warm viewer facts hit called backend: calls=%d", users.calls)
}
users.users[2001] = domain.User{ID: 2001}
svc.InvalidateViewerFacts(2001)
if premium, err := svc.ViewerIsPremium(ctx, 2001); err != nil || premium {
t.Fatalf("invalidated ViewerIsPremium = %v, err=%v; want false", premium, err)
}
if users.calls != 2 {
t.Fatalf("invalidated viewer facts cold loads = %d, want 2", users.calls)
}
}
func TestMembershipReadModelCachesNegativeFactsAndInvalidatesPair(t *testing.T) {
ctx := context.Background()
rules := memory.NewPrivacyStore()
memberships := &countingMemberships{active: map[int64]map[int64]bool{
9001: {2001: true},
9002: {},
}}
svc := NewService(rules, memory.NewContactStore()).ConfigureReadModels(nil, memberships)
if _, err := svc.SetRules(ctx, 1001, domain.PrivacyKeyChatInvite, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleAllowChatParticipants, ChatIDs: []int64{9001, 9002}},
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("set participant rules: %v", err)
}
got, err := svc.CanSeeMatrix(
ctx,
[]int64{1001},
[]int64{2001, 2002},
[]domain.PrivacyKey{domain.PrivacyKeyChatInvite},
)
if err != nil {
t.Fatalf("CanSeeMatrix: %v", err)
}
if !got[1001][2001][domain.PrivacyKeyChatInvite] ||
got[1001][2002][domain.PrivacyKeyChatInvite] {
t.Fatalf("unexpected membership visibility matrix: %+v", got)
}
if memberships.batchCalls != 1 || memberships.calls != 0 {
t.Fatalf("membership cold loads = batch %d scalar %d, want batch=1 scalar=0", memberships.batchCalls, memberships.calls)
}
if allowed, err := svc.CanSee(ctx, 1001, 2002, domain.PrivacyKeyChatInvite); err != nil || allowed {
t.Fatalf("warm negative membership = %v, err=%v; want false", allowed, err)
}
if memberships.batchCalls != 1 || memberships.calls != 0 {
t.Fatalf("negative cache missed: batch=%d scalar=%d", memberships.batchCalls, memberships.calls)
}
memberships.active[9002][2002] = true
svc.InvalidateMembership(9002, 2002)
if allowed, err := svc.CanSee(ctx, 1001, 2002, domain.PrivacyKeyChatInvite); err != nil || !allowed {
t.Fatalf("invalidated membership = %v, err=%v; want true", allowed, err)
}
if memberships.batchCalls != 2 || memberships.calls != 0 {
t.Fatalf("pair invalidation reloads = batch %d scalar %d, want batch=2 scalar=0", memberships.batchCalls, memberships.calls)
}
}
func TestSparsePrivacyMembershipUsesOneExactPairBatch(t *testing.T) {
ctx := context.Background()
const (
ownerA = int64(1001)
ownerB = int64(1002)
viewerA = int64(2001)
viewerB = int64(2002)
chatA = int64(9001)
chatB = int64(9002)
)
rules := memory.NewPrivacyStore()
memberships := &countingMemberships{active: map[int64]map[int64]bool{
chatA: {viewerA: true, viewerB: true},
chatB: {viewerA: true, viewerB: true},
}}
svc := NewService(rules, memory.NewContactStore()).ConfigureReadModels(nil, memberships)
for ownerID, chatID := range map[int64]int64{ownerA: chatA, ownerB: chatB} {
if _, err := svc.SetRules(ctx, ownerID, domain.PrivacyKeyProfilePhoto, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleAllowChatParticipants, ChatIDs: []int64{chatID}},
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("SetRules(%d): %v", ownerID, err)
}
}
got, err := svc.CanSeeForViewerUserIDs(ctx, map[int64][]int64{
viewerA: {ownerA},
viewerB: {ownerB},
}, []domain.PrivacyKey{domain.PrivacyKeyProfilePhoto}, map[int64]map[int64]domain.Contact{})
if err != nil {
t.Fatalf("CanSeeForViewerUserIDs: %v", err)
}
if !got[ownerA][viewerA][domain.PrivacyKeyProfilePhoto] || !got[ownerB][viewerB][domain.PrivacyKeyProfilePhoto] {
t.Fatalf("visibility = %+v, want both exact pairs visible", got)
}
if memberships.batchCalls != 1 || memberships.calls != 0 {
t.Fatalf("membership loads = batch %d scalar %d, want batch=1 scalar=0", memberships.batchCalls, memberships.calls)
}
requested := memberships.batchRequests[0]
if len(requested) != 2 || len(requested[chatA]) != 1 || requested[chatA][0] != viewerA || len(requested[chatB]) != 1 || requested[chatB][0] != viewerB {
t.Fatalf("membership request = %+v, want only (%d,%d) and (%d,%d)", requested, chatA, viewerA, chatB, viewerB)
}
}
func TestSparsePrivacyMembershipRejectsDerivedPairOverflowBeforeLoad(t *testing.T) {
ctx := context.Background()
rules := memory.NewPrivacyStore()
memberships := &countingMemberships{active: map[int64]map[int64]bool{}}
svc := NewService(rules, memory.NewContactStore()).ConfigureReadModels(nil, memberships)
owners := []int64{1001, 1002, 1003, 1004, 1005}
for ownerIndex, ownerID := range owners {
chatIDs := make([]int64, 5000)
for i := range chatIDs {
chatIDs[i] = int64(100000 + ownerIndex*10000 + i)
}
if _, err := svc.SetRules(ctx, ownerID, domain.PrivacyKeyProfilePhoto, []domain.PrivacyRule{
{Kind: domain.PrivacyRuleAllowChatParticipants, ChatIDs: chatIDs},
{Kind: domain.PrivacyRuleDisallowAll},
}); err != nil {
t.Fatalf("SetRules(%d): %v", ownerID, err)
}
}
_, err := svc.CanSeeForViewerUserIDs(ctx, map[int64][]int64{
2001: owners,
2002: owners,
2003: owners,
}, []domain.PrivacyKey{domain.PrivacyKeyProfilePhoto}, map[int64]map[int64]domain.Contact{})
if !errors.Is(err, store.ErrActiveChannelMemberPairsLimit) {
t.Fatalf("CanSeeForViewerUserIDs error = %v, want ErrActiveChannelMemberPairsLimit", err)
}
if memberships.batchCalls != 0 || memberships.calls != 0 {
t.Fatalf("membership loads = batch %d scalar %d, want fail before load", memberships.batchCalls, memberships.calls)
}
}
func TestDensePrivacyMembershipRejectsDerivedPairOverflowBeforeLoad(t *testing.T) {
memberships := &countingMemberships{active: map[int64]map[int64]bool{}}
svc := NewService(memory.NewPrivacyStore(), memory.NewContactStore()).ConfigureReadModels(nil, memberships)
chatIDs := make([]int64, 257)
viewerIDs := make([]int64, 256)
for i := range chatIDs {
chatIDs[i] = int64(i + 1)
}
for i := range viewerIDs {
viewerIDs[i] = int64(1000 + i)
}
_, err := svc.loadMembershipFacts(context.Background(), chatIDs, viewerIDs)
if !errors.Is(err, store.ErrActiveChannelMemberPairsLimit) {
t.Fatalf("loadMembershipFacts error = %v, want ErrActiveChannelMemberPairsLimit", err)
}
if memberships.batchCalls != 0 || memberships.calls != 0 {
t.Fatalf("membership loads = batch %d scalar %d, want fail before load", memberships.batchCalls, memberships.calls)
}
}
func TestLargeMembershipBatchBypassesLRUAdmissionWithoutEvictingHotPair(t *testing.T) {
ctx := context.Background()
memberships := &countingMemberships{active: map[int64]map[int64]bool{}}
svc := NewService(memory.NewPrivacyStore(), memory.NewContactStore()).ConfigureReadModels(nil, memberships)
hot := membershipKey{ChatID: 9001, UserID: 2001}
if _, err := svc.loadMembershipFactsForKeys(ctx, []membershipKey{hot}); err != nil {
t.Fatalf("warm hot membership pair: %v", err)
}
large := make([]membershipKey, store.MaxActiveChannelMemberPairs)
for i := range large {
large[i] = membershipKey{ChatID: 9002, UserID: int64(100000 + i)}
}
if _, err := svc.loadMembershipFactsForKeys(ctx, large); err != nil {
t.Fatalf("load large membership batch: %v", err)
}
if memberships.batchCalls != 2 || memberships.calls != 0 {
t.Fatalf("loads after large batch = batch %d scalar %d, want batch=2 scalar=0", memberships.batchCalls, memberships.calls)
}
if _, err := svc.loadMembershipFactsForKeys(ctx, []membershipKey{hot}); err != nil {
t.Fatalf("reload hot membership pair: %v", err)
}
if memberships.batchCalls != 2 || memberships.calls != 0 {
t.Fatalf("hot pair was evicted by non-admitted batch: batch=%d scalar=%d", memberships.batchCalls, memberships.calls)
}
}