v0.33.0 62a09f5

favicon_test.go RawHistoryFolder

1package adapter
2
3import (
4	"context"
5	"sync/atomic"
6	"testing"
7
8	"github.com/bnema/dumber/internal/application/port"
9	portmocks "github.com/bnema/dumber/internal/application/port/mocks"
10	"github.com/bnema/dumber/internal/domain/favicon"
11	"github.com/bnema/puregotk/v4/gdk"
12	"github.com/rs/zerolog"
13	"github.com/stretchr/testify/mock"
14)
15
16func TestFaviconAdapterInvalidateRemovesStoredKey(t *testing.T) {
17	adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
18	texture := &gdk.Texture{}
19	adapter.setTexture("example.com", texture)
20
21	if adapter.GetTexture("example.com") == nil {
22		t.Fatal("expected key to be cached before invalidation")
23	}
24	if err := adapter.Invalidate(context.Background(), "example.com"); err != nil {
25		t.Fatal(err)
26	}
27	if adapter.GetTexture("example.com") != nil {
28		t.Fatal("expected invalidation to remove the stored key")
29	}
30}
31
32func TestFaviconAdapterGetTextureByURLPrefersExactThenParentThenDomainFallback(t *testing.T) {
33	adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
34	domainTexture := &gdk.Texture{}
35	repoTexture := &gdk.Texture{}
36	prTexture := &gdk.Texture{}
37	adapter.setTexture("github.com", domainTexture)
38	adapter.setTexture("github.com/bnema/gordon", repoTexture)
39	adapter.setTexture("github.com/bnema/gordon/pull/123", prTexture)
40
41	if got := adapter.GetTextureByURL("https://github.com/bnema/gordon/pull/123"); got != prTexture {
42		t.Fatal("expected exact PR favicon")
43	}
44	if got := adapter.GetTextureByURL("https://github.com/bnema/gordon/issues/5"); got != repoTexture {
45		t.Fatal("expected issue URL to fall back to repository favicon")
46	}
47	if got := adapter.GetTextureByURL("https://github.com/notifications"); got != domainTexture {
48		t.Fatal("expected unrelated URL to fall back to domain favicon")
49	}
50}
51
52func TestFaviconAdapterSetResolvedTextureCachesOnlyResolvedKey(t *testing.T) {
53	adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
54	fallbackTexture := &gdk.Texture{}
55	pageURL := "https://github.com/bnema/gordon/pull/123"
56	pageKey := "github.com/bnema/gordon/pull/123"
57
58	adapter.setResolvedTexture(pageURL, favicon.Key("github.com"), fallbackTexture)
59
60	if got := adapter.GetTexture("github.com"); got != fallbackTexture {
61		t.Fatal("expected resolved fallback key to be cached")
62	}
63	if got := adapter.GetTexture(pageKey); got != nil {
64		t.Fatal("fallback result should not be cached under the exact page key")
65	}
66	if got := adapter.GetTextureByURL(pageURL); got != fallbackTexture {
67		t.Fatal("expected exact page lookup to fall back through candidate ordering")
68	}
69}
70
71func TestFaviconAdapterResolvedPathSpecificTextureDoesNotPolluteDomain(t *testing.T) {
72	adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
73	prTexture := &gdk.Texture{}
74	adapter.setResolvedTexture("https://github.com/bnema/gordon/pull/123", favicon.Key("github.com/bnema/gordon/pull/123"), prTexture)
75
76	if got := adapter.GetTextureByURL("https://github.com/bnema/gordon/pull/123"); got != prTexture {
77		t.Fatal("expected exact PR favicon")
78	}
79	if got := adapter.GetTextureByURL("https://github.com/bnema/gordon"); got != nil {
80		t.Fatal("path-specific favicon leaked to sibling repository URL")
81	}
82	if got := adapter.GetTexture("github.com"); got != nil {
83		t.Fatal("path-specific favicon was cached under bare domain")
84	}
85	if got := adapter.GetTextureByURL("https://gitlab.com/group/project"); got != nil {
86		t.Fatal("path-specific favicon leaked across domains")
87	}
88}
89
90func TestFaviconAdapterGetOrFetchSkipsResolverForExactMemoryCacheHit(t *testing.T) {
91	resolver := portmocks.NewMockFaviconResolver(t)
92	db := portmocks.NewMockFaviconDatabase(t)
93	adapter := NewFaviconAdapterWithResolver(nil, resolver, db, FaviconAdapterConfig{})
94	texture := &gdk.Texture{}
95	pageURL := "https://github.com/bnema/gordon/pull/123"
96	adapter.setTexture("github.com/bnema/gordon/pull/123", texture)
97
98	var got *gdk.Texture
99	adapter.GetOrFetch(context.Background(), pageURL, func(received *gdk.Texture) {
100		got = received
101	})
102
103	if got != texture {
104		t.Fatal("expected exact cached texture")
105	}
106	db.AssertNotCalled(t, "GetFaviconAsync", mock.Anything, mock.Anything)
107}
108
109func TestFaviconAdapterGetOrFetchConsultsResolverBeforeFallbackMemoryCache(t *testing.T) {
110	resolver := portmocks.NewMockFaviconResolver(t)
111	adapter := NewFaviconAdapterWithResolver(nil, resolver, nil, FaviconAdapterConfig{})
112	fallbackTexture := &gdk.Texture{}
113	pageURL := "https://github.com/bnema/gordon/pull/123"
114	adapter.setTexture("github.com", fallbackTexture)
115	resolver.EXPECT().Resolve(
116		mock.Anything,
117		pageURL,
118		0,
119		port.FaviconResolveOptions{Purpose: port.FaviconResolvePurposeUI, ScheduleBackgroundRefresh: true},
120	).Return((*port.ResolvedFavicon)(nil), nil).Once()
121
122	var got *gdk.Texture
123	adapter.GetOrFetch(context.Background(), pageURL, func(received *gdk.Texture) {
124		got = received
125	})
126
127	if got != fallbackTexture {
128		t.Fatal("expected domain fallback texture after resolver miss")
129	}
130}
131
132func TestFaviconAdapterPreloadFromCacheConsultsResolverBeforeFallbackMemoryCache(t *testing.T) {
133	resolver := portmocks.NewMockFaviconResolver(t)
134	adapter := NewFaviconAdapterWithResolver(nil, resolver, nil, FaviconAdapterConfig{})
135	fallbackTexture := &gdk.Texture{}
136	pageURL := "https://github.com/bnema/gordon/pull/123"
137	adapter.setTexture("github.com", fallbackTexture)
138	resolver.EXPECT().Resolve(
139		mock.Anything,
140		pageURL,
141		0,
142		port.FaviconResolveOptions{Purpose: port.FaviconResolvePurposeUI},
143	).Return((*port.ResolvedFavicon)(nil), nil).Once()
144
145	if got := adapter.PreloadFromCache(context.Background(), pageURL); got != fallbackTexture {
146		t.Fatal("expected domain fallback texture after resolver preload miss")
147	}
148}
149
150func TestFaviconAdapterGetOrFetchProbesEngineAfterFallbackMemoryCacheHit(t *testing.T) {
151	resolver := portmocks.NewMockFaviconResolver(t)
152	db := portmocks.NewMockFaviconDatabase(t)
153	adapter := NewFaviconAdapterWithResolver(nil, resolver, db, FaviconAdapterConfig{})
154	fallbackTexture := &gdk.Texture{}
155	pageURL := "https://github.com/bnema/gordon/pull/123"
156	adapter.setTexture("github.com", fallbackTexture)
157	resolver.EXPECT().Resolve(
158		mock.Anything,
159		pageURL,
160		0,
161		port.FaviconResolveOptions{Purpose: port.FaviconResolvePurposeUI, ScheduleBackgroundRefresh: true},
162	).Return((*port.ResolvedFavicon)(nil), nil).Once()
163	db.EXPECT().GetFaviconAsync(pageURL, mock.Anything).Run(func(_ string, callback func(port.Texture)) {
164		callback(nil)
165	}).Return().Once()
166
167	var (
168		got   *gdk.Texture
169		calls int
170	)
171	adapter.GetOrFetch(context.Background(), pageURL, func(received *gdk.Texture) {
172		calls++
173		got = received
174	})
175
176	if got != fallbackTexture {
177		t.Fatal("expected fallback texture while probing exact engine favicon")
178	}
179	if calls != 1 {
180		t.Fatalf("expected single callback on engine miss, got %d", calls)
181	}
182}
183
184func TestFaviconAdapterStoreFromWebKitWithOriginDoesNotAliasSameDomainSpecificPaths(t *testing.T) {
185	resolver := portmocks.NewMockFaviconResolver(t)
186	resolver.EXPECT().Observe(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
187		Return((*favicon.Metadata)(nil), nil).
188		Maybe()
189	adapter := NewFaviconAdapterWithResolver(nil, resolver, nil, FaviconAdapterConfig{})
190	texture := &gdk.Texture{}
191
192	adapter.StoreFromWebKitWithOrigin(
193		context.Background(),
194		"https://github.com/login",
195		"https://github.com/bnema/gordon/pull/123",
196		texture,
197	)
198
199	if got := adapter.GetTextureByURL("https://github.com/login"); got != texture {
200		t.Fatal("expected current URL favicon to be cached")
201	}
202	if got := adapter.GetTextureByURL("https://github.com/bnema/gordon/pull/123"); got != nil {
203		t.Fatal("same-domain redirect aliased favicon onto unrelated exact path")
204	}
205	if got := adapter.GetTexture("github.com"); got != nil {
206		t.Fatal("same-domain redirect promoted favicon to bare domain")
207	}
208}
209
210func TestFaviconAdapterStoreFromWebKitWithOriginDoesNotAliasCrossDomainRedirectsInResolverMode(t *testing.T) {
211	resolver := portmocks.NewMockFaviconResolver(t)
212	resolver.EXPECT().Observe(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
213		Return((*favicon.Metadata)(nil), nil).
214		Maybe()
215	adapter := NewFaviconAdapterWithResolver(nil, resolver, nil, FaviconAdapterConfig{})
216	texture := &gdk.Texture{}
217	currentURL := "https://auth.example.net/challenge"
218	originURL := "https://github.com/bnema/gordon/pull/123"
219
220	adapter.StoreFromWebKitWithOrigin(context.Background(), currentURL, originURL, texture)
221
222	if got := adapter.GetTextureByURL(currentURL); got != texture {
223		t.Fatal("expected current redirected URL favicon to be cached")
224	}
225	if got := adapter.GetTextureByURL(originURL); got != nil {
226		t.Fatal("cross-domain redirect aliased favicon onto original exact path")
227	}
228	if got := adapter.GetTexture("github.com"); got != nil {
229		t.Fatal("cross-domain redirect promoted favicon to original domain")
230	}
231}
232
233func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) {
234	adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
235
236	first, suppressed := adapter.shouldLogWarningDedup("save-png:example.com")
237	if !first {
238		t.Fatalf("expected first warning to be logged")
239	}
240	if suppressed != 0 {
241		t.Fatalf("expected no suppressed count on first warning, got %d", suppressed)
242	}
243
244	first, suppressed = adapter.shouldLogWarningDedup("save-png:example.com")
245	if first {
246		t.Fatalf("expected repeated warning to be suppressed")
247	}
248	if suppressed != 1 {
249		t.Fatalf("expected suppressed count to be 1, got %d", suppressed)
250	}
251
252	first, suppressed = adapter.shouldLogWarningDedup("save-png:example.com")
253	if first {
254		t.Fatalf("expected repeated warning to remain suppressed")
255	}
256	if suppressed != 2 {
257		t.Fatalf("expected suppressed count to be 2, got %d", suppressed)
258	}
259}
260
261func TestFaviconWarningDedup_ClearResetsState(t *testing.T) {
262	adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
263	key := "sized-png:example.com"
264
265	first, _ := adapter.shouldLogWarningDedup(key)
266	if !first {
267		t.Fatalf("expected first warning to be logged")
268	}
269
270	adapter.clearWarningDedup(key)
271
272	first, suppressed := adapter.shouldLogWarningDedup(key)
273	if !first {
274		t.Fatalf("expected warning to log again after clear")
275	}
276	if suppressed != 0 {
277		t.Fatalf("expected suppressed count reset after clear, got %d", suppressed)
278	}
279}
280
281func TestFaviconWarningDedup_LogWarningDedupInvokesCallbackOnce(t *testing.T) {
282	adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
283	key := "save-png:example.com"
284	var calls atomic.Int32
285
286	adapter.logWarningDedup(t.Context(), key, nil, func(_ *zerolog.Logger, _ error) {
287		calls.Add(1)
288	})
289	adapter.logWarningDedup(t.Context(), key, nil, func(_ *zerolog.Logger, _ error) {
290		calls.Add(1)
291	})
292
293	if got := calls.Load(); got != 1 {
294		t.Fatalf("expected warning callback to run once, got %d", got)
295	}
296}

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