Ctrl K
ComunidadGuías…

internal/app/userprojection/photo_cache_test.go

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

Archivo original ↗Preguntar a KiBot
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()

	// First time: both owners miss, so the underlying store is queried once.
	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)
	}

	// Second time (within TTL): all hits in cache (including owner 2's negative result), no further underlying query.
	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)
	}

	// Different kinds are independent cache keys: fallback should query again.
	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)
	}

	// After the TTL expires it re-queries the underlying store.
	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])
	}
}