main 5041353

favicon_refresh_test.go RawHistoryFolder

1package usecase
2
3import (
4	"context"
5	"fmt"
6	"sync"
7	"sync/atomic"
8	"testing"
9	"time"
10
11	appport "github.com/bnema/dumber/internal/application/port"
12	portmocks "github.com/bnema/dumber/internal/application/port/mocks"
13	"github.com/bnema/dumber/internal/domain/favicon"
14	"github.com/stretchr/testify/mock"
15	"github.com/stretchr/testify/require"
16)
17
18// refreshTestUC builds a FaviconUseCase with a custom fetcher and
19// controllable clock for refresh-coordinator tests.
20func refreshTestUC(t *testing.T, fetcher appport.FaviconFetcher, now *time.Time, background context.Context) *FaviconUseCase {
21	t.Helper()
22	if background == nil {
23		background = context.Background()
24	}
25	nowFn := time.Now
26	if now != nil {
27		nowFn = func() time.Time { return *now }
28	}
29	return NewFaviconUseCase(FaviconDeps{
30		Repository:   mockFaviconRepository(t, newFaviconRepoState()),
31		BlobStore:    mockFaviconBlobStore(t, newFaviconBlobStoreState()),
32		Converter:    mockFaviconConverter(t, &faviconConverterState{}),
33		Scheduler:    mockFaviconScheduler(t, &faviconSchedulerState{seen: map[favicon.Key]bool{}}),
34		Invalidators: mockFaviconInvalidators(t, &faviconInvalidatorsState{}),
35		Fetcher:      fetcher,
36		Now:          nowFn,
37		Background:   background,
38	})
39}
40
41func refreshFetcherMock(t *testing.T, fetch func(context.Context, appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error)) *portmocks.MockFaviconFetcher {
42	t.Helper()
43	fetcher := portmocks.NewMockFaviconFetcher(t)
44	fetcher.EXPECT().Fetch(mock.Anything, mock.Anything).RunAndReturn(fetch).Maybe()
45	return fetcher
46}
47
48func fetchedIcon(iconURL string) *appport.FaviconFetchedIcon {
49	return &appport.FaviconFetchedIcon{
50		IconURL:     iconURL,
51		Bytes:       []byte("icon-bytes"),
52		ContentType: "image/png",
53	}
54}
55
56// TestRefreshFromIconURLs_ConcurrentIdenticalSharesOneFetch reproduces the
57// P1.1 bypass: concurrent identical candidate requests must share a single
58// fetch instead of issuing one fetch per caller.
59func TestRefreshFromIconURLs_ConcurrentIdenticalSharesOneFetch(t *testing.T) {
60	var calls atomic.Int64
61	entered := make(chan struct{}, 8)
62	release := make(chan struct{})
63	fetcher := refreshFetcherMock(t, func(_ context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
64		calls.Add(1)
65		entered <- struct{}{}
66		<-release
67		return fetchedIcon(req.IconURL), nil
68	})
69	uc := refreshTestUC(t, fetcher, nil, nil)
70
71	const waiters = 5
72	page := "https://example.com/a"
73	candidates := []string{"https://example.com/favicon.ico"}
74	key, _, ok := refreshRequestKey(page, candidates)
75	require.True(t, ok)
76	results := make(chan error, waiters)
77	var wg sync.WaitGroup
78	for range waiters {
79		wg.Add(1)
80		go func() {
81			defer wg.Done()
82			results <- uc.RefreshFromIconURLs(context.Background(), page, candidates)
83		}()
84	}
85	// All five waiters must join the single shared operation before the
86	// fetch completes; the waiter count is the deterministic join signal.
87	require.Eventually(t, func() bool {
88		uc.shared.mu.Lock()
89		defer uc.shared.mu.Unlock()
90		call, ok := uc.shared.active[key]
91		return ok && call.waiters == waiters
92	}, 10*time.Second, 5*time.Millisecond, "waiters did not join the shared operation")
93	close(release)
94	wg.Wait()
95	close(results)
96	for err := range results {
97		require.NoError(t, err)
98	}
99	require.EqualValues(t, 1, calls.Load(), "identical concurrent refreshes must share one fetch")
100}
101
102// TestRefreshFromIconURLs_ChangingCandidatesDoNotShare verifies that
103// different candidate lists are distinct operations.
104func TestRefreshFromIconURLs_ChangingCandidatesDoNotShare(t *testing.T) {
105	var calls atomic.Int64
106	fetcher := refreshFetcherMock(t, func(_ context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
107		calls.Add(1)
108		return fetchedIcon(req.IconURL), nil
109	})
110	uc := refreshTestUC(t, fetcher, nil, nil)
111
112	require.NoError(t, uc.RefreshFromIconURLs(context.Background(), "https://example.com/a", []string{"https://example.com/one.ico"}))
113	require.NoError(t, uc.RefreshFromIconURLs(context.Background(), "https://example.com/a", []string{"https://example.com/two.ico"}))
114	require.EqualValues(t, 2, calls.Load())
115}
116
117// TestRefreshFromIconURLs_CancelOneWaiterKeepsOthers proves waiter
118// independence: a canceled waiter leaves while the shared operation serves
119// the rest.
120func TestRefreshFromIconURLs_CancelOneWaiterKeepsOthers(t *testing.T) {
121	release := make(chan struct{})
122	fetcher := refreshFetcherMock(t, func(_ context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
123		<-release
124		return fetchedIcon(req.IconURL), nil
125	})
126	uc := refreshTestUC(t, fetcher, nil, nil)
127
128	page := "https://example.com/a"
129	candidates := []string{"https://example.com/favicon.ico"}
130	key, _, ok := refreshRequestKey(page, candidates)
131	require.True(t, ok, "test page and candidates must form a refresh key")
132	leaving, cancelLeaving := context.WithCancel(context.Background())
133	left := make(chan error, 1)
134	go func() {
135		left <- uc.RefreshFromIconURLs(leaving, page, candidates)
136	}()
137	staying := make(chan error, 1)
138	go func() {
139		staying <- uc.RefreshFromIconURLs(context.Background(), page, candidates)
140	}()
141	// Both waiters must share one operation before either leaves: two
142	// waiters on the same call proves sharing, not two separate fetches.
143	require.Eventually(t, func() bool {
144		uc.shared.mu.Lock()
145		defer uc.shared.mu.Unlock()
146		call, ok := uc.shared.active[key]
147		return ok && !call.closing && call.waiters == 2
148	}, 10*time.Second, 5*time.Millisecond, "both waiters must join one shared operation")
149	cancelLeaving()
150	select {
151	case err := <-left:
152		require.ErrorIs(t, err, context.Canceled)
153	case <-time.After(10 * time.Second):
154		t.Fatal("canceled waiter did not return")
155	}
156	close(release)
157	select {
158	case err := <-staying:
159		require.NoError(t, err, "remaining waiter must still be served")
160	case <-time.After(10 * time.Second):
161		t.Fatal("remaining waiter was dropped with the canceled one")
162	}
163}
164
165// TestRefreshFromIconURLs_InvalidPageHierarchyRejected verifies invalid
166// origins never reach the fetcher.
167func TestRefreshFromIconURLs_InvalidPageHierarchyRejected(t *testing.T) {
168	var calls atomic.Int64
169	fetcher := refreshFetcherMock(t, func(_ context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
170		calls.Add(1)
171		return fetchedIcon(req.IconURL), nil
172	})
173	uc := refreshTestUC(t, fetcher, nil, nil)
174
175	for _, page := range []string{"", "not a url", "ftp://example.com/a", "dumb://internal"} {
176		require.ErrorIs(t, uc.RefreshFromIconURLs(context.Background(), page, []string{"https://example.com/favicon.ico"}), ErrFaviconMiss)
177	}
178	require.Zero(t, calls.Load())
179}
180
181// TestRefreshRequestKey_Distinctions covers origin and candidate
182// significance: scheme/port/query matter, fragments/case/default ports do
183// not, relative candidates resolve against the page, over-long and excess
184// candidates are skipped.
185func TestRefreshRequestKey_Distinctions(t *testing.T) {
186	page := "https://example.com:8443/a?x=1"
187	keyOf := func(candidates ...string) (string, bool) {
188		key, _, ok := refreshRequestKey(page, candidates)
189		return key, ok
190	}
191	base, ok := keyOf("https://example.com:8443/icon.png")
192	require.True(t, ok)
193
194	other, _ := keyOf("https://example.com:8443/other.png")
195	require.NotEqual(t, base, other, "different candidates must not share")
196
197	portKey, _ := keyOf("https://example.com:9443/icon.png")
198	require.NotEqual(t, base, portKey, "ports are significant")
199
200	queryKey, _ := keyOf("https://example.com:8443/icon.png?v=2")
201	require.NotEqual(t, base, queryKey, "queries are significant")
202
203	fragKey, _ := keyOf("https://example.com:8443/icon.png#frag")
204	require.Equal(t, base, fragKey, "fragments must not split sharing")
205
206	caseKey, _ := keyOf("HTTPS://EXAMPLE.COM:8443/icon.png")
207	require.Equal(t, base, caseKey, "scheme/host case must not split sharing")
208
209	defaultPort, _ := keyOf("https://example.com:443/icon.png")
210	otherPage, _, ok := refreshRequestKey("https://example.com/icon", []string{"https://example.com:443/icon.png"})
211	require.True(t, ok)
212	require.NotEqual(t, defaultPort, otherPage, "page origin is part of the identity")
213
214	relKey, relCandidates, ok := refreshRequestKey("https://example.com/a", []string{"/icon.png"})
215	require.True(t, ok)
216	require.Equal(t, []string{"https://example.com/icon.png"}, relCandidates, "relative candidates resolve against the page")
217	require.NotEqual(t, relKey, base, "different pages must not share")
218
219	long := "https://example.com/" + string(make([]byte, maxRefreshCandidateLen))
220	_, kept, ok := refreshRequestKey(page, []string{long, "https://example.com:8443/icon.png"})
221	require.True(t, ok)
222	require.Len(t, kept, 1, "over-long candidates are skipped")
223
224	many := make([]string, maxRefreshCandidates+4)
225	for i := range many {
226		many[i] = "https://example.com:8443/icon.png"
227	}
228	_, kept, ok = refreshRequestKey(page, many)
229	require.True(t, ok)
230	require.Len(t, kept, maxRefreshCandidates, "candidates are capped")
231
232	_, _, ok = refreshRequestKey("not a url", []string{"https://example.com/icon.png"})
233	require.False(t, ok, "invalid page hierarchy is rejected")
234}
235
236// TestRefreshAdmission_BusyWhenExhausted fills the shared budget with
237// blocked operations and proves the next direct caller gets a distinct
238// busy outcome instead of false success.
239func TestRefreshAdmission_BusyWhenExhausted(t *testing.T) {
240	release := make(chan struct{})
241	fetcher := refreshFetcherMock(t, func(_ context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
242		<-release
243		return fetchedIcon(req.IconURL), nil
244	})
245	uc := refreshTestUC(t, fetcher, nil, nil)
246
247	var wg sync.WaitGroup
248	for i := range maxActiveRefreshes {
249		wg.Add(1)
250		go func(i int) {
251			defer wg.Done()
252			// Distinct candidates per holder fill distinct budget slots;
253			// identical requests would share one.
254			icon := fmt.Sprintf("https://example.com/icon-%d.png", i)
255			_ = uc.RefreshFromIconURLs(context.Background(), "https://example.com/page", []string{icon})
256		}(i)
257	}
258	require.Eventually(t, func() bool {
259		uc.shared.mu.Lock()
260		defer uc.shared.mu.Unlock()
261		return len(uc.shared.active) == maxActiveRefreshes
262	}, 10*time.Second, 5*time.Millisecond, "budget must fill")
263
264	err := uc.RefreshFromIconURLs(context.Background(), "https://other.example/", []string{"https://other.example/icon.png"})
265	require.ErrorIs(t, err, appport.ErrFaviconBusy)
266	require.NotErrorIs(t, err, ErrFaviconMiss, "overload must stay distinct from a miss")
267
268	close(release)
269	wg.Wait()
270	require.NoError(t, uc.RefreshFromIconURLs(context.Background(), "https://other.example/", []string{"https://other.example/icon.png"}))
271}
272
273// TestRefreshOp_WholeDeadline verifies the shared operation carries the
274// bounded whole-operation deadline without waiting it out.
275func TestRefreshOp_WholeDeadline(t *testing.T) {
276	deadlines := make(chan time.Time, 1)
277	fetcher := refreshFetcherMock(t, func(ctx context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
278		if deadline, ok := ctx.Deadline(); ok {
279			deadlines <- deadline
280		}
281		return fetchedIcon(req.IconURL), nil
282	})
283	uc := refreshTestUC(t, fetcher, nil, nil)
284
285	require.NoError(t, uc.RefreshFromIconURLs(context.Background(), "https://example.com/a", []string{"https://example.com/favicon.ico"}))
286	select {
287	case deadline := <-deadlines:
288		remaining := time.Until(deadline)
289		require.Greater(t, remaining, 4*time.Second)
290		require.LessOrEqual(t, remaining, refreshOpTimeout)
291	case <-time.After(10 * time.Second):
292		t.Fatal("fetch did not observe the operation deadline")
293	}
294}
295
296// TestRefreshLastWaiterCancelAbortsOp proves a lone waiter that leaves
297// cancels the shared operation instead of leaking it.
298func TestRefreshLastWaiterCancelAbortsOp(t *testing.T) {
299	observedCancel := make(chan struct{}, 1)
300	entered := make(chan struct{}, 1)
301	fetcher := refreshFetcherMock(t, func(ctx context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
302		select {
303		case entered <- struct{}{}:
304		default:
305		}
306		select {
307		case <-ctx.Done():
308			observedCancel <- struct{}{}
309			return nil, ctx.Err()
310		case <-time.After(10 * time.Second):
311			return fetchedIcon(req.IconURL), nil
312		}
313	})
314	uc := refreshTestUC(t, fetcher, nil, nil)
315
316	waiter, cancel := context.WithCancel(context.Background())
317	done := make(chan error, 1)
318	go func() {
319		done <- uc.RefreshFromIconURLs(waiter, "https://example.com/a", []string{"https://example.com/favicon.ico"})
320	}()
321	// Cancel only after the executor entered the fetch: len(active) == 1
322	// alone cannot prove the executor is blocked inside Fetch, and an early
323	// cancel could race operation creation instead of aborting shared work.
324	select {
325	case <-entered:
326	case <-time.After(10 * time.Second):
327		t.Fatal("executor did not enter the fetch")
328	}
329	cancel()
330	select {
331	case err := <-done:
332		require.ErrorIs(t, err, context.Canceled)
333	case <-time.After(10 * time.Second):
334		t.Fatal("canceled lone waiter did not return")
335	}
336	select {
337	case <-observedCancel:
338	case <-time.After(10 * time.Second):
339		t.Fatal("operation was not canceled with its last waiter")
340	}
341	require.Eventually(t, func() bool {
342		uc.shared.mu.Lock()
343		defer uc.shared.mu.Unlock()
344		return len(uc.shared.active) == 0
345	}, 10*time.Second, 5*time.Millisecond, "canceled operation must release its slot")
346}
347
348// TestFaviconUseCase_CloseCancelsAndDrains proves shutdown wiring: Close
349// aborts owned background work and returns once nothing is active.
350func TestFaviconUseCase_CloseCancelsAndDrains(t *testing.T) {
351	background, cancel := context.WithCancel(context.Background())
352	defer cancel()
353	sawCancel := make(chan struct{}, 1)
354	fetcher := refreshFetcherMock(t, func(ctx context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
355		select {
356		case <-ctx.Done():
357			sawCancel <- struct{}{}
358			return nil, ctx.Err()
359		case <-time.After(10 * time.Second):
360			return fetchedIcon(req.IconURL), nil
361		}
362	})
363	uc := refreshTestUC(t, fetcher, nil, background)
364
365	done := make(chan error, 1)
366	go func() {
367		done <- uc.RefreshFromIconURLs(context.Background(), "https://example.com/a", []string{"https://example.com/favicon.ico"})
368	}()
369	require.Eventually(t, func() bool {
370		uc.shared.mu.Lock()
371		defer uc.shared.mu.Unlock()
372		return len(uc.shared.active) == 1
373	}, 10*time.Second, 5*time.Millisecond)
374
375	closed := make(chan struct{})
376	go func() {
377		uc.Close()
378		close(closed)
379	}()
380	select {
381	case <-sawCancel:
382	case <-time.After(10 * time.Second):
383		t.Fatal("Close did not cancel background work")
384	}
385	select {
386	case <-closed:
387	case <-time.After(10 * time.Second):
388		t.Fatal("Close did not drain")
389	}
390	select {
391	case err := <-done:
392		require.Error(t, err)
393	case <-time.After(10 * time.Second):
394		t.Fatal("refresh caller did not return after Close")
395	}
396	// Repeat Close must be safe (idempotent, no error to report).
397	uc.Close()
398}
399
400// TestRefreshMissClassification_HTTPClasses proves only genuine absence
401// (404/410) is negatively cached: auth/transient failures refetch, and a
402// repeated miss within TTL issues no fetch.
403func TestRefreshMissClassification_HTTPClasses(t *testing.T) {
404	now := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC)
405	statusByURL := map[string]int{
406		"https://example.com/gone.ico":       404,
407		"https://example.com/removed.ico":    410,
408		"https://example.com/denied.ico":     403,
409		"https://example.com/login.ico":      401,
410		"https://example.com/broken.ico":     500,
411		"https://example.com/unstable.ico":   503,
412		"https://example.com/validation.ico": 0,
413	}
414	var calls atomic.Int64
415	var candidateCalls atomic.Int64
416	fetcher := refreshFetcherMock(t, func(_ context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
417		calls.Add(1)
418		// The all-miss fallback runs a candidate-less discovery fetch;
419		// assertions below count candidate batches only.
420		if req.IconURL != "" {
421			candidateCalls.Add(1)
422		}
423		status := statusByURL[req.IconURL]
424		if status == 0 {
425			return nil, appport.ErrFaviconMiss
426		}
427		return nil, &appport.FaviconFetchError{StatusCode: status}
428	})
429	uc := refreshTestUC(t, fetcher, &now, nil)
430	page := "https://example.com/a"
431
432	refresh := func(icon string) error {
433		return uc.RefreshFromIconURLs(context.Background(), page, []string{icon})
434	}
435	for icon := range statusByURL {
436		err := refresh(icon)
437		require.ErrorIs(t, err, ErrFaviconMiss, "every class must preserve miss matching for %s", icon)
438	}
439	firstCalls := candidateCalls.Load()
440	require.EqualValues(t, len(statusByURL), firstCalls)
441
442	// Genuine absence is cached: 404/410 refetch nothing within TTL.
443	require.ErrorIs(t, refresh("https://example.com/gone.ico"), ErrFaviconMiss)
444	require.ErrorIs(t, refresh("https://example.com/removed.ico"), ErrFaviconMiss)
445	require.Equal(t, firstCalls, candidateCalls.Load(), "cached genuine absence must not refetch")
446
447	// Auth/transient/validation failures are never cached: they refetch.
448	require.ErrorIs(t, refresh("https://example.com/denied.ico"), ErrFaviconMiss)
449	require.ErrorIs(t, refresh("https://example.com/broken.ico"), ErrFaviconMiss)
450	require.ErrorIs(t, refresh("https://example.com/validation.ico"), ErrFaviconMiss)
451	require.Equal(t, firstCalls+3, candidateCalls.Load())
452
453	// After TTL expiry the genuine absence is eligible again.
454	now = now.Add(faviconMissTTL + time.Second)
455	require.ErrorIs(t, refresh("https://example.com/gone.ico"), ErrFaviconMiss)
456	require.Equal(t, firstCalls+4, candidateCalls.Load())
457}
458
459// TestRefreshInvalidate_DropsStaleCompletion proves the epoch rule: an
460// invalidation racing an in-flight fetch prevents the stale bytes from
461// being stored.
462func TestRefreshInvalidate_DropsStaleCompletion(t *testing.T) {
463	release := make(chan struct{})
464	fetcher := refreshFetcherMock(t, func(_ context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
465		<-release
466		return fetchedIcon(req.IconURL), nil
467	})
468	repo := newFaviconRepoState()
469	uc := NewFaviconUseCase(FaviconDeps{
470		Repository:   mockFaviconRepository(t, repo),
471		BlobStore:    mockFaviconBlobStore(t, newFaviconBlobStoreState()),
472		Converter:    mockFaviconConverter(t, &faviconConverterState{}),
473		Scheduler:    mockFaviconScheduler(t, &faviconSchedulerState{seen: map[favicon.Key]bool{}}),
474		Invalidators: mockFaviconInvalidators(t, &faviconInvalidatorsState{}),
475		Fetcher:      fetcher,
476		Now:          time.Now,
477		Background:   context.Background(),
478	})
479
480	page := "https://example.com/a"
481	done := make(chan error, 1)
482	go func() {
483		done <- uc.RefreshFromIconURLs(context.Background(), page, []string{"https://example.com/favicon.ico"})
484	}()
485	require.Eventually(t, func() bool {
486		uc.shared.mu.Lock()
487		defer uc.shared.mu.Unlock()
488		return len(uc.shared.active) == 1
489	}, 10*time.Second, 5*time.Millisecond)
490
491	key, ok := favicon.CanonicalKey(page)
492	require.True(t, ok)
493	require.NoError(t, uc.Invalidate(context.Background(), key))
494	close(release)
495	select {
496	case <-done:
497	case <-time.After(10 * time.Second):
498		t.Fatal("refresh did not complete after release")
499	}
500	require.Empty(t, repo.byKey, "stale completion must not repopulate invalidated entries")
501}
502
503// TestRefreshJoinerSharesCreatorEpoch proves concurrent joiners never
504// invalidate the operation they joined: overlapping identical refreshes
505// both succeed and the fetched icon is stored.
506func TestRefreshJoinerSharesCreatorEpoch(t *testing.T) {
507	release := make(chan struct{})
508	var calls atomic.Int64
509	fetcher := refreshFetcherMock(t, func(_ context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
510		calls.Add(1)
511		<-release
512		return fetchedIcon(req.IconURL), nil
513	})
514	repo := newFaviconRepoState()
515	uc := NewFaviconUseCase(FaviconDeps{
516		Repository:   mockFaviconRepository(t, repo),
517		BlobStore:    mockFaviconBlobStore(t, newFaviconBlobStoreState()),
518		Converter:    mockFaviconConverter(t, &faviconConverterState{}),
519		Scheduler:    mockFaviconScheduler(t, &faviconSchedulerState{seen: map[favicon.Key]bool{}}),
520		Invalidators: mockFaviconInvalidators(t, &faviconInvalidatorsState{}),
521		Fetcher:      fetcher,
522		Now:          time.Now,
523		Background:   context.Background(),
524	})
525
526	page := "https://example.com/a"
527	candidates := []string{"https://example.com/favicon.ico"}
528	key, _, ok := refreshRequestKey(page, candidates)
529	require.True(t, ok)
530	first := make(chan error, 1)
531	go func() {
532		first <- uc.RefreshFromIconURLs(context.Background(), page, candidates)
533	}()
534	require.Eventually(t, func() bool {
535		uc.shared.mu.Lock()
536		defer uc.shared.mu.Unlock()
537		return len(uc.shared.active) == 1
538	}, 10*time.Second, 5*time.Millisecond)
539	second := make(chan error, 1)
540	go func() {
541		second <- uc.RefreshFromIconURLs(context.Background(), page, candidates)
542	}()
543	require.Eventually(t, func() bool {
544		uc.shared.mu.Lock()
545		defer uc.shared.mu.Unlock()
546		call, ok := uc.shared.active[key]
547		return ok && call.waiters == 2
548	}, 10*time.Second, 5*time.Millisecond, "joiner must share the creator operation")
549	close(release)
550	select {
551	case err := <-first:
552		require.NoError(t, err)
553	case <-time.After(10 * time.Second):
554		t.Fatal("creator did not complete")
555	}
556	select {
557	case err := <-second:
558		require.NoError(t, err)
559	case <-time.After(10 * time.Second):
560		t.Fatal("joiner did not complete")
561	}
562	require.EqualValues(t, 1, calls.Load())
563	require.NotEmpty(t, repo.byKey, "shared success must be stored, not dropped by a joiner epoch bump")
564}
565
566// TestRefreshPartialMissDoesNotPoisonLaterCandidates proves request-level
567// negative caching happens only for fully missed requests: a 404 on the
568// first candidate followed by a successful second candidate stores the
569// icon and records no miss.
570func TestRefreshPartialMissDoesNotPoisonLaterCandidates(t *testing.T) {
571	var calls atomic.Int64
572	fetcher := refreshFetcherMock(t, func(_ context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
573		calls.Add(1)
574		if req.IconURL == "https://example.com/gone.ico" {
575			return nil, &appport.FaviconFetchError{StatusCode: 404}
576		}
577		return fetchedIcon(req.IconURL), nil
578	})
579	repo := newFaviconRepoState()
580	uc := NewFaviconUseCase(FaviconDeps{
581		Repository:   mockFaviconRepository(t, repo),
582		BlobStore:    mockFaviconBlobStore(t, newFaviconBlobStoreState()),
583		Converter:    mockFaviconConverter(t, &faviconConverterState{}),
584		Scheduler:    mockFaviconScheduler(t, &faviconSchedulerState{seen: map[favicon.Key]bool{}}),
585		Invalidators: mockFaviconInvalidators(t, &faviconInvalidatorsState{}),
586		Fetcher:      fetcher,
587		Now:          time.Now,
588		Background:   context.Background(),
589	})
590
591	page := "https://example.com/a"
592	candidates := []string{"https://example.com/gone.ico", "https://example.com/good.ico"}
593	require.NoError(t, uc.RefreshFromIconURLs(context.Background(), page, candidates))
594	require.NotEmpty(t, repo.byKey, "successful candidate must be stored")
595	key, _, ok := refreshRequestKey(page, candidates)
596	require.True(t, ok)
597	require.False(t, uc.cachedMiss(key), "partial miss must not populate the request miss cache")
598}
599
600// TestRefreshCanceledCallNotJoined proves a waiter arriving after the last
601// waiter canceled starts a fresh operation instead of observing the dying
602// call.
603func TestRefreshCanceledCallNotJoined(t *testing.T) {
604	release := make(chan struct{})
605	var calls atomic.Int64
606	fetcher := refreshFetcherMock(t, func(ctx context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
607		calls.Add(1)
608		select {
609		case <-release:
610			return fetchedIcon(req.IconURL), nil
611		case <-ctx.Done():
612			return nil, ctx.Err()
613		}
614	})
615	uc := refreshTestUC(t, fetcher, nil, nil)
616
617	page := "https://example.com/a"
618	candidates := []string{"https://example.com/favicon.ico"}
619	leaving, cancel := context.WithCancel(context.Background())
620	left := make(chan error, 1)
621	go func() {
622		left <- uc.RefreshFromIconURLs(leaving, page, candidates)
623	}()
624	require.Eventually(t, func() bool {
625		uc.shared.mu.Lock()
626		defer uc.shared.mu.Unlock()
627		return len(uc.shared.active) == 1
628	}, 10*time.Second, 5*time.Millisecond)
629	cancel()
630	select {
631	case err := <-left:
632		require.ErrorIs(t, err, context.Canceled)
633	case <-time.After(10 * time.Second):
634		t.Fatal("canceled waiter did not return")
635	}
636	// The dying call still occupies the map entry until its executor
637	// finishes; the next waiter must start fresh rather than join it.
638	fresh := make(chan error, 1)
639	go func() {
640		fresh <- uc.RefreshFromIconURLs(context.Background(), page, candidates)
641	}()
642	require.Eventually(t, func() bool {
643		return calls.Load() == 2
644	}, 10*time.Second, 5*time.Millisecond, "late joiner must start a fresh operation")
645	close(release)
646	select {
647	case err := <-fresh:
648		require.NoError(t, err)
649	case <-time.After(10 * time.Second):
650		t.Fatal("fresh operation did not complete")
651	}
652}
653
654// TestRefreshCloseSealsAdmission proves no refresh can start after Close
655// began: late callers receive a distinct shutdown error instead of racing
656// the drain.
657func TestRefreshCloseSealsAdmission(t *testing.T) {
658	uc := refreshTestUC(t, refreshFetcherMock(t, func(_ context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
659		return fetchedIcon(req.IconURL), nil
660	}), nil, nil)
661
662	uc.Close()
663	err := uc.RefreshFromIconURLs(context.Background(), "https://example.com/a", []string{"https://example.com/favicon.ico"})
664	require.ErrorIs(t, err, appport.ErrFaviconShutdown)
665	require.NotErrorIs(t, err, ErrFaviconMiss, "shutdown must stay distinct from a miss")
666}
667
668// TestRefreshMissOrderBounded proves the negative-cache order backlog
669// cannot grow without bound under distinct misses.
670func TestRefreshMissOrderBounded(t *testing.T) {
671	now := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC)
672	fetcher := refreshFetcherMock(t, func(_ context.Context, _ appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
673		return nil, &appport.FaviconFetchError{StatusCode: 404}
674	})
675	uc := refreshTestUC(t, fetcher, &now, nil)
676
677	for i := range 3 * maxMissEntries {
678		icon := "https://example.com/missing-" + string(rune('a'+i%26)) + "-" + string(rune('0'+i/26%10)) + ".ico"
679		require.ErrorIs(t, uc.RefreshFromIconURLs(context.Background(), "https://example.com/a", []string{icon}), ErrFaviconMiss)
680	}
681	uc.shared.mu.Lock()
682	defer uc.shared.mu.Unlock()
683	require.LessOrEqual(t, len(uc.shared.misses), maxMissEntries)
684	require.LessOrEqual(t, len(uc.shared.order), 4*maxMissEntries, "order backlog must stay bounded")
685}
686
687// TestRefreshSharing_ScopedToUseCaseInstance proves profile isolation:
688// identical requests on different use-case instances never share fetches.
689func TestRefreshSharing_ScopedToUseCaseInstance(t *testing.T) {
690	var calls atomic.Int64
691	newUC := func() *FaviconUseCase {
692		fetcher := refreshFetcherMock(t, func(_ context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
693			calls.Add(1)
694			return fetchedIcon(req.IconURL), nil
695		})
696		return refreshTestUC(t, fetcher, nil, nil)
697	}
698	first, second := newUC(), newUC()
699	page := "https://example.com/a"
700	candidates := []string{"https://example.com/favicon.ico"}
701	var wg sync.WaitGroup
702	errs := make(chan error, 2)
703	for _, uc := range []*FaviconUseCase{first, second} {
704		wg.Add(1)
705		go func(uc *FaviconUseCase) {
706			defer wg.Done()
707			errs <- uc.RefreshFromIconURLs(context.Background(), page, candidates)
708		}(uc)
709	}
710	wg.Wait()
711	close(errs)
712	for err := range errs {
713		require.NoError(t, err)
714	}
715	require.EqualValues(t, 2, calls.Load(), "sharing must not cross use-case instances")
716}
717
718// TestRefreshMixedStatusBatchNotCached proves a batch mixing a transient
719// failure with a genuine absence never populates the negative cache: the
720// last miss alone must not decide cacheability.
721func TestRefreshMixedStatusBatchNotCached(t *testing.T) {
722	var calls atomic.Int64
723	fetcher := refreshFetcherMock(t, func(_ context.Context, req appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
724		calls.Add(1)
725		if req.IconURL == "https://example.com/denied.ico" {
726			return nil, &appport.FaviconFetchError{StatusCode: 403}
727		}
728		return nil, &appport.FaviconFetchError{StatusCode: 404}
729	})
730	uc := refreshTestUC(t, fetcher, nil, nil)
731
732	page := "https://example.com/a"
733	candidates := []string{"https://example.com/denied.ico", "https://example.com/gone.ico"}
734	require.ErrorIs(t, uc.RefreshFromIconURLs(context.Background(), page, candidates), ErrFaviconMiss)
735	key, _, ok := refreshRequestKey(page, candidates)
736	require.True(t, ok)
737	require.False(t, uc.cachedMiss(key), "mixed 403+404 batch must not be negatively cached")
738	require.ErrorIs(t, uc.RefreshFromIconURLs(context.Background(), page, candidates), ErrFaviconMiss)
739	require.EqualValues(t, 4, calls.Load(), "second refresh must retry instead of reading a cached absence")
740}
741
742// TestRefreshClosedWinsOverCachedMiss proves sealed admission takes
743// precedence over a cached absence: after Close, refresh reports shutdown
744// even for a key holding a genuine 404.
745func TestRefreshClosedWinsOverCachedMiss(t *testing.T) {
746	var calls atomic.Int64
747	fetcher := refreshFetcherMock(t, func(_ context.Context, _ appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
748		calls.Add(1)
749		return nil, &appport.FaviconFetchError{StatusCode: 404}
750	})
751	uc := refreshTestUC(t, fetcher, nil, nil)
752
753	page := "https://example.com/a"
754	candidates := []string{"https://example.com/gone.ico"}
755	key, _, ok := refreshRequestKey(page, candidates)
756	require.True(t, ok)
757	uc.noteMiss(key, &appport.FaviconFetchError{StatusCode: 404})
758	require.True(t, uc.cachedMiss(key))
759	uc.Close()
760	err := uc.RefreshFromIconURLs(context.Background(), page, candidates)
761	require.ErrorIs(t, err, appport.ErrFaviconShutdown)
762	require.NotErrorIs(t, err, ErrFaviconMiss, "shutdown must stay distinct from a miss")
763	require.Zero(t, calls.Load(), "sealed refresh must not reach the fetcher")
764}
765
766// TestCompactMissOrderDedupsReinsertedKeys proves compaction collapses
767// stale duplicates: an evicted key that is reinserted keeps exactly one
768// order entry, so the backlog cannot grow across expire/reinsert cycles.
769func TestCompactMissOrderDedupsReinsertedKeys(t *testing.T) {
770	now := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC)
771	uc := refreshTestUC(t, refreshFetcherMock(t, func(_ context.Context, _ appport.FaviconFetchRequest) (*appport.FaviconFetchedIcon, error) {
772		return nil, &appport.FaviconFetchError{StatusCode: 404}
773	}), &now, nil)
774
775	uc.shared.mu.Lock()
776	uc.shared.misses["live"] = refreshMiss{expiresAt: now.Add(faviconMissTTL)}
777	// Simulate eviction (map entry removed, order name left behind) plus
778	// reinsertion: the same key appears twice in order.
779	uc.shared.order = []string{"stale-a", "live", "stale-b", "live"}
780	uc.compactMissOrderLocked()
781	order := append([]string(nil), uc.shared.order...)
782	uc.shared.mu.Unlock()
783	require.Equal(t, []string{"live"}, order, "compaction must keep one entry per live key")
784}
785
786// TestRefreshRequestKey_IPv6DefaultPortEquivalence proves bracketed IPv6
787// hosts with an explicit default port share identity with the bare form:
788// without bracket-aware parsing the two spellings hash differently and
789// bypass in-flight sharing.
790func TestRefreshRequestKey_IPv6DefaultPortEquivalence(t *testing.T) {
791	page := "https://[2001:db8::1]/a"
792	withPort, withPortAccepted, ok := refreshRequestKey(page, []string{"https://[2001:db8::1]:443/favicon.ico"})
793	require.True(t, ok)
794	bare, bareAccepted, ok := refreshRequestKey(page, []string{"https://[2001:db8::1]/favicon.ico"})
795	require.True(t, ok)
796	require.Equal(t, bare, withPort, "explicit default port must not change the request key")
797	require.Equal(t, bareAccepted, withPortAccepted)
798}

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