370 lines
12 KiB
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))
|
|
}
|
|
}
|