v0.32.0 0617278

favicon_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close