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}