A community based topic aggregation platform built on atproto
at main 401 lines 11 kB view raw
1package imageproxy 2 3import ( 4 "context" 5 "errors" 6 "sync" 7 "testing" 8 "time" 9) 10 11// MockCache implements Cache for testing 12type MockCache struct { 13 mu sync.Mutex 14 data map[string][]byte 15 getCalls int 16 setCalls int 17 setData map[string][]byte // Track what was set 18} 19 20func NewMockCache() *MockCache { 21 return &MockCache{ 22 data: make(map[string][]byte), 23 setData: make(map[string][]byte), 24 } 25} 26 27func (m *MockCache) cacheKey(preset, did, cid string) string { 28 return preset + ":" + did + ":" + cid 29} 30 31func (m *MockCache) Get(preset, did, cid string) ([]byte, bool, error) { 32 m.mu.Lock() 33 defer m.mu.Unlock() 34 m.getCalls++ 35 key := m.cacheKey(preset, did, cid) 36 data, found := m.data[key] 37 return data, found, nil 38} 39 40func (m *MockCache) Set(preset, did, cid string, data []byte) error { 41 m.mu.Lock() 42 defer m.mu.Unlock() 43 m.setCalls++ 44 key := m.cacheKey(preset, did, cid) 45 m.data[key] = data 46 m.setData[key] = data 47 return nil 48} 49 50func (m *MockCache) Delete(preset, did, cid string) error { 51 m.mu.Lock() 52 defer m.mu.Unlock() 53 key := m.cacheKey(preset, did, cid) 54 delete(m.data, key) 55 return nil 56} 57 58func (m *MockCache) Cleanup() (int, error) { 59 // Mock implementation - no-op for tests 60 return 0, nil 61} 62 63func (m *MockCache) SetCacheData(preset, did, cid string, data []byte) { 64 m.mu.Lock() 65 defer m.mu.Unlock() 66 key := m.cacheKey(preset, did, cid) 67 m.data[key] = data 68} 69 70func (m *MockCache) GetCalls() int { 71 m.mu.Lock() 72 defer m.mu.Unlock() 73 return m.getCalls 74} 75 76func (m *MockCache) SetCalls() int { 77 m.mu.Lock() 78 defer m.mu.Unlock() 79 return m.setCalls 80} 81 82func (m *MockCache) GetSetData(preset, did, cid string) ([]byte, bool) { 83 m.mu.Lock() 84 defer m.mu.Unlock() 85 key := m.cacheKey(preset, did, cid) 86 data, found := m.setData[key] 87 return data, found 88} 89 90// MockProcessor implements Processor for testing 91type MockProcessor struct { 92 returnData []byte 93 returnErr error 94 calls int 95 mu sync.Mutex 96} 97 98func NewMockProcessor(returnData []byte, returnErr error) *MockProcessor { 99 return &MockProcessor{ 100 returnData: returnData, 101 returnErr: returnErr, 102 } 103} 104 105func (m *MockProcessor) Process(data []byte, preset Preset) ([]byte, error) { 106 m.mu.Lock() 107 m.calls++ 108 m.mu.Unlock() 109 if m.returnErr != nil { 110 return nil, m.returnErr 111 } 112 return m.returnData, nil 113} 114 115func (m *MockProcessor) Calls() int { 116 m.mu.Lock() 117 defer m.mu.Unlock() 118 return m.calls 119} 120 121// MockFetcher implements Fetcher for testing 122type MockFetcher struct { 123 returnData []byte 124 returnErr error 125 calls int 126 mu sync.Mutex 127} 128 129func NewMockFetcher(returnData []byte, returnErr error) *MockFetcher { 130 return &MockFetcher{ 131 returnData: returnData, 132 returnErr: returnErr, 133 } 134} 135 136func (m *MockFetcher) Fetch(ctx context.Context, pdsURL, did, cid string) ([]byte, error) { 137 m.mu.Lock() 138 m.calls++ 139 m.mu.Unlock() 140 if m.returnErr != nil { 141 return nil, m.returnErr 142 } 143 return m.returnData, nil 144} 145 146func (m *MockFetcher) Calls() int { 147 m.mu.Lock() 148 defer m.mu.Unlock() 149 return m.calls 150} 151 152// mustNewService is a test helper that creates a service or fails the test 153func mustNewService(t *testing.T, cache Cache, processor Processor, fetcher Fetcher, config Config) *ImageProxyService { 154 t.Helper() 155 service, err := NewService(cache, processor, fetcher, config) 156 if err != nil { 157 t.Fatalf("NewService failed: %v", err) 158 } 159 return service 160} 161 162func TestImageProxyService_GetImage_CacheHit(t *testing.T) { 163 cache := NewMockCache() 164 processor := NewMockProcessor(nil, nil) 165 fetcher := NewMockFetcher(nil, nil) 166 config := DefaultConfig() 167 168 // Pre-populate the cache 169 cachedData := []byte("cached image data") 170 cache.SetCacheData("avatar", "did:plc:test123", "bafyreicid123", cachedData) 171 172 service := mustNewService(t, cache, processor, fetcher, config) 173 ctx := context.Background() 174 175 data, err := service.GetImage(ctx, "avatar", "did:plc:test123", "bafyreicid123", "https://pds.example.com") 176 if err != nil { 177 t.Fatalf("expected no error, got: %v", err) 178 } 179 if string(data) != string(cachedData) { 180 t.Errorf("expected cached data %q, got %q", cachedData, data) 181 } 182 183 // Verify fetcher was not called 184 if fetcher.Calls() != 0 { 185 t.Errorf("expected fetcher to not be called on cache hit, got %d calls", fetcher.Calls()) 186 } 187 188 // Verify processor was not called 189 if processor.Calls() != 0 { 190 t.Errorf("expected processor to not be called on cache hit, got %d calls", processor.Calls()) 191 } 192} 193 194func TestImageProxyService_GetImage_CacheMiss(t *testing.T) { 195 cache := NewMockCache() 196 rawImageData := []byte("raw image from PDS") 197 processedData := []byte("processed image") 198 processor := NewMockProcessor(processedData, nil) 199 fetcher := NewMockFetcher(rawImageData, nil) 200 config := DefaultConfig() 201 202 service := mustNewService(t, cache, processor, fetcher, config) 203 ctx := context.Background() 204 205 data, err := service.GetImage(ctx, "avatar", "did:plc:test123", "bafyreicid123", "https://pds.example.com") 206 if err != nil { 207 t.Fatalf("expected no error, got: %v", err) 208 } 209 if string(data) != string(processedData) { 210 t.Errorf("expected processed data %q, got %q", processedData, data) 211 } 212 213 // Verify fetcher was called 214 if fetcher.Calls() != 1 { 215 t.Errorf("expected fetcher to be called once, got %d calls", fetcher.Calls()) 216 } 217 218 // Verify processor was called 219 if processor.Calls() != 1 { 220 t.Errorf("expected processor to be called once, got %d calls", processor.Calls()) 221 } 222 223 // Wait a bit for async cache write 224 time.Sleep(50 * time.Millisecond) 225 226 // Verify cache was written 227 if cache.SetCalls() < 1 { 228 t.Errorf("expected cache to be written, got %d set calls", cache.SetCalls()) 229 } 230 231 // Verify the correct data was cached 232 setData, found := cache.GetSetData("avatar", "did:plc:test123", "bafyreicid123") 233 if !found { 234 t.Error("expected data to be set in cache") 235 } 236 if string(setData) != string(processedData) { 237 t.Errorf("expected cached data %q, got %q", processedData, setData) 238 } 239} 240 241func TestImageProxyService_GetImage_InvalidPreset(t *testing.T) { 242 cache := NewMockCache() 243 processor := NewMockProcessor(nil, nil) 244 fetcher := NewMockFetcher(nil, nil) 245 config := DefaultConfig() 246 247 service := mustNewService(t, cache, processor, fetcher, config) 248 ctx := context.Background() 249 250 _, err := service.GetImage(ctx, "invalid_preset", "did:plc:test123", "bafyreicid123", "https://pds.example.com") 251 if !errors.Is(err, ErrInvalidPreset) { 252 t.Errorf("expected ErrInvalidPreset, got: %v", err) 253 } 254} 255 256func TestImageProxyService_GetImage_PDSFetchError(t *testing.T) { 257 cache := NewMockCache() 258 processor := NewMockProcessor(nil, nil) 259 fetcher := NewMockFetcher(nil, ErrPDSNotFound) 260 config := DefaultConfig() 261 262 service := mustNewService(t, cache, processor, fetcher, config) 263 ctx := context.Background() 264 265 _, err := service.GetImage(ctx, "avatar", "did:plc:test123", "bafyreicid123", "https://pds.example.com") 266 if !errors.Is(err, ErrPDSNotFound) { 267 t.Errorf("expected ErrPDSNotFound, got: %v", err) 268 } 269} 270 271func TestImageProxyService_GetImage_ProcessingError(t *testing.T) { 272 cache := NewMockCache() 273 processor := NewMockProcessor(nil, ErrProcessingFailed) 274 fetcher := NewMockFetcher([]byte("raw data"), nil) 275 config := DefaultConfig() 276 277 service := mustNewService(t, cache, processor, fetcher, config) 278 ctx := context.Background() 279 280 _, err := service.GetImage(ctx, "avatar", "did:plc:test123", "bafyreicid123", "https://pds.example.com") 281 if !errors.Is(err, ErrProcessingFailed) { 282 t.Errorf("expected ErrProcessingFailed, got: %v", err) 283 } 284} 285 286func TestImageProxyService_GetImage_CacheWriteIsAsync(t *testing.T) { 287 cache := NewMockCache() 288 rawImageData := []byte("raw image from PDS") 289 processedData := []byte("processed image") 290 processor := NewMockProcessor(processedData, nil) 291 fetcher := NewMockFetcher(rawImageData, nil) 292 config := DefaultConfig() 293 294 service := mustNewService(t, cache, processor, fetcher, config) 295 ctx := context.Background() 296 297 // Call GetImage 298 startTime := time.Now() 299 data, err := service.GetImage(ctx, "avatar", "did:plc:test123", "bafyreicid123", "https://pds.example.com") 300 elapsed := time.Since(startTime) 301 302 if err != nil { 303 t.Fatalf("expected no error, got: %v", err) 304 } 305 if string(data) != string(processedData) { 306 t.Errorf("expected processed data %q, got %q", processedData, data) 307 } 308 309 // The response should come back quickly, not blocked by cache write 310 // (This is a soft assertion - just ensures we're not blocking) 311 if elapsed > 100*time.Millisecond { 312 t.Logf("warning: GetImage took %v, expected faster response", elapsed) 313 } 314 315 // Wait for async cache write to complete 316 time.Sleep(100 * time.Millisecond) 317 318 // Now verify cache was written 319 if cache.SetCalls() < 1 { 320 t.Errorf("expected cache to be written asynchronously, got %d set calls", cache.SetCalls()) 321 } 322} 323 324func TestImageProxyService_GetImage_EmptyPreset(t *testing.T) { 325 cache := NewMockCache() 326 processor := NewMockProcessor(nil, nil) 327 fetcher := NewMockFetcher(nil, nil) 328 config := DefaultConfig() 329 330 service := mustNewService(t, cache, processor, fetcher, config) 331 ctx := context.Background() 332 333 _, err := service.GetImage(ctx, "", "did:plc:test123", "bafyreicid123", "https://pds.example.com") 334 if !errors.Is(err, ErrInvalidPreset) { 335 t.Errorf("expected ErrInvalidPreset for empty preset, got: %v", err) 336 } 337} 338 339func TestImageProxyService_GetImage_AllPresets(t *testing.T) { 340 // Test that all predefined presets work 341 presets := []string{"avatar", "avatar_small", "banner", "content_preview", "content_full", "embed_thumbnail"} 342 343 for _, presetName := range presets { 344 t.Run(presetName, func(t *testing.T) { 345 cache := NewMockCache() 346 processedData := []byte("processed image") 347 processor := NewMockProcessor(processedData, nil) 348 fetcher := NewMockFetcher([]byte("raw data"), nil) 349 config := DefaultConfig() 350 351 service := mustNewService(t, cache, processor, fetcher, config) 352 ctx := context.Background() 353 354 data, err := service.GetImage(ctx, presetName, "did:plc:test123", "bafyreicid123", "https://pds.example.com") 355 if err != nil { 356 t.Errorf("expected no error for preset %s, got: %v", presetName, err) 357 } 358 if string(data) != string(processedData) { 359 t.Errorf("expected processed data for preset %s", presetName) 360 } 361 }) 362 } 363} 364 365func TestNewService_NilDependencies(t *testing.T) { 366 config := DefaultConfig() 367 cache := NewMockCache() 368 processor := NewMockProcessor(nil, nil) 369 fetcher := NewMockFetcher(nil, nil) 370 371 t.Run("nil cache", func(t *testing.T) { 372 _, err := NewService(nil, processor, fetcher, config) 373 if !errors.Is(err, ErrNilDependency) { 374 t.Errorf("expected ErrNilDependency, got: %v", err) 375 } 376 }) 377 378 t.Run("nil processor", func(t *testing.T) { 379 _, err := NewService(cache, nil, fetcher, config) 380 if !errors.Is(err, ErrNilDependency) { 381 t.Errorf("expected ErrNilDependency, got: %v", err) 382 } 383 }) 384 385 t.Run("nil fetcher", func(t *testing.T) { 386 _, err := NewService(cache, processor, nil, config) 387 if !errors.Is(err, ErrNilDependency) { 388 t.Errorf("expected ErrNilDependency, got: %v", err) 389 } 390 }) 391 392 t.Run("all valid", func(t *testing.T) { 393 service, err := NewService(cache, processor, fetcher, config) 394 if err != nil { 395 t.Errorf("expected no error with valid dependencies, got: %v", err) 396 } 397 if service == nil { 398 t.Error("expected non-nil service") 399 } 400 }) 401}