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}