221 lines
7.4 KiB
Go
221 lines
7.4 KiB
Go
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 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])
|
||
}
|
||
}
|