Files
silo-server/internal/metadata/image_resolver_test.go

370 lines
12 KiB
Go

package metadata
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type fakeExpiringImageSource struct {
expiresAt *time.Time
delay time.Duration
calls atomic.Int32
}
func (s *fakeExpiringImageSource) ResolveImageURL(ctx context.Context, path string, variant string) (string, error) {
resolved, err := s.ResolveImageURLWithExpiry(ctx, path, variant)
return resolved.URL, err
}
func (s *fakeExpiringImageSource) ResolveImageURLWithExpiry(ctx context.Context, path string, variant string) (catalog.ResolvedImageURL, error) {
resolved, err := s.ResolveImageURLsWithExpiry(ctx, []string{path}, variant)
if err != nil {
return catalog.ResolvedImageURL{}, err
}
return resolved[path], nil
}
func (s *fakeExpiringImageSource) ResolveImageURLs(ctx context.Context, paths []string, variant string) (map[string]string, error) {
resolved, err := s.ResolveImageURLsWithExpiry(ctx, paths, variant)
if err != nil {
return nil, err
}
urls := make(map[string]string, len(resolved))
for path, value := range resolved {
urls[path] = value.URL
}
return urls, nil
}
func (s *fakeExpiringImageSource) ResolveImageURLsWithExpiry(ctx context.Context, paths []string, variant string) (map[string]catalog.ResolvedImageURL, error) {
s.calls.Add(1)
if s.delay > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(s.delay):
}
}
resolved := make(map[string]catalog.ResolvedImageURL, len(paths))
for _, path := range paths {
resolved[path] = catalog.ResolvedImageURL{
URL: "plugin:" + variant + ":" + path,
ExpiresAt: s.expiresAt,
}
}
return resolved, nil
}
type scriptedImageSource struct {
urls map[string]string
err error
calls atomic.Int32
}
func (s *scriptedImageSource) ResolveImageURL(ctx context.Context, path string, variant string) (string, error) {
resolved, err := s.ResolveImageURLsWithExpiry(ctx, []string{path}, variant)
if err != nil {
return "", err
}
return resolved[path].URL, nil
}
func (s *scriptedImageSource) ResolveImageURLs(ctx context.Context, paths []string, variant string) (map[string]string, error) {
resolved, err := s.ResolveImageURLsWithExpiry(ctx, paths, variant)
if err != nil {
return nil, err
}
urls := make(map[string]string, len(resolved))
for path, value := range resolved {
urls[path] = value.URL
}
return urls, nil
}
func (s *scriptedImageSource) ResolveImageURLsWithExpiry(_ context.Context, paths []string, variant string) (map[string]catalog.ResolvedImageURL, error) {
s.calls.Add(1)
if s.err != nil {
return nil, s.err
}
resolved := make(map[string]catalog.ResolvedImageURL, len(paths))
for _, path := range paths {
if url, ok := s.urls[path]; ok {
resolved[path] = catalog.ResolvedImageURL{URL: url + ":" + variant}
}
}
return resolved, nil
}
func TestPluginImageResolverCachesOnlyKnownUsableExpiries(t *testing.T) {
expiresAt := time.Now().Add(time.Hour)
source := &fakeExpiringImageSource{expiresAt: &expiresAt}
resolver := NewPluginImageResolver()
defer resolver.Close()
resolver.RegisterSource("plug", source)
for range 2 {
got := resolver.ResolveImageURLWithExpiry(context.Background(), "plug://poster.jpg", "featured")
if got.URL != "plugin:featured:poster.jpg" {
t.Fatalf("resolved URL = %q", got.URL)
}
}
if calls := source.calls.Load(); calls != 1 {
t.Fatalf("plugin calls with usable expiry = %d, want 1", calls)
}
noExpirySource := &fakeExpiringImageSource{}
noExpiryResolver := NewPluginImageResolver()
defer noExpiryResolver.Close()
noExpiryResolver.RegisterSource("plug", noExpirySource)
for range 2 {
_ = noExpiryResolver.ResolveImageURLWithExpiry(context.Background(), "plug://poster.jpg", "featured")
}
if calls := noExpirySource.calls.Load(); calls != 2 {
t.Fatalf("plugin calls without expiry = %d, want 2", calls)
}
nearExpiry := time.Now().Add(time.Minute)
nearExpirySource := &fakeExpiringImageSource{expiresAt: &nearExpiry}
nearExpiryResolver := NewPluginImageResolver()
defer nearExpiryResolver.Close()
nearExpiryResolver.RegisterSource("plug", nearExpirySource)
for range 2 {
_ = nearExpiryResolver.ResolveImageURLWithExpiry(context.Background(), "plug://poster.jpg", "featured")
}
if calls := nearExpirySource.calls.Load(); calls != 2 {
t.Fatalf("plugin calls with near expiry = %d, want 2", calls)
}
}
func TestPluginImageResolverExplicitSourcesPrecedeLegacyFallbacks(t *testing.T) {
resolver := NewPluginImageResolver()
defer resolver.Close()
explicit := &scriptedImageSource{urls: map[string]string{"poster.jpg": "explicit"}}
legacy := &scriptedImageSource{urls: map[string]string{"poster.jpg": "legacy"}}
resolver.ReplaceSources([]PluginImageResolverSourceRegistration{
{
Scheme: "tmdb",
Source: legacy,
Kind: PluginImageResolverSourceLegacy,
Priority: 1000,
InstallationID: 1,
CapabilityID: "tmdb",
},
{
Scheme: "tmdb",
Source: explicit,
Kind: PluginImageResolverSourceExplicit,
Priority: 0,
InstallationID: 2,
CapabilityID: "tmdb",
},
})
got := resolver.ResolveImageURL(context.Background(), "tmdb://poster.jpg", "card")
if got != "explicit:card" {
t.Fatalf("resolved URL = %q, want explicit source", got)
}
if calls := legacy.calls.Load(); calls != 0 {
t.Fatalf("legacy source calls = %d, want 0 when explicit resolves", calls)
}
}
func TestPluginImageResolverOrdersSourcesByPriority(t *testing.T) {
resolver := NewPluginImageResolver()
defer resolver.Close()
low := &scriptedImageSource{urls: map[string]string{"poster.jpg": "low"}}
high := &scriptedImageSource{urls: map[string]string{"poster.jpg": "high"}}
resolver.ReplaceSources([]PluginImageResolverSourceRegistration{
{
Scheme: "tmdb",
Source: low,
Kind: PluginImageResolverSourceExplicit,
Priority: 10,
InstallationID: 1,
CapabilityID: "low",
},
{
Scheme: "tmdb",
Source: high,
Kind: PluginImageResolverSourceExplicit,
Priority: 50,
InstallationID: 2,
CapabilityID: "high",
},
})
got := resolver.ResolveImageURL(context.Background(), "tmdb://poster.jpg", "card")
if got != "high:card" {
t.Fatalf("resolved URL = %q, want high-priority source", got)
}
}
func TestPluginImageResolverSkipsUnimplementedSourcesInEitherRegistrationOrder(t *testing.T) {
cases := []struct {
name string
registrations func(broken, working *scriptedImageSource) []PluginImageResolverSourceRegistration
}{
{
name: "working registered first",
registrations: func(broken, working *scriptedImageSource) []PluginImageResolverSourceRegistration {
return []PluginImageResolverSourceRegistration{
{Scheme: "tmdb", Source: working, Kind: PluginImageResolverSourceExplicit, Priority: 10, InstallationID: 2, CapabilityID: "working"},
{Scheme: "tmdb", Source: broken, Kind: PluginImageResolverSourceExplicit, Priority: 20, InstallationID: 1, CapabilityID: "broken"},
}
},
},
{
name: "broken registered first",
registrations: func(broken, working *scriptedImageSource) []PluginImageResolverSourceRegistration {
return []PluginImageResolverSourceRegistration{
{Scheme: "tmdb", Source: broken, Kind: PluginImageResolverSourceExplicit, Priority: 20, InstallationID: 1, CapabilityID: "broken"},
{Scheme: "tmdb", Source: working, Kind: PluginImageResolverSourceExplicit, Priority: 10, InstallationID: 2, CapabilityID: "working"},
}
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
resolver := NewPluginImageResolver()
defer resolver.Close()
broken := &scriptedImageSource{err: status.Error(codes.Unimplemented, "method ResolveImageURLs not implemented")}
working := &scriptedImageSource{urls: map[string]string{"poster.jpg": "working"}}
resolver.ReplaceSources(tt.registrations(broken, working))
got := resolver.ResolveImageURL(context.Background(), "tmdb://poster.jpg", "card")
if got != "working:card" {
t.Fatalf("resolved URL = %q, want fallback working source", got)
}
})
}
}
func TestPluginImageResolverFallsThroughForPartialBatchResults(t *testing.T) {
resolver := NewPluginImageResolver()
defer resolver.Close()
primary := &scriptedImageSource{urls: map[string]string{"a.jpg": "primary-a"}}
secondary := &scriptedImageSource{urls: map[string]string{
"a.jpg": "secondary-a",
"b.jpg": "secondary-b",
}}
resolver.ReplaceSources([]PluginImageResolverSourceRegistration{
{Scheme: "tmdb", Source: primary, Kind: PluginImageResolverSourceExplicit, Priority: 100, InstallationID: 1, CapabilityID: "primary"},
{Scheme: "tmdb", Source: secondary, Kind: PluginImageResolverSourceExplicit, Priority: 10, InstallationID: 2, CapabilityID: "secondary"},
})
got := resolver.ResolveImageURLs(context.Background(), []string{"tmdb://a.jpg", "tmdb://b.jpg"}, "card")
if got["tmdb://a.jpg"] != "primary-a:card" {
t.Fatalf("a.jpg = %q, want primary result", got["tmdb://a.jpg"])
}
if got["tmdb://b.jpg"] != "secondary-b:card" {
t.Fatalf("b.jpg = %q, want secondary fallback result", got["tmdb://b.jpg"])
}
}
func TestPluginImageResolverDoesNotCacheEmptyFailure(t *testing.T) {
resolver := NewPluginImageResolver()
defer resolver.Close()
source := &scriptedImageSource{err: status.Error(codes.Unavailable, "temporary outage")}
resolver.ReplaceSources([]PluginImageResolverSourceRegistration{
{Scheme: "tmdb", Source: source, Kind: PluginImageResolverSourceExplicit, Priority: 100, InstallationID: 1, CapabilityID: "tmdb"},
})
if got := resolver.ResolveImageURL(context.Background(), "tmdb://poster.jpg", "card"); got != "" {
t.Fatalf("first resolved URL = %q, want empty during failure", got)
}
source.err = nil
source.urls = map[string]string{"poster.jpg": "recovered"}
got := resolver.ResolveImageURL(context.Background(), "tmdb://poster.jpg", "card")
if got != "recovered:card" {
t.Fatalf("second resolved URL = %q, want recovered result", got)
}
if calls := source.calls.Load(); calls != 2 {
t.Fatalf("source calls = %d, want 2 to prove failure was not cached", calls)
}
}
func TestPluginImageResolverCoalescesConcurrentBatchMisses(t *testing.T) {
expiresAt := time.Now().Add(time.Hour)
source := &fakeExpiringImageSource{expiresAt: &expiresAt, delay: 50 * time.Millisecond}
resolver := NewPluginImageResolver()
defer resolver.Close()
resolver.RegisterSource("plug", source)
const workers = 24
start := make(chan struct{})
var wg sync.WaitGroup
wg.Add(workers)
for i := range workers {
go func(i int) {
defer wg.Done()
<-start
resolved := resolver.ResolveImageURLsWithExpiry(context.Background(), []string{"plug://a.jpg", "plug://b.jpg"}, "featured")
if got := resolved["plug://a.jpg"].URL; got != "plugin:featured:a.jpg" {
t.Errorf("worker %d resolved a.jpg = %q", i, got)
}
}(i)
}
close(start)
wg.Wait()
if calls := source.calls.Load(); calls != 1 {
t.Fatalf("plugin calls = %d, want 1", calls)
}
}
type fakeS3ImagePresigner struct {
calls int
ttl time.Duration
}
func (p *fakeS3ImagePresigner) PresignGetURL(_ context.Context, _ string, key string, expiry time.Duration) (string, error) {
p.calls++
p.ttl = expiry
return fmt.Sprintf("s3:%s:%d", key, p.calls), nil
}
func (p *fakeS3ImagePresigner) Bucket() string {
return "metadata"
}
func TestPluginImageResolverS3URLsCarryConfiguredExpiry(t *testing.T) {
presigner := &fakeS3ImagePresigner{}
resolver := NewPluginImageResolver()
defer resolver.Close()
resolver.SetS3Presigner(presigner, 10*time.Minute)
before := time.Now()
first := resolver.ResolveImageURLWithExpiry(context.Background(), "poster.jpg", "featured")
second := resolver.ResolveImageURLWithExpiry(context.Background(), "poster.jpg", "featured")
if first.URL == "" || second.URL != first.URL {
t.Fatalf("cached S3 URLs = first %q second %q", first.URL, second.URL)
}
if presigner.calls != 1 {
t.Fatalf("s3 presign calls = %d, want 1", presigner.calls)
}
if presigner.ttl != 10*time.Minute {
t.Fatalf("s3 presign ttl = %s, want 10m", presigner.ttl)
}
if first.ExpiresAt == nil {
t.Fatal("S3 resolved URL missing expiry")
}
if first.ExpiresAt.Before(before.Add(9*time.Minute)) || first.ExpiresAt.After(before.Add(11*time.Minute)) {
t.Fatalf("S3 expiry = %s, want about 10m from now", first.ExpiresAt.Sub(before))
}
}