1package usecase
2
3import (
4 "context"
5 "errors"
6 "testing"
7 "time"
8
9 appport "github.com/bnema/dumber/internal/application/port"
10 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
11 "github.com/bnema/dumber/internal/domain/favicon"
12 "github.com/stretchr/testify/mock"
13)
14
15func TestFaviconResolveExactHit(t *testing.T) {
16 fx := newFaviconFixture(t)
17 fx.seed("docs.example.com", []byte("png32"), false)
18 got, err := fx.uc.Resolve(context.Background(), "https://docs.example.com/a", 32, ResolveOptions{Purpose: ResolvePurposeUI})
19 if err != nil {
20 t.Fatal(err)
21 }
22 if got.Key != "docs.example.com" || string(got.Bytes) != "png32" {
23 t.Fatalf("unexpected resolve: %#v %q", got, got.Bytes)
24 }
25}
26
27func TestFaviconResolveParentFallback(t *testing.T) {
28 fx := newFaviconFixture(t)
29 fx.seed("example.com", []byte("parent"), false)
30 got, err := fx.uc.Resolve(context.Background(), "https://docs.example.com/a", 32, ResolveOptions{Purpose: ResolvePurposeUI})
31 if err != nil {
32 t.Fatal(err)
33 }
34 if got.Key != "example.com" || string(got.Bytes) != "parent" {
35 t.Fatalf("unexpected fallback: %#v", got)
36 }
37}
38
39func TestFaviconResolvePrefersParentPathFallbackBeforeDomain(t *testing.T) {
40 fx := newFaviconFixture(t)
41 fx.seed("github.com", []byte("github-default"), false)
42 fx.seed("github.com/bnema/gordon", []byte("repo-default"), false)
43
44 got, err := fx.uc.Resolve(context.Background(), "https://github.com/bnema/gordon/issues/5", 32, ResolveOptions{Purpose: ResolvePurposeUI})
45 if err != nil {
46 t.Fatal(err)
47 }
48 if got.Key != "github.com/bnema/gordon" || string(got.Bytes) != "repo-default" {
49 t.Fatalf("expected repository fallback before domain: key=%q bytes=%q", got.Key, got.Bytes)
50 }
51}
52
53func TestFaviconResolvePathSpecificDoesNotLeakToParentOrSiblings(t *testing.T) {
54 fx := newFaviconFixture(t)
55 fx.seed("github.com", []byte("github-default"), false)
56
57 if _, err := fx.uc.Observe(
58 context.Background(),
59 "https://github.com/bnema/gordon/pull/123",
60 "https://github.com/favicon-status-success.png",
61 []byte("pr-success"),
62 favicon.SourceEngine,
63 "image/png",
64 ); err != nil {
65 t.Fatal(err)
66 }
67
68 pr, err := fx.uc.Resolve(context.Background(), "https://github.com/bnema/gordon/pull/123", 32, ResolveOptions{Purpose: ResolvePurposeUI})
69 if err != nil {
70 t.Fatal(err)
71 }
72 if pr.Key != "github.com/bnema/gordon/pull/123" || string(pr.Bytes) != "png32:pr-success" {
73 t.Fatalf("PR resolve used wrong icon: key=%q bytes=%q", pr.Key, pr.Bytes)
74 }
75
76 for _, rawURL := range []string{
77 "https://github.com/bnema/gordon",
78 "https://github.com/notifications",
79 "https://github.com/bnema?tab=repositories",
80 } {
81 got, err := fx.uc.Resolve(context.Background(), rawURL, 32, ResolveOptions{Purpose: ResolvePurposeUI})
82 if err != nil {
83 t.Fatalf("Resolve(%q): %v", rawURL, err)
84 }
85 if got.Key != "github.com" || string(got.Bytes) != "github-default" {
86 t.Fatalf("Resolve(%q) leaked path-specific favicon: key=%q bytes=%q", rawURL, got.Key, got.Bytes)
87 }
88 }
89
90 if _, err := fx.uc.Resolve(context.Background(), "https://gitlab.com/bnema/gordon", 32, ResolveOptions{Purpose: ResolvePurposeUI}); !errors.Is(err, ErrFaviconMiss) {
91 t.Fatalf("expected cross-domain miss, got %v", err)
92 }
93}
94
95func TestFaviconResolveRepairsOrphanCanonicalBlobMetadata(t *testing.T) {
96 fx := newFaviconFixture(t)
97 fx.blobs.png["example.com"] = []byte("png")
98 fx.blobs.sized["example.com"] = map[int][]byte{SystemviewIconSize: []byte("png32")}
99
100 got, err := fx.uc.ResolveSystemviewIcon(context.Background(), "example.com", SystemviewIconSize)
101 if err != nil {
102 t.Fatal(err)
103 }
104 if got.Key != "example.com" || string(got.Bytes) != "png32" {
105 t.Fatalf("unexpected resolve: %#v %q", got, got.Bytes)
106 }
107 meta := fx.repo.byKey["example.com"]
108 if meta == nil {
109 t.Fatal("expected metadata to be repaired")
110 }
111 if meta.Source != favicon.SourceImported || meta.ContentType != "image/png" || meta.ContentHash != favicon.Hash([]byte("png32")) {
112 t.Fatalf("unexpected repaired metadata: %#v", meta)
113 }
114}
115
116func TestFaviconResolveRepairOrphanWithoutConverterReturnsMiss(t *testing.T) {
117 fx := newFaviconFixture(t)
118 fx.uc.converter = nil
119 fx.blobs.original["example.com"] = []byte("original")
120 fx.blobs.contentTypes["example.com"] = "image/png"
121
122 _, err := fx.uc.ResolveSystemviewIcon(context.Background(), "example.com", SystemviewIconSize)
123 if !errors.Is(err, ErrFaviconMiss) {
124 t.Fatalf("expected favicon miss, got %v", err)
125 }
126}
127
128func TestFaviconResolveStaleSchedulesDedupedBackgroundRefresh(t *testing.T) {
129 fx := newFaviconFixture(t)
130 fx.seed("example.com", []byte("old"), true)
131 for i := range 2 {
132 got, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{ScheduleBackgroundRefresh: true})
133 if err != nil {
134 t.Fatal(err)
135 }
136 if !got.BackgroundRefreshScheduled && i == 0 {
137 t.Fatal("first stale resolve did not schedule refresh")
138 }
139 }
140 if fx.scheduler.schedules != 1 {
141 t.Fatalf("schedules=%d", fx.scheduler.schedules)
142 }
143 if fx.scheduler.usedCallerContext {
144 t.Fatal("scheduler used caller context")
145 }
146}
147
148func TestFaviconResolveStaleExactRefreshIgnoresFreshParent(t *testing.T) {
149 fx := newFaviconFixture(t)
150 fx.seed("docs.example.com", []byte("old-exact"), true)
151 fx.seed("example.com", []byte("fresh-parent"), false)
152 got, err := fx.uc.Resolve(
153 context.Background(),
154 "https://docs.example.com/page",
155 32,
156 ResolveOptions{ScheduleBackgroundRefresh: true},
157 )
158 if err != nil {
159 t.Fatal(err)
160 }
161 if got.Key != "docs.example.com" || !got.BackgroundRefreshScheduled {
162 t.Fatalf("unexpected resolve: %#v", got)
163 }
164 fx.scheduler.run("docs.example.com")
165 if string(fx.blobs.original["docs.example.com"]) != "fetched" {
166 t.Fatalf("exact key was not refreshed, originals=%v", fx.blobs.original)
167 }
168}
169
170func TestFaviconObserveSameHashUpdatesMetadataOnly(t *testing.T) {
171 fx := newFaviconFixture(t)
172 fx.seedOriginal([]byte("same"))
173 if _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://cdn.test/icon.png", []byte("same"), favicon.SourceEngine, "image/png"); err != nil {
174 t.Fatal(err)
175 }
176 if fx.blobs.writeOriginals != 0 || fx.converter.calls != 0 || fx.invalidators.calls != 0 {
177 t.Fatalf("rewrote/converted/invalidated same hash")
178 }
179 if fx.repo.byKey["example.com"].LastCheckedAt != fx.now {
180 t.Fatal("last checked not updated")
181 }
182}
183
184func TestFaviconObserveChangedHashRegeneratesAndInvalidates(t *testing.T) {
185 fx := newFaviconFixture(t)
186 fx.seedOriginal([]byte("old"))
187 if _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://cdn.test/icon.png", []byte("new"), favicon.SourceEngine, "image/png"); err != nil {
188 t.Fatal(err)
189 }
190 if fx.blobs.writeOriginals != 1 || fx.converter.calls != 1 || fx.blobs.removeDerived != 1 || fx.invalidators.calls != 1 {
191 t.Fatalf("writes=%d converts=%d remove=%d invalidates=%d", fx.blobs.writeOriginals, fx.converter.calls, fx.blobs.removeDerived, fx.invalidators.calls)
192 }
193 if string(fx.blobs.png["example.com"]) != "png:new" || string(fx.blobs.sized["example.com"][32]) != "png32:new" {
194 t.Fatal("derived pngs not regenerated")
195 }
196 if fx.repo.byKey["example.com"].SourceURL != "https://cdn.test/icon.png" {
197 t.Fatal("iconURL not stored as metadata")
198 }
199}
200
201func TestFaviconObserveWithoutConverterStoresOriginalOnly(t *testing.T) {
202 fx := newFaviconFixture(t)
203 fx.uc.converter = nil
204 if _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://example.com/icon.png", []byte("raw"), favicon.SourceEngine, "image/png"); err != nil {
205 t.Fatal(err)
206 }
207 if string(fx.blobs.original["example.com"]) != "raw" {
208 t.Fatalf("original not stored: %v", fx.blobs.original)
209 }
210 if len(fx.blobs.png) != 0 || len(fx.blobs.sized) != 0 {
211 t.Fatalf("unexpected derivatives: png=%v sized=%v", fx.blobs.png, fx.blobs.sized)
212 }
213}
214
215func TestFaviconResolveMissingFetchesOnlyWhenBlockingAllowed(t *testing.T) {
216 fx := newFaviconFixture(t)
217 if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{}); !errors.Is(err, ErrFaviconMiss) {
218 t.Fatalf("default resolve err=%v", err)
219 }
220 if fx.fetcher.calls != 0 {
221 t.Fatal("default resolve blocked on fetch")
222 }
223 got, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{AllowBlockingRefresh: true})
224 if err != nil {
225 t.Fatal(err)
226 }
227 if got.Key != "example.com" || fx.fetcher.calls != 1 {
228 t.Fatalf("blocking resolve failed: %#v calls=%d", got, fx.fetcher.calls)
229 }
230}
231
232func TestFaviconRefreshIfStaleUsesParentFallbackBeforeFetching(t *testing.T) {
233 fx := newFaviconFixture(t)
234 fx.seed("example.com", []byte("parent"), false)
235 if err := fx.uc.RefreshIfStale(context.Background(), "https://docs.example.com/page"); err != nil {
236 t.Fatal(err)
237 }
238 if fx.fetcher.calls != 0 {
239 t.Fatalf("fresh parent fallback should not fetch, calls=%d", fx.fetcher.calls)
240 }
241}
242
243func TestFaviconUseCasePropagatesRepositoryErrors(t *testing.T) {
244 fx := newFaviconFixture(t)
245 fx.repo.err = errors.New("database unavailable")
246 if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{}); !errors.Is(err, fx.repo.err) {
247 t.Fatalf("Resolve err=%v, want %v", err, fx.repo.err)
248 }
249 if _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://cdn.example/icon.png", []byte("new"), favicon.SourceEngine, "image/png"); !errors.Is(err, fx.repo.err) {
250 t.Fatalf("Observe err=%v, want %v", err, fx.repo.err)
251 }
252 if err := fx.uc.RefreshIfStale(context.Background(), "https://example.com"); !errors.Is(err, fx.repo.err) {
253 t.Fatalf("RefreshIfStale err=%v, want %v", err, fx.repo.err)
254 }
255}
256
257func TestFaviconResolvePropagatesBlobErrors(t *testing.T) {
258 fx := newFaviconFixture(t)
259 fx.seed("example.com", []byte("cached"), false)
260 fx.blobs.err = errors.New("disk unavailable")
261 if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{}); !errors.Is(err, fx.blobs.err) {
262 t.Fatalf("Resolve err=%v, want %v", err, fx.blobs.err)
263 }
264}
265
266func TestFaviconResolveBlockingRefreshesWhenMetadataFreshButBlobMissing(t *testing.T) {
267 fx := newFaviconFixture(t)
268 fx.repo.byKey["example.com"] = &favicon.Metadata{Key: "example.com", PageURL: "https://example.com", ContentHash: favicon.Hash([]byte("old")), LastCheckedAt: fx.now}
269 got, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{AllowBlockingRefresh: true})
270 if err != nil {
271 t.Fatal(err)
272 }
273 if got.Key != "example.com" || fx.fetcher.calls != 1 || string(got.Bytes) != "png32:fetched" {
274 t.Fatalf("unexpected resolve after forced refresh: got=%#v calls=%d bytes=%q", got, fx.fetcher.calls, got.Bytes)
275 }
276}
277
278func TestFaviconResolveBlockingPropagatesFetchErrors(t *testing.T) {
279 fx := newFaviconFixture(t)
280 fx.fetcher.err = errors.New("network unavailable")
281 if _, err := fx.uc.Resolve(context.Background(), "https://example.com", 32, ResolveOptions{AllowBlockingRefresh: true}); !errors.Is(err, fx.fetcher.err) {
282 t.Fatalf("Resolve err=%v, want %v", err, fx.fetcher.err)
283 }
284}
285
286func TestFaviconRefreshFromIconURLsUsesPageURLKey(t *testing.T) {
287 fx := newFaviconFixture(t)
288 if err := fx.uc.RefreshFromIconURLs(context.Background(), "https://docs.example.com/page", []string{"https://cdn.example.net/icon.png"}); err != nil {
289 t.Fatal(err)
290 }
291 if _, ok := fx.repo.byKey["docs.example.com/page"]; !ok {
292 t.Fatalf("page URL key was not stored; keys=%v", fx.repo.byKey)
293 }
294 if _, ok := fx.repo.byKey["cdn.example.net/icon.png"]; ok {
295 t.Fatal("icon URL should not be used as cache key")
296 }
297}
298
299func TestFaviconRefreshStoresDuckDuckGoFallbackUnderHostKey(t *testing.T) {
300 fx := newFaviconFixture(t)
301 fx.fetcher.responses = map[string]faviconFetchResponse{
302 "https://example.com/favicon.ico": {
303 bytes: []byte("host-fallback"),
304 contentType: "image/png",
305 resolvedKey: "github.com",
306 },
307 }
308
309 if err := fx.uc.RefreshIfStale(context.Background(), "https://github.com/bnema/gordon/pull/123"); err != nil {
310 t.Fatal(err)
311 }
312 if _, ok := fx.repo.byKey["github.com"]; !ok {
313 t.Fatalf("host fallback key was not stored; keys=%v", fx.repo.byKey)
314 }
315 if _, ok := fx.repo.byKey["github.com/bnema/gordon/pull/123"]; ok {
316 t.Fatal("duckduckgo host fallback should not be stored under the exact path key")
317 }
318}
319
320func TestFaviconRefreshRejectsResolvedKeyOutsideCandidateHierarchy(t *testing.T) {
321 fx := newFaviconFixture(t)
322 fx.fetcher.responses = map[string]faviconFetchResponse{
323 "https://example.com/favicon.ico": {
324 bytes: []byte("bad-fallback"),
325 contentType: "image/png",
326 resolvedKey: "evil.example.com",
327 },
328 }
329
330 err := fx.uc.RefreshIfStale(context.Background(), "https://github.com/bnema/gordon/pull/123")
331 if !errors.Is(err, ErrFaviconMiss) {
332 t.Fatalf("RefreshIfStale err=%v, want favicon miss", err)
333 }
334 if len(fx.repo.byKey) != 0 {
335 t.Fatalf("invalid resolved key should not persist metadata: %v", fx.repo.byKey)
336 }
337}
338
339func TestFaviconRefreshRejectsFetcherPageURLOutsideRequestedHierarchy(t *testing.T) {
340 fx := newFaviconFixture(t)
341 fx.fetcher.responses = map[string]faviconFetchResponse{
342 "https://example.com/favicon.ico": {
343 bytes: []byte("bad-page-url"),
344 contentType: "image/png",
345 pageURL: "https://evil.example.com/pwned",
346 resolvedKey: "evil.example.com/pwned",
347 },
348 }
349
350 err := fx.uc.RefreshIfStale(context.Background(), "https://github.com/bnema/gordon/pull/123")
351 if !errors.Is(err, ErrFaviconMiss) {
352 t.Fatalf("RefreshIfStale err=%v, want favicon miss", err)
353 }
354 if len(fx.repo.byKey) != 0 {
355 t.Fatalf("fetcher-controlled pageURL should not expand storage scope: %v", fx.repo.byKey)
356 }
357}
358
359func TestFaviconRefreshFromIconURLsSkipsUnsupportedCandidates(t *testing.T) {
360 fx := newFaviconFixture(t)
361 fx.fetcher.responses = map[string]faviconFetchResponse{
362 "https://cdn.example.net/icon.svg": {bytes: []byte("svg"), contentType: "image/svg+xml"},
363 "https://cdn.example.net/icon.png": {bytes: []byte("png"), contentType: "image/png"},
364 }
365 fx.converter.errByContentType = map[string]error{"image/svg+xml": ErrFaviconMiss}
366
367 err := fx.uc.RefreshFromIconURLs(context.Background(), "https://docs.example.com/page", []string{
368 "https://cdn.example.net/icon.svg",
369 "https://cdn.example.net/icon.png",
370 })
371 if err != nil {
372 t.Fatal(err)
373 }
374 if fx.fetcher.calls != 2 {
375 t.Fatalf("fetcher calls=%d, want 2", fx.fetcher.calls)
376 }
377 meta := fx.repo.byKey["docs.example.com/page"]
378 if meta == nil {
379 t.Fatalf("expected page URL key to be stored; keys=%v", fx.repo.byKey)
380 }
381 if meta.SourceURL != "https://cdn.example.net/icon.png" || meta.ContentType != "image/png" {
382 t.Fatalf("stored meta = %+v, want png candidate", meta)
383 }
384 if string(fx.blobs.original["docs.example.com/page"]) != "png" {
385 t.Fatalf("stored original = %q, want png", fx.blobs.original["docs.example.com/page"])
386 }
387}
388
389func TestFaviconObserveConversionErrorDoesNotMutateExistingAssets(t *testing.T) {
390 fx := newFaviconFixture(t)
391 fx.seedOriginal([]byte("old"))
392 fx.blobs.png["example.com"] = []byte("old-png")
393 fx.blobs.sized["example.com"] = map[int][]byte{32: []byte("old-32")}
394 fx.converter.err = errors.New("unsupported image")
395
396 if _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://example.com/favicon.ico", []byte("bad"), favicon.SourceEngine, "image/svg+xml"); !errors.Is(err, fx.converter.err) {
397 t.Fatalf("Observe err=%v, want %v", err, fx.converter.err)
398 }
399 if string(fx.blobs.original["example.com"]) != "old" || string(fx.blobs.png["example.com"]) != "old-png" || string(fx.blobs.sized["example.com"][32]) != "old-32" {
400 t.Fatalf("existing assets mutated on conversion error")
401 }
402 if fx.blobs.writeOriginals != 0 || fx.blobs.removeDerived != 0 || fx.invalidators.calls != 0 {
403 t.Fatalf("unexpected mutation counters writes=%d remove=%d invalidates=%d", fx.blobs.writeOriginals, fx.blobs.removeDerived, fx.invalidators.calls)
404 }
405}
406
407func TestFaviconObserveUnavailableWebPPreservesCachedAssets(t *testing.T) {
408 fx := newFaviconFixture(t)
409 fx.seedOriginal([]byte("old"))
410 fx.blobs.png["example.com"] = []byte("old-png")
411 fx.blobs.sized["example.com"] = map[int][]byte{32: []byte("old-32")}
412 fx.converter.errByContentType = map[string]error{"image/webp": ErrFaviconMiss}
413
414 _, err := fx.uc.Observe(context.Background(), "https://example.com", "https://example.com/favicon.webp", []byte("webp"), favicon.SourceEngine, "image/webp")
415 if !errors.Is(err, ErrFaviconMiss) {
416 t.Fatalf("Observe err=%v, want favicon miss", err)
417 }
418 if string(fx.blobs.original["example.com"]) != "old" || string(fx.blobs.png["example.com"]) != "old-png" || string(fx.blobs.sized["example.com"][32]) != "old-32" {
419 t.Fatalf("cached assets changed after unavailable WebP decoder")
420 }
421 if fx.blobs.writeOriginals != 0 || fx.blobs.removeDerived != 0 || fx.invalidators.calls != 0 {
422 t.Fatalf("unavailable decoder mutated cache: writes=%d removes=%d invalidations=%d", fx.blobs.writeOriginals, fx.blobs.removeDerived, fx.invalidators.calls)
423 }
424}
425
426func TestFaviconEnsureSizedPropagatesReadSizedErrors(t *testing.T) {
427 fx := newFaviconFixture(t)
428 fx.seedOriginal([]byte("old"))
429 fx.blobs.readSizedErr = errors.New("sized store unavailable")
430 if err := fx.uc.EnsureSized(context.Background(), "example.com", 32); !errors.Is(err, fx.blobs.readSizedErr) {
431 t.Fatalf("EnsureSized err=%v, want %v", err, fx.blobs.readSizedErr)
432 }
433 if fx.converter.calls != 0 {
434 t.Fatal("converter called after non-miss ReadSizedPNG error")
435 }
436}
437
438type faviconFixture struct {
439 repo *faviconRepoState
440 blobs *faviconBlobStoreState
441 converter *faviconConverterState
442 scheduler *faviconSchedulerState
443 invalidators *faviconInvalidatorsState
444 fetcher *faviconFetcherState
445 uc *FaviconUseCase
446 now time.Time
447}
448
449type faviconTestContextKey string
450
451const faviconFixtureBackgroundKey faviconTestContextKey = "bg"
452
453func newFaviconFixture(t *testing.T) *faviconFixture {
454 t.Helper()
455
456 now := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
457 fx := &faviconFixture{
458 repo: newFaviconRepoState(),
459 blobs: newFaviconBlobStoreState(),
460 converter: &faviconConverterState{},
461 scheduler: &faviconSchedulerState{seen: map[favicon.Key]bool{}},
462 invalidators: &faviconInvalidatorsState{},
463 fetcher: &faviconFetcherState{},
464 now: now,
465 }
466
467 repo := mockFaviconRepository(t, fx.repo)
468 blobs := mockFaviconBlobStore(t, fx.blobs)
469 converter := mockFaviconConverter(t, fx.converter)
470 scheduler := mockFaviconScheduler(t, fx.scheduler)
471 invalidators := mockFaviconInvalidators(t, fx.invalidators)
472 fetcher := mockFaviconFetcher(t, fx.fetcher)
473
474 fx.uc = NewFaviconUseCase(FaviconDeps{
475 Repository: repo,
476 BlobStore: blobs,
477 Converter: converter,
478 Scheduler: scheduler,
479 Invalidators: invalidators,
480 Fetcher: fetcher,
481 Now: func() time.Time { return fx.now },
482 Background: context.WithValue(context.Background(), faviconFixtureBackgroundKey, true),
483 })
484 return fx
485}
486
487func mockFaviconRepository(t *testing.T, state *faviconRepoState) *portmocks.MockFaviconRepository {
488 t.Helper()
489
490 repo := portmocks.NewMockFaviconRepository(t)
491 repo.EXPECT().Get(mock.Anything, mock.Anything).RunAndReturn(state.get).Maybe()
492 repo.EXPECT().FindFirst(mock.Anything, mock.Anything).RunAndReturn(state.findFirst).Maybe()
493 repo.EXPECT().Upsert(mock.Anything, mock.Anything).RunAndReturn(state.upsert).Maybe()
494 repo.EXPECT().UpdateLastChecked(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(state.updateLastChecked).Maybe()
495 repo.EXPECT().Delete(mock.Anything, mock.Anything).RunAndReturn(state.delete).Maybe()
496 return repo
497}
498
499func mockFaviconBlobStore(t *testing.T, state *faviconBlobStoreState) *portmocks.MockFaviconBlobStore {
500 t.Helper()
501
502 blobs := portmocks.NewMockFaviconBlobStore(t)
503 blobs.EXPECT().ReadOriginal(mock.Anything, mock.Anything).RunAndReturn(state.readOriginal).Maybe()
504 blobs.EXPECT().WriteOriginal(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(state.writeOriginal).Maybe()
505 blobs.EXPECT().ReadPNG(mock.Anything, mock.Anything).RunAndReturn(state.readPNG).Maybe()
506 blobs.EXPECT().WritePNG(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(state.writePNG).Maybe()
507 blobs.EXPECT().ReadSizedPNG(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(state.readSizedPNG).Maybe()
508 blobs.EXPECT().WriteSizedPNG(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(state.writeSizedPNG).Maybe()
509 blobs.EXPECT().RemoveDerived(mock.Anything, mock.Anything).RunAndReturn(state.removeDerivedForKey).Maybe()
510 return blobs
511}
512
513func mockFaviconConverter(t *testing.T, state *faviconConverterState) *portmocks.MockFaviconImageConverter {
514 t.Helper()
515
516 converter := portmocks.NewMockFaviconImageConverter(t)
517 converter.EXPECT().Convert(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(state.convert).Maybe()
518 return converter
519}
520
521func mockFaviconScheduler(t *testing.T, state *faviconSchedulerState) *portmocks.MockFaviconRefreshScheduler {
522 t.Helper()
523
524 scheduler := portmocks.NewMockFaviconRefreshScheduler(t)
525 scheduler.EXPECT().Schedule(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(state.schedule).Maybe()
526 return scheduler
527}
528
529func mockFaviconInvalidators(t *testing.T, state *faviconInvalidatorsState) *portmocks.MockFaviconInvalidators {
530 t.Helper()
531
532 invalidators := portmocks.NewMockFaviconInvalidators(t)
533 invalidators.EXPECT().InvalidateAll(mock.Anything, mock.Anything).RunAndReturn(state.invalidateAll).Maybe()
534 return invalidators
535}
536
537func mockFaviconFetcher(t *testing.T, state *faviconFetcherState) *portmocks.MockFaviconFetcher {
538 t.Helper()
539
540 fetcher := portmocks.NewMockFaviconFetcher(t)
541 fetcher.EXPECT().Fetch(mock.Anything, mock.Anything).RunAndReturn(state.fetch).Maybe()
542 return fetcher
543}
544
545func (f *faviconFixture) seed(key string, sized []byte, stale bool) {
546 checked := f.now
547 if stale {
548 checked = f.now.Add(-favicon.DefaultTTL - time.Hour)
549 }
550 f.repo.byKey[favicon.Key(key)] = &favicon.Metadata{Key: favicon.Key(key), PageURL: "https://" + key, ContentHash: favicon.Hash([]byte("orig")), LastCheckedAt: checked}
551 f.blobs.sized[favicon.Key(key)] = map[int][]byte{32: sized}
552}
553
554func (f *faviconFixture) seedOriginal(b []byte) {
555 const contentType = "image/png"
556 key := favicon.Key("example.com")
557 f.repo.byKey[key] = &favicon.Metadata{
558 Key: key,
559 PageURL: "https://example.com",
560 ContentHash: favicon.Hash(b),
561 ContentType: contentType,
562 LastCheckedAt: f.now.Add(-time.Hour),
563 }
564 f.blobs.original[key] = b
565 f.blobs.contentTypes[key] = contentType
566}
567
568type faviconRepoState struct {
569 byKey map[favicon.Key]*favicon.Metadata
570 err error
571}
572
573func newFaviconRepoState() *faviconRepoState {
574 return &faviconRepoState{byKey: map[favicon.Key]*favicon.Metadata{}}
575}
576
577func (r *faviconRepoState) get(_ context.Context, key favicon.Key) (*favicon.Metadata, error) {
578 if r.err != nil {
579 return nil, r.err
580 }
581 if m := r.byKey[key]; m != nil {
582 cp := *m
583 return &cp, nil
584 }
585 return nil, nil
586}
587
588func (r *faviconRepoState) findFirst(ctx context.Context, keys []favicon.Key) (*favicon.Metadata, error) {
589 if r.err != nil {
590 return nil, r.err
591 }
592 for _, k := range keys {
593 if m, _ := r.get(ctx, k); m != nil {
594 return m, nil
595 }
596 }
597 return nil, nil
598}
599
600func (r *faviconRepoState) upsert(_ context.Context, meta favicon.Metadata) error {
601 if r.err != nil {
602 return r.err
603 }
604 cp := meta
605 r.byKey[meta.Key] = &cp
606 return nil
607}
608
609func (r *faviconRepoState) updateLastChecked(_ context.Context, key favicon.Key, hash string, checkedAt time.Time) error {
610 if r.err != nil {
611 return r.err
612 }
613 r.byKey[key].ContentHash = hash
614 r.byKey[key].LastCheckedAt = checkedAt
615 return nil
616}
617
618func (r *faviconRepoState) delete(_ context.Context, key favicon.Key) error {
619 if r.err != nil {
620 return r.err
621 }
622 delete(r.byKey, key)
623 return nil
624}
625
626type faviconBlobStoreState struct {
627 original, png map[favicon.Key][]byte
628 sized map[favicon.Key]map[int][]byte
629 contentTypes map[favicon.Key]string
630 writeOriginals, removeDerived int
631 err error
632 readSizedErr error
633}
634
635func newFaviconBlobStoreState() *faviconBlobStoreState {
636 return &faviconBlobStoreState{
637 original: map[favicon.Key][]byte{},
638 png: map[favicon.Key][]byte{},
639 sized: map[favicon.Key]map[int][]byte{},
640 contentTypes: map[favicon.Key]string{},
641 }
642}
643
644func (b *faviconBlobStoreState) readOriginal(_ context.Context, key favicon.Key) ([]byte, string, error) {
645 if b.err != nil {
646 return nil, "", b.err
647 }
648 v := b.original[key]
649 if v == nil {
650 return nil, "", ErrFaviconMiss
651 }
652 return v, b.contentTypes[key], nil
653}
654
655func (b *faviconBlobStoreState) writeOriginal(_ context.Context, key favicon.Key, data []byte, ct string) error {
656 if b.err != nil {
657 return b.err
658 }
659 b.writeOriginals++
660 b.original[key] = data
661 b.contentTypes[key] = ct
662 return nil
663}
664
665func (b *faviconBlobStoreState) readPNG(_ context.Context, key favicon.Key) ([]byte, string, error) {
666 if b.err != nil {
667 return nil, "", b.err
668 }
669 v := b.png[key]
670 if v == nil {
671 return nil, "", ErrFaviconMiss
672 }
673 return v, "image/png", nil
674}
675
676func (b *faviconBlobStoreState) writePNG(_ context.Context, key favicon.Key, data []byte) error {
677 if b.err != nil {
678 return b.err
679 }
680 b.png[key] = data
681 return nil
682}
683
684func (b *faviconBlobStoreState) readSizedPNG(_ context.Context, key favicon.Key, size int) ([]byte, string, error) {
685 if b.err != nil {
686 return nil, "", b.err
687 }
688 if b.readSizedErr != nil {
689 return nil, "", b.readSizedErr
690 }
691 if b.sized[key] == nil || b.sized[key][size] == nil {
692 return nil, "", ErrFaviconMiss
693 }
694 return b.sized[key][size], "image/png", nil
695}
696
697func (b *faviconBlobStoreState) writeSizedPNG(_ context.Context, key favicon.Key, size int, data []byte) error {
698 if b.err != nil {
699 return b.err
700 }
701 if b.sized[key] == nil {
702 b.sized[key] = map[int][]byte{}
703 }
704 b.sized[key][size] = data
705 return nil
706}
707
708func (b *faviconBlobStoreState) removeDerivedForKey(_ context.Context, key favicon.Key) error {
709 if b.err != nil {
710 return b.err
711 }
712 b.removeDerived++
713 delete(b.png, key)
714 delete(b.sized, key)
715 return nil
716}
717
718type faviconConverterState struct {
719 calls int
720 err error
721 errByContentType map[string]error
722}
723
724func (c *faviconConverterState) convert(_ context.Context, original []byte, contentType string, sizes []int) (*appport.ConvertedFavicon, error) {
725 c.calls++
726 if c.err != nil {
727 return nil, c.err
728 }
729 if err := c.errByContentType[contentType]; err != nil {
730 return nil, err
731 }
732 out := &appport.ConvertedFavicon{PNG: append([]byte("png:"), original...), SizedPNG: map[int][]byte{}}
733 for _, s := range sizes {
734 out.SizedPNG[s] = append([]byte("png32:"), original...)
735 }
736 return out, nil
737}
738
739type faviconSchedulerState struct {
740 seen map[favicon.Key]bool
741 works map[favicon.Key]func(context.Context)
742 schedules int
743 usedCallerContext bool
744}
745
746func (s *faviconSchedulerState) schedule(ctx context.Context, key favicon.Key, work func(context.Context)) bool {
747 if ctx.Value(faviconFixtureBackgroundKey) != true {
748 s.usedCallerContext = true
749 }
750 if s.seen[key] {
751 return false
752 }
753 if s.works == nil {
754 s.works = map[favicon.Key]func(context.Context){}
755 }
756 s.seen[key] = true
757 s.works[key] = work
758 s.schedules++
759 return true
760}
761
762func (s *faviconSchedulerState) run(key favicon.Key) {
763 if work := s.works[key]; work != nil {
764 work(context.Background())
765 }
766}
767
768type faviconInvalidatorsState struct{ calls int }
769
770func (i *faviconInvalidatorsState) invalidateAll(_ context.Context, _ favicon.Key) error {
771 i.calls++
772 return nil
773}
774
775type faviconFetchResponse struct {
776 bytes []byte
777 contentType string
778 pageURL string
779 resolvedKey favicon.Key
780}
781
782type faviconFetcherState struct {
783 calls int
784 err error
785 responses map[string]faviconFetchResponse
786}
787
788func (f *faviconFetcherState) fetch(_ context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
789 f.calls++
790 if f.err != nil {
791 return nil, f.err
792 }
793 iconURL := req.IconURL
794 if iconURL == "" {
795 iconURL = "https://example.com/favicon.ico"
796 }
797 response := f.responses[iconURL]
798 if response.bytes == nil {
799 response = faviconFetchResponse{bytes: []byte("fetched"), contentType: "image/png"}
800 }
801 pageURL := req.PageURL
802 if response.pageURL != "" {
803 pageURL = response.pageURL
804 }
805 return &appport.FaviconFetchedIcon{
806 PageURL: pageURL,
807 IconURL: iconURL,
808 ResolvedKey: response.resolvedKey,
809 Bytes: response.bytes,
810 Source: favicon.SourceDuckDuckGo,
811 ContentType: response.contentType,
812 }, nil
813}