package userprojection import ( "context" "sync" "testing" "time" "telesrv/internal/domain" ) type countingPhotoProvider struct { kindCalls int queriedIDs []int64 refs map[int64]domain.ProfilePhotoRef } type blockingFirstPhotoProvider struct { started chan struct{} release chan struct{} mu sync.Mutex firstUsed bool first map[int64]domain.ProfilePhotoRef refs map[int64]domain.ProfilePhotoRef } func (p *blockingFirstPhotoProvider) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) { return p.CurrentProfilePhotosKind(ctx, ownerType, ownerIDs, domain.ProfilePhotoKindProfile) } func (p *blockingFirstPhotoProvider) CurrentProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) { p.mu.Lock() if !p.firstUsed { p.firstUsed = true first := clonePhotoRefs(p.first, ownerIDs) p.mu.Unlock() close(p.started) select { case <-p.release: case <-ctx.Done(): return nil, ctx.Err() } return first, nil } out := clonePhotoRefs(p.refs, ownerIDs) p.mu.Unlock() return out, nil } func (p *blockingFirstPhotoProvider) setRef(ownerID int64, ref domain.ProfilePhotoRef) { p.mu.Lock() p.refs[ownerID] = ref p.mu.Unlock() } func clonePhotoRefs(in map[int64]domain.ProfilePhotoRef, ownerIDs []int64) map[int64]domain.ProfilePhotoRef { out := make(map[int64]domain.ProfilePhotoRef, len(ownerIDs)) for _, id := range ownerIDs { ref, ok := in[id] if !ok { continue } out[id] = cloneCachedProfilePhotoRef(ref) } return out } func (p *countingPhotoProvider) CurrentProfilePhotos(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64) (map[int64]domain.ProfilePhotoRef, error) { return p.CurrentProfilePhotosKind(ctx, ownerType, ownerIDs, domain.ProfilePhotoKindProfile) } func (p *countingPhotoProvider) CurrentProfilePhotosKind(ctx context.Context, ownerType domain.PeerType, ownerIDs []int64, kind domain.ProfilePhotoKind) (map[int64]domain.ProfilePhotoRef, error) { p.kindCalls++ p.queriedIDs = append(p.queriedIDs, ownerIDs...) out := map[int64]domain.ProfilePhotoRef{} for _, id := range ownerIDs { if ref, ok := p.refs[id]; ok { out[id] = ref } } return out, nil } func TestCachedPhotoProviderCachesHitsAndMisses(t *testing.T) { inner := &countingPhotoProvider{refs: map[int64]domain.ProfilePhotoRef{1: {PhotoID: 111}}} now := time.Unix(1000, 0) c := newCachedPhotoProviderWithClock(inner, time.Minute, func() time.Time { return now }) ctx := context.Background() // 首次:两 owner 都未命中,查底层一次。 got, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1, 2}, domain.ProfilePhotoKindProfile) if err != nil { t.Fatalf("first: %v", err) } if got[1].PhotoID != 111 { t.Fatalf("owner 1 ref = %+v, want PhotoID 111", got[1]) } if _, ok := got[2]; ok { t.Fatalf("owner 2 should have no photo") } if inner.kindCalls != 1 || len(inner.queriedIDs) != 2 { t.Fatalf("first call: kindCalls=%d queried=%v, want 1 call of 2 ids", inner.kindCalls, inner.queriedIDs) } // 二次(TTL 内):全部命中缓存(含 owner 2 的负结果),不再查底层。 got, err = c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1, 2}, domain.ProfilePhotoKindProfile) if err != nil { t.Fatalf("second: %v", err) } if got[1].PhotoID != 111 { t.Fatalf("cached owner 1 ref = %+v, want PhotoID 111", got[1]) } if inner.kindCalls != 1 { t.Fatalf("second call hit DB: kindCalls=%d, want still 1", inner.kindCalls) } // 不同 kind 是独立缓存键:fallback 应再查一次。 if _, err = c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindFallback); err != nil { t.Fatalf("fallback: %v", err) } if inner.kindCalls != 2 { t.Fatalf("fallback kind should query: kindCalls=%d, want 2", inner.kindCalls) } // TTL 过期后重新查底层。 now = now.Add(2 * time.Minute) if _, err = c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1, 2}, domain.ProfilePhotoKindProfile); err != nil { t.Fatalf("after ttl: %v", err) } if inner.kindCalls != 3 { t.Fatalf("after ttl should re-query: kindCalls=%d, want 3", inner.kindCalls) } } func TestCachedPhotoProviderDefaultTTLRetainsLoginRampWorkingSet(t *testing.T) { inner := &countingPhotoProvider{refs: map[int64]domain.ProfilePhotoRef{}} now := time.Unix(1000, 0) c := newCachedPhotoProvider(inner, 0, DefaultPhotoCacheMaxEntries, func() time.Time { return now }) ctx := context.Background() if _, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile); err != nil { t.Fatalf("first: %v", err) } now = now.Add(time.Minute) if _, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile); err != nil { t.Fatalf("within login ramp: %v", err) } if inner.kindCalls != 1 { t.Fatalf("default TTL expired inside 60s login ramp: calls=%d, want 1", inner.kindCalls) } now = now.Add(DefaultPhotoCacheTTL) if _, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile); err != nil { t.Fatalf("after safety TTL: %v", err) } if inner.kindCalls != 2 { t.Fatalf("safety TTL did not reload: calls=%d, want 2", inner.kindCalls) } } func TestCachedPhotoProviderConfiguredCapacityEvictsOneLRUKey(t *testing.T) { inner := &countingPhotoProvider{refs: map[int64]domain.ProfilePhotoRef{}} now := time.Unix(1000, 0) c := newCachedPhotoProvider(inner, time.Hour, 2, func() time.Time { return now }) ctx := context.Background() read := func(ownerID int64) { t.Helper() if _, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{ownerID}, domain.ProfilePhotoKindProfile); err != nil { t.Fatalf("owner %d: %v", ownerID, err) } } for _, ownerID := range []int64{1, 2, 1, 3, 1, 2} { read(ownerID) } if inner.kindCalls != 4 { t.Fatalf("kind calls = %d, want 4 with owner 1 touched and only owner 2 evicted", inner.kindCalls) } if c.cache.Len() != 2 { t.Fatalf("cache entries = %d, want configured capacity 2", c.cache.Len()) } } func TestCachedPhotoProviderInvalidatesOwnerAndFlushes(t *testing.T) { inner := &countingPhotoProvider{refs: map[int64]domain.ProfilePhotoRef{1: {PhotoID: 111}}} now := time.Unix(1000, 0) c := newCachedPhotoProviderWithClock(inner, time.Minute, func() time.Time { return now }) ctx := context.Background() if _, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile); err != nil { t.Fatalf("prime profile: %v", err) } if _, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindFallback); err != nil { t.Fatalf("prime fallback: %v", err) } inner.refs[1] = domain.ProfilePhotoRef{PhotoID: 222} c.InvalidateOwner(domain.PeerTypeUser, 1) profile, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile) if err != nil { t.Fatalf("profile after invalidation: %v", err) } if profile[1].PhotoID != 222 { t.Fatalf("profile after invalidation = %+v, want 222", profile[1]) } if _, err = c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindFallback); err != nil { t.Fatalf("fallback after invalidation: %v", err) } if inner.kindCalls != 4 { t.Fatalf("InvalidateOwner should drop both kinds: kindCalls=%d, want 4", inner.kindCalls) } inner.refs[1] = domain.ProfilePhotoRef{PhotoID: 333} c.FlushReadModelCache() profile, err = c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile) if err != nil { t.Fatalf("profile after flush: %v", err) } if profile[1].PhotoID != 333 { t.Fatalf("profile after flush = %+v, want 333", profile[1]) } } func TestCachedPhotoProviderDoesNotRefillStaleSnapshotAfterInvalidation(t *testing.T) { ctx := context.Background() inner := &blockingFirstPhotoProvider{ started: make(chan struct{}), release: make(chan struct{}), first: map[int64]domain.ProfilePhotoRef{1: {PhotoID: 111}}, refs: map[int64]domain.ProfilePhotoRef{1: {PhotoID: 111}}, } c := NewCachedPhotoProvider(inner, time.Minute) type readResult struct { refs map[int64]domain.ProfilePhotoRef err error } resultCh := make(chan readResult, 1) go func() { got, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile) resultCh <- readResult{refs: got, err: err} }() waitForCacheTestSignal(t, inner.started) inner.setRef(1, domain.ProfilePhotoRef{PhotoID: 222}) c.InvalidateOwner(domain.PeerTypeUser, 1) close(inner.release) var result readResult select { case result = <-resultCh: case <-time.After(2 * time.Second): t.Fatal("timed out waiting for photo read") } if result.err != nil { t.Fatalf("photo read: %v", result.err) } if result.refs[1].PhotoID != 222 { t.Fatalf("photo after concurrent invalidation = %+v, want 222", result.refs[1]) } cachedHit, err := c.CurrentProfilePhotosKind(ctx, domain.PeerTypeUser, []int64{1}, domain.ProfilePhotoKindProfile) if err != nil { t.Fatalf("cached hit after stale load retry: %v", err) } if cachedHit[1].PhotoID != 222 { t.Fatalf("cached photo after stale load retry = %+v, want 222", cachedHit[1]) } }