A community based topic aggregation platform built on atproto
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}