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 := 0; i < 2; i++ {
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 TestFaviconEnsureSizedPropagatesReadSizedErrors(t *testing.T) {
408 fx := newFaviconFixture(t)
409 fx.seedOriginal([]byte("old"))
410 fx.blobs.readSizedErr = errors.New("sized store unavailable")
411 if err := fx.uc.EnsureSized(context.Background(), "example.com", 32); !errors.Is(err, fx.blobs.readSizedErr) {
412 t.Fatalf("EnsureSized err=%v, want %v", err, fx.blobs.readSizedErr)
413 }
414 if fx.converter.calls != 0 {
415 t.Fatal("converter called after non-miss ReadSizedPNG error")
416 }
417}
418
419type faviconFixture struct {
420 repo *faviconRepoState
421 blobs *faviconBlobStoreState
422 converter *faviconConverterState
423 scheduler *faviconSchedulerState
424 invalidators *faviconInvalidatorsState
425 fetcher *faviconFetcherState
426 uc *FaviconUseCase
427 now time.Time
428}
429
430type faviconTestContextKey string
431
432const faviconFixtureBackgroundKey faviconTestContextKey = "bg"
433
434func newFaviconFixture(t *testing.T) *faviconFixture {
435 t.Helper()
436
437 now := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
438 fx := &faviconFixture{
439 repo: newFaviconRepoState(),
440 blobs: newFaviconBlobStoreState(),
441 converter: &faviconConverterState{},
442 scheduler: &faviconSchedulerState{seen: map[favicon.Key]bool{}},
443 invalidators: &faviconInvalidatorsState{},
444 fetcher: &faviconFetcherState{},
445 now: now,
446 }
447
448 repo := mockFaviconRepository(t, fx.repo)
449 blobs := mockFaviconBlobStore(t, fx.blobs)
450 converter := mockFaviconConverter(t, fx.converter)
451 scheduler := mockFaviconScheduler(t, fx.scheduler)
452 invalidators := mockFaviconInvalidators(t, fx.invalidators)
453 fetcher := mockFaviconFetcher(t, fx.fetcher)
454
455 fx.uc = NewFaviconUseCase(FaviconDeps{
456 Repository: repo,
457 BlobStore: blobs,
458 Converter: converter,
459 Scheduler: scheduler,
460 Invalidators: invalidators,
461 Fetcher: fetcher,
462 Now: func() time.Time { return fx.now },
463 Background: context.WithValue(context.Background(), faviconFixtureBackgroundKey, true),
464 })
465 return fx
466}
467
468func mockFaviconRepository(t *testing.T, state *faviconRepoState) *portmocks.MockFaviconRepository {
469 t.Helper()
470
471 repo := portmocks.NewMockFaviconRepository(t)
472 repo.EXPECT().Get(mock.Anything, mock.Anything).RunAndReturn(state.get).Maybe()
473 repo.EXPECT().FindFirst(mock.Anything, mock.Anything).RunAndReturn(state.findFirst).Maybe()
474 repo.EXPECT().Upsert(mock.Anything, mock.Anything).RunAndReturn(state.upsert).Maybe()
475 repo.EXPECT().UpdateLastChecked(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(state.updateLastChecked).Maybe()
476 repo.EXPECT().Delete(mock.Anything, mock.Anything).RunAndReturn(state.delete).Maybe()
477 return repo
478}
479
480func mockFaviconBlobStore(t *testing.T, state *faviconBlobStoreState) *portmocks.MockFaviconBlobStore {
481 t.Helper()
482
483 blobs := portmocks.NewMockFaviconBlobStore(t)
484 blobs.EXPECT().ReadOriginal(mock.Anything, mock.Anything).RunAndReturn(state.readOriginal).Maybe()
485 blobs.EXPECT().WriteOriginal(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(state.writeOriginal).Maybe()
486 blobs.EXPECT().ReadPNG(mock.Anything, mock.Anything).RunAndReturn(state.readPNG).Maybe()
487 blobs.EXPECT().WritePNG(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(state.writePNG).Maybe()
488 blobs.EXPECT().ReadSizedPNG(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(state.readSizedPNG).Maybe()
489 blobs.EXPECT().WriteSizedPNG(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(state.writeSizedPNG).Maybe()
490 blobs.EXPECT().RemoveDerived(mock.Anything, mock.Anything).RunAndReturn(state.removeDerivedForKey).Maybe()
491 return blobs
492}
493
494func mockFaviconConverter(t *testing.T, state *faviconConverterState) *portmocks.MockFaviconImageConverter {
495 t.Helper()
496
497 converter := portmocks.NewMockFaviconImageConverter(t)
498 converter.EXPECT().Convert(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(state.convert).Maybe()
499 return converter
500}
501
502func mockFaviconScheduler(t *testing.T, state *faviconSchedulerState) *portmocks.MockFaviconRefreshScheduler {
503 t.Helper()
504
505 scheduler := portmocks.NewMockFaviconRefreshScheduler(t)
506 scheduler.EXPECT().Schedule(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(state.schedule).Maybe()
507 return scheduler
508}
509
510func mockFaviconInvalidators(t *testing.T, state *faviconInvalidatorsState) *portmocks.MockFaviconInvalidators {
511 t.Helper()
512
513 invalidators := portmocks.NewMockFaviconInvalidators(t)
514 invalidators.EXPECT().InvalidateAll(mock.Anything, mock.Anything).RunAndReturn(state.invalidateAll).Maybe()
515 return invalidators
516}
517
518func mockFaviconFetcher(t *testing.T, state *faviconFetcherState) *portmocks.MockFaviconFetcher {
519 t.Helper()
520
521 fetcher := portmocks.NewMockFaviconFetcher(t)
522 fetcher.EXPECT().Fetch(mock.Anything, mock.Anything).RunAndReturn(state.fetch).Maybe()
523 return fetcher
524}
525
526func (f *faviconFixture) seed(key string, sized []byte, stale bool) {
527 checked := f.now
528 if stale {
529 checked = f.now.Add(-favicon.DefaultTTL - time.Hour)
530 }
531 f.repo.byKey[favicon.Key(key)] = &favicon.Metadata{Key: favicon.Key(key), PageURL: "https://" + key, ContentHash: favicon.Hash([]byte("orig")), LastCheckedAt: checked}
532 f.blobs.sized[favicon.Key(key)] = map[int][]byte{32: sized}
533}
534
535func (f *faviconFixture) seedOriginal(b []byte) {
536 const contentType = "image/png"
537 key := favicon.Key("example.com")
538 f.repo.byKey[key] = &favicon.Metadata{
539 Key: key,
540 PageURL: "https://example.com",
541 ContentHash: favicon.Hash(b),
542 ContentType: contentType,
543 LastCheckedAt: f.now.Add(-time.Hour),
544 }
545 f.blobs.original[key] = b
546 f.blobs.contentTypes[key] = contentType
547}
548
549type faviconRepoState struct {
550 byKey map[favicon.Key]*favicon.Metadata
551 err error
552}
553
554func newFaviconRepoState() *faviconRepoState {
555 return &faviconRepoState{byKey: map[favicon.Key]*favicon.Metadata{}}
556}
557
558func (r *faviconRepoState) get(_ context.Context, key favicon.Key) (*favicon.Metadata, error) {
559 if r.err != nil {
560 return nil, r.err
561 }
562 if m := r.byKey[key]; m != nil {
563 cp := *m
564 return &cp, nil
565 }
566 return nil, nil
567}
568
569func (r *faviconRepoState) findFirst(ctx context.Context, keys []favicon.Key) (*favicon.Metadata, error) {
570 if r.err != nil {
571 return nil, r.err
572 }
573 for _, k := range keys {
574 if m, _ := r.get(ctx, k); m != nil {
575 return m, nil
576 }
577 }
578 return nil, nil
579}
580
581func (r *faviconRepoState) upsert(_ context.Context, meta favicon.Metadata) error {
582 if r.err != nil {
583 return r.err
584 }
585 cp := meta
586 r.byKey[meta.Key] = &cp
587 return nil
588}
589
590func (r *faviconRepoState) updateLastChecked(_ context.Context, key favicon.Key, hash string, checkedAt time.Time) error {
591 if r.err != nil {
592 return r.err
593 }
594 r.byKey[key].ContentHash = hash
595 r.byKey[key].LastCheckedAt = checkedAt
596 return nil
597}
598
599func (r *faviconRepoState) delete(_ context.Context, key favicon.Key) error {
600 if r.err != nil {
601 return r.err
602 }
603 delete(r.byKey, key)
604 return nil
605}
606
607type faviconBlobStoreState struct {
608 original, png map[favicon.Key][]byte
609 sized map[favicon.Key]map[int][]byte
610 contentTypes map[favicon.Key]string
611 writeOriginals, removeDerived int
612 err error
613 readSizedErr error
614}
615
616func newFaviconBlobStoreState() *faviconBlobStoreState {
617 return &faviconBlobStoreState{
618 original: map[favicon.Key][]byte{},
619 png: map[favicon.Key][]byte{},
620 sized: map[favicon.Key]map[int][]byte{},
621 contentTypes: map[favicon.Key]string{},
622 }
623}
624
625func (b *faviconBlobStoreState) readOriginal(_ context.Context, key favicon.Key) ([]byte, string, error) {
626 if b.err != nil {
627 return nil, "", b.err
628 }
629 v := b.original[key]
630 if v == nil {
631 return nil, "", ErrFaviconMiss
632 }
633 return v, b.contentTypes[key], nil
634}
635
636func (b *faviconBlobStoreState) writeOriginal(_ context.Context, key favicon.Key, data []byte, ct string) error {
637 if b.err != nil {
638 return b.err
639 }
640 b.writeOriginals++
641 b.original[key] = data
642 b.contentTypes[key] = ct
643 return nil
644}
645
646func (b *faviconBlobStoreState) readPNG(_ context.Context, key favicon.Key) ([]byte, string, error) {
647 if b.err != nil {
648 return nil, "", b.err
649 }
650 v := b.png[key]
651 if v == nil {
652 return nil, "", ErrFaviconMiss
653 }
654 return v, "image/png", nil
655}
656
657func (b *faviconBlobStoreState) writePNG(_ context.Context, key favicon.Key, data []byte) error {
658 if b.err != nil {
659 return b.err
660 }
661 b.png[key] = data
662 return nil
663}
664
665func (b *faviconBlobStoreState) readSizedPNG(_ context.Context, key favicon.Key, size int) ([]byte, string, error) {
666 if b.err != nil {
667 return nil, "", b.err
668 }
669 if b.readSizedErr != nil {
670 return nil, "", b.readSizedErr
671 }
672 if b.sized[key] == nil || b.sized[key][size] == nil {
673 return nil, "", ErrFaviconMiss
674 }
675 return b.sized[key][size], "image/png", nil
676}
677
678func (b *faviconBlobStoreState) writeSizedPNG(_ context.Context, key favicon.Key, size int, data []byte) error {
679 if b.err != nil {
680 return b.err
681 }
682 if b.sized[key] == nil {
683 b.sized[key] = map[int][]byte{}
684 }
685 b.sized[key][size] = data
686 return nil
687}
688
689func (b *faviconBlobStoreState) removeDerivedForKey(_ context.Context, key favicon.Key) error {
690 if b.err != nil {
691 return b.err
692 }
693 b.removeDerived++
694 delete(b.png, key)
695 delete(b.sized, key)
696 return nil
697}
698
699type faviconConverterState struct {
700 calls int
701 err error
702 errByContentType map[string]error
703}
704
705func (c *faviconConverterState) convert(_ context.Context, original []byte, contentType string, sizes []int) (*appport.ConvertedFavicon, error) {
706 c.calls++
707 if c.err != nil {
708 return nil, c.err
709 }
710 if err := c.errByContentType[contentType]; err != nil {
711 return nil, err
712 }
713 out := &appport.ConvertedFavicon{PNG: append([]byte("png:"), original...), SizedPNG: map[int][]byte{}}
714 for _, s := range sizes {
715 out.SizedPNG[s] = append([]byte("png32:"), original...)
716 }
717 return out, nil
718}
719
720type faviconSchedulerState struct {
721 seen map[favicon.Key]bool
722 works map[favicon.Key]func(context.Context)
723 schedules int
724 usedCallerContext bool
725}
726
727func (s *faviconSchedulerState) schedule(ctx context.Context, key favicon.Key, work func(context.Context)) bool {
728 if ctx.Value(faviconFixtureBackgroundKey) != true {
729 s.usedCallerContext = true
730 }
731 if s.seen[key] {
732 return false
733 }
734 if s.works == nil {
735 s.works = map[favicon.Key]func(context.Context){}
736 }
737 s.seen[key] = true
738 s.works[key] = work
739 s.schedules++
740 return true
741}
742
743func (s *faviconSchedulerState) run(key favicon.Key) {
744 if work := s.works[key]; work != nil {
745 work(context.Background())
746 }
747}
748
749type faviconInvalidatorsState struct{ calls int }
750
751func (i *faviconInvalidatorsState) invalidateAll(_ context.Context, _ favicon.Key) error {
752 i.calls++
753 return nil
754}
755
756type faviconFetchResponse struct {
757 bytes []byte
758 contentType string
759 pageURL string
760 resolvedKey favicon.Key
761}
762
763type faviconFetcherState struct {
764 calls int
765 err error
766 responses map[string]faviconFetchResponse
767}
768
769func (f *faviconFetcherState) fetch(_ context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
770 f.calls++
771 if f.err != nil {
772 return nil, f.err
773 }
774 iconURL := req.IconURL
775 if iconURL == "" {
776 iconURL = "https://example.com/favicon.ico"
777 }
778 response := f.responses[iconURL]
779 if response.bytes == nil {
780 response = faviconFetchResponse{bytes: []byte("fetched"), contentType: "image/png"}
781 }
782 pageURL := req.PageURL
783 if response.pageURL != "" {
784 pageURL = response.pageURL
785 }
786 return &appport.FaviconFetchedIcon{
787 PageURL: pageURL,
788 IconURL: iconURL,
789 ResolvedKey: response.resolvedKey,
790 Bytes: response.bytes,
791 Source: favicon.SourceDuckDuckGo,
792 ContentType: response.contentType,
793 }, nil
794}