v0.30.0 eb3068f

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 := 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}

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