1package filtering_test
2
3import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "strings"
9 "sync"
10 "testing"
11 "time"
12
13 "github.com/bnema/dumber/internal/infrastructure/filtering"
14 "github.com/bnema/dumber/internal/infrastructure/filtering/mocks"
15 "github.com/bnema/dumber/internal/logging"
16 "github.com/bnema/puregotk-webkit/webkit"
17 "github.com/stretchr/testify/assert"
18 "github.com/stretchr/testify/mock"
19 "github.com/stretchr/testify/require"
20)
21
22func testContext() context.Context {
23 logger := logging.NewFromConfigValues("debug", "pretty")
24 return logging.WithContext(context.Background(), logger)
25}
26
27func TestManager_LoadAsync_ChecksCacheFirst(t *testing.T) {
28 // Setup temp directories
29 tmpDir := t.TempDir()
30 storeDir := filepath.Join(tmpDir, "store")
31 jsonDir := filepath.Join(tmpDir, "json")
32
33 // Create test JSON file for fallback download
34 testJSON := `[{"trigger":{"url-filter":"test"},"action":{"type":"block"}}]`
35 jsonFile := filepath.Join(jsonDir, "combined-part1.json")
36 require.NoError(t, os.MkdirAll(jsonDir, 0o755))
37 require.NoError(t, os.WriteFile(jsonFile, []byte(testJSON), 0o644))
38
39 // Create mocks
40 mockStore := mocks.NewMockFilterStore(t)
41 mockDownloader := mocks.NewMockFilterDownloader(t)
42
43 // Setup expectations: filter identifier exists in store but Load returns nil
44 // This verifies the cache check path is taken, then falls back to download
45 mockStore.EXPECT().
46 FetchIdentifiers(mock.Anything).
47 Return([]string{"ublock-combined-0"}, nil).Once()
48
49 mockStore.EXPECT().
50 Load(mock.Anything, "ublock-combined-0").
51 Return(nil, nil) // nil filter triggers fallback to download
52
53 // Fallback: download will be triggered since Load returned nil filter
54 mockDownloader.EXPECT().
55 DownloadFilters(mock.Anything, mock.Anything).
56 Return([]string{jsonFile}, nil)
57
58 mockStore.EXPECT().
59 Compile(mock.Anything, mock.MatchedBy(func(s string) bool { return strings.HasPrefix(s, filtering.FilterIdentifierPrefix) }), mock.Anything).
60 Return(&webkit.UserContentFilter{}, nil)
61
62 mockDownloader.EXPECT().
63 GetCachedManifest().
64 Return(&filtering.Manifest{Version: "2025.12.19"}, nil)
65
66 // Create manager with mocks
67 mgr, err := filtering.NewManager(filtering.ManagerConfig{
68 StoreDir: storeDir,
69 JSONDir: jsonDir,
70 Enabled: true,
71 AutoUpdate: false,
72 Store: mockStore,
73 Downloader: mockDownloader,
74 })
75 require.NoError(t, err)
76
77 // Track status changes
78 var statuses []filtering.FilterStatus
79 var mu sync.Mutex
80 mgr.SetStatusCallback(func(status filtering.FilterStatus) {
81 mu.Lock()
82 statuses = append(statuses, status)
83 mu.Unlock()
84 })
85
86 // Initialize and load
87 ctx := testContext()
88 err = mgr.Initialize(ctx)
89 require.NoError(t, err)
90
91 // Start async load and wait for completion
92 done := make(chan struct{})
93 go func() {
94 mgr.LoadAsync(ctx)
95 close(done)
96 }()
97
98 select {
99 case <-done:
100 // Success
101 case <-time.After(5 * time.Second):
102 t.Fatal("LoadAsync timed out")
103 }
104
105 // Give callbacks time to fire
106 time.Sleep(100 * time.Millisecond)
107
108 // Verify status progression
109 mu.Lock()
110 defer mu.Unlock()
111
112 // Should have loading states followed by active
113 require.GreaterOrEqual(t, len(statuses), 2, "Expected at least 2 status updates")
114
115 // Final status should be active
116 finalStatus := statuses[len(statuses)-1]
117 assert.Equal(t, filtering.StateActive, finalStatus.State)
118 assert.Equal(t, "2025.12.19", finalStatus.Version)
119}
120
121func TestManager_LoadAsync_DownloadsWhenCacheMiss(t *testing.T) {
122 // Setup temp directories
123 tmpDir := t.TempDir()
124 storeDir := filepath.Join(tmpDir, "store")
125 jsonDir := filepath.Join(tmpDir, "json")
126
127 // Create test JSON files
128 testJSON := `[{"trigger":{"url-filter":"test"},"action":{"type":"block"}}]`
129 jsonFile := filepath.Join(jsonDir, "combined-part1.json")
130 require.NoError(t, os.MkdirAll(jsonDir, 0o755))
131 require.NoError(t, os.WriteFile(jsonFile, []byte(testJSON), 0o644))
132
133 // Create mocks
134 mockStore := mocks.NewMockFilterStore(t)
135 mockDownloader := mocks.NewMockFilterDownloader(t)
136
137 // Setup expectations: no filter in store
138 mockStore.EXPECT().
139 FetchIdentifiers(mock.Anything).
140 Return([]string{}, nil)
141
142 // Download should be triggered
143 mockDownloader.EXPECT().
144 DownloadFilters(mock.Anything, mock.Anything).
145 Return([]string{jsonFile}, nil)
146
147 // Compile should be called
148 mockStore.EXPECT().
149 Compile(mock.Anything, "ublock-combined-0", mock.Anything).
150 Return(&webkit.UserContentFilter{}, nil)
151
152 // GetCachedManifest for version
153 mockDownloader.EXPECT().
154 GetCachedManifest().
155 Return(&filtering.Manifest{Version: "2025.12.19"}, nil)
156
157 // Create manager with mocks
158 mgr, err := filtering.NewManager(filtering.ManagerConfig{
159 StoreDir: storeDir,
160 JSONDir: jsonDir,
161 Enabled: true,
162 AutoUpdate: false,
163 Store: mockStore,
164 Downloader: mockDownloader,
165 })
166 require.NoError(t, err)
167
168 // Track status changes
169 var statuses []filtering.FilterStatus
170 var mu sync.Mutex
171 mgr.SetStatusCallback(func(status filtering.FilterStatus) {
172 mu.Lock()
173 statuses = append(statuses, status)
174 mu.Unlock()
175 })
176
177 // Initialize and load
178 ctx := testContext()
179 err = mgr.Initialize(ctx)
180 require.NoError(t, err)
181
182 // Start async load and wait for completion
183 done := make(chan struct{})
184 go func() {
185 mgr.LoadAsync(ctx)
186 close(done)
187 }()
188
189 select {
190 case <-done:
191 // Success
192 case <-time.After(5 * time.Second):
193 t.Fatal("LoadAsync timed out")
194 }
195
196 // Give callbacks time to fire
197 time.Sleep(100 * time.Millisecond)
198
199 // Verify status progression
200 mu.Lock()
201 defer mu.Unlock()
202
203 // Should have loading states followed by active
204 foundLoading := false
205 foundActive := false
206 for _, s := range statuses {
207 if s.State == filtering.StateLoading {
208 foundLoading = true
209 }
210 if s.State == filtering.StateActive {
211 foundActive = true
212 }
213 }
214 assert.True(t, foundLoading, "Expected loading state")
215 assert.True(t, foundActive, "Expected active state")
216}
217
218func TestManager_CheckForUpdates_DownloadsWhenNewVersionAvailable(t *testing.T) {
219 // Setup temp directories
220 tmpDir := t.TempDir()
221 storeDir := filepath.Join(tmpDir, "store")
222 jsonDir := filepath.Join(tmpDir, "json")
223
224 // Create test JSON files
225 testJSON := `[{"trigger":{"url-filter":"test"},"action":{"type":"block"}}]`
226 jsonFile := filepath.Join(jsonDir, "combined-part1.json")
227 require.NoError(t, os.MkdirAll(jsonDir, 0o755))
228 require.NoError(t, os.WriteFile(jsonFile, []byte(testJSON), 0o644))
229
230 // Create mocks
231 mockStore := mocks.NewMockFilterStore(t)
232 mockDownloader := mocks.NewMockFilterDownloader(t)
233
234 // NeedsUpdate returns true (new version available)
235 mockDownloader.EXPECT().
236 NeedsUpdate(mock.Anything).
237 Return(true, nil)
238
239 // Download triggered
240 mockDownloader.EXPECT().
241 DownloadFilters(mock.Anything, mock.Anything).
242 Return([]string{jsonFile}, nil)
243
244 // Compile called
245 mockStore.EXPECT().
246 Compile(mock.Anything, "ublock-combined-0", mock.Anything).
247 Return(&webkit.UserContentFilter{}, nil)
248
249 // GetCachedManifest for version
250 mockDownloader.EXPECT().
251 GetCachedManifest().
252 Return(&filtering.Manifest{Version: "2025.12.20"}, nil)
253
254 // Create manager with mocks
255 mgr, err := filtering.NewManager(filtering.ManagerConfig{
256 StoreDir: storeDir,
257 JSONDir: jsonDir,
258 Enabled: true,
259 AutoUpdate: true,
260 Store: mockStore,
261 Downloader: mockDownloader,
262 })
263 require.NoError(t, err)
264
265 // Track status changes
266 var statuses []filtering.FilterStatus
267 var mu sync.Mutex
268 mgr.SetStatusCallback(func(s filtering.FilterStatus) {
269 mu.Lock()
270 statuses = append(statuses, s)
271 mu.Unlock()
272 })
273
274 // Call CheckForUpdates
275 ctx := testContext()
276 err = mgr.CheckForUpdates(ctx)
277 require.NoError(t, err)
278
279 // Verify final status is active with new version
280 mu.Lock()
281 defer mu.Unlock()
282
283 if len(statuses) > 0 {
284 finalStatus := statuses[len(statuses)-1]
285 assert.Equal(t, filtering.StateActive, finalStatus.State)
286 assert.Equal(t, "2025.12.20", finalStatus.Version)
287 }
288}
289
290func TestManager_CheckForUpdates_SkipsWhenUpToDate(t *testing.T) {
291 // Setup temp directories
292 tmpDir := t.TempDir()
293 storeDir := filepath.Join(tmpDir, "store")
294 jsonDir := filepath.Join(tmpDir, "json")
295
296 // Create mocks
297 mockStore := mocks.NewMockFilterStore(t)
298 mockDownloader := mocks.NewMockFilterDownloader(t)
299
300 // NeedsUpdate returns false (already up to date)
301 mockDownloader.EXPECT().
302 NeedsUpdate(mock.Anything).
303 Return(false, nil)
304
305 // Download should NOT be called (we verify this via no expectation)
306
307 // Create manager with mocks
308 mgr, err := filtering.NewManager(filtering.ManagerConfig{
309 StoreDir: storeDir,
310 JSONDir: jsonDir,
311 Enabled: true,
312 AutoUpdate: true,
313 Store: mockStore,
314 Downloader: mockDownloader,
315 })
316 require.NoError(t, err)
317
318 // Call CheckForUpdates
319 ctx := testContext()
320 err = mgr.CheckForUpdates(ctx)
321 require.NoError(t, err)
322
323 // Mock expectations will verify DownloadFilters was not called
324}
325
326func TestManager_CheckForUpdates_SkipsWhenDisabled(t *testing.T) {
327 // Setup temp directories
328 tmpDir := t.TempDir()
329 storeDir := filepath.Join(tmpDir, "store")
330 jsonDir := filepath.Join(tmpDir, "json")
331
332 // Create mocks
333 mockStore := mocks.NewMockFilterStore(t)
334 mockDownloader := mocks.NewMockFilterDownloader(t)
335
336 // No expectations - nothing should be called when disabled
337
338 // Create manager with mocks - autoUpdate disabled
339 mgr, err := filtering.NewManager(filtering.ManagerConfig{
340 StoreDir: storeDir,
341 JSONDir: jsonDir,
342 Enabled: true,
343 AutoUpdate: false, // Disabled
344 Store: mockStore,
345 Downloader: mockDownloader,
346 })
347 require.NoError(t, err)
348
349 // Call CheckForUpdates
350 ctx := testContext()
351 err = mgr.CheckForUpdates(ctx)
352 require.NoError(t, err)
353
354 // Mock expectations will verify nothing was called
355}
356
357func TestManager_Clear_RemovesFilterAndCache(t *testing.T) {
358 // Setup temp directories
359 tmpDir := t.TempDir()
360 storeDir := filepath.Join(tmpDir, "store")
361 jsonDir := filepath.Join(tmpDir, "json")
362
363 // Create mocks
364 mockStore := mocks.NewMockFilterStore(t)
365 mockDownloader := mocks.NewMockFilterDownloader(t)
366
367 // FetchIdentifiers + Remove should be called for each part
368 mockStore.EXPECT().
369 FetchIdentifiers(mock.Anything).
370 Return([]string{"ublock-combined-0", "ublock-combined-1"}, nil)
371 mockStore.EXPECT().
372 Remove(mock.Anything, "ublock-combined-0").
373 Return(nil)
374 mockStore.EXPECT().
375 Remove(mock.Anything, "ublock-combined-1").
376 Return(nil)
377
378 // ClearCache should be called
379 mockDownloader.EXPECT().
380 ClearCache().
381 Return(nil)
382
383 // Create manager with mocks
384 mgr, err := filtering.NewManager(filtering.ManagerConfig{
385 StoreDir: storeDir,
386 JSONDir: jsonDir,
387 Enabled: true,
388 AutoUpdate: false,
389 Store: mockStore,
390 Downloader: mockDownloader,
391 })
392 require.NoError(t, err)
393
394 // Clear filters
395 ctx := testContext()
396 err = mgr.Clear(ctx)
397 require.NoError(t, err)
398
399 // Verify status is uninitialized
400 status := mgr.Status()
401 assert.Equal(t, filtering.StateUninitialized, status.State)
402}
403
404func TestManager_Initialize_DisabledByConfig(t *testing.T) {
405 // Setup temp directories
406 tmpDir := t.TempDir()
407 storeDir := filepath.Join(tmpDir, "store")
408 jsonDir := filepath.Join(tmpDir, "json")
409
410 // Create mocks (no expectations - nothing should be called)
411 mockStore := mocks.NewMockFilterStore(t)
412 mockDownloader := mocks.NewMockFilterDownloader(t)
413
414 // Create manager with filtering disabled
415 mgr, err := filtering.NewManager(filtering.ManagerConfig{
416 StoreDir: storeDir,
417 JSONDir: jsonDir,
418 Enabled: false, // Disabled
419 AutoUpdate: false,
420 Store: mockStore,
421 Downloader: mockDownloader,
422 })
423 require.NoError(t, err)
424
425 // Initialize
426 ctx := testContext()
427 err = mgr.Initialize(ctx)
428 require.NoError(t, err)
429
430 // Verify status is disabled
431 status := mgr.Status()
432 assert.Equal(t, filtering.StateDisabled, status.State)
433}
434
435func TestManager_StatusCallback_CalledOnStateChange(t *testing.T) {
436 // Setup temp directories
437 tmpDir := t.TempDir()
438 storeDir := filepath.Join(tmpDir, "store")
439 jsonDir := filepath.Join(tmpDir, "json")
440
441 // Create test JSON file
442 testJSON := `[{"trigger":{"url-filter":"test"},"action":{"type":"block"}}]`
443 jsonFile := filepath.Join(jsonDir, "combined-part1.json")
444 require.NoError(t, os.MkdirAll(jsonDir, 0o755))
445 require.NoError(t, os.WriteFile(jsonFile, []byte(testJSON), 0o644))
446
447 // Create mocks
448 mockStore := mocks.NewMockFilterStore(t)
449 mockDownloader := mocks.NewMockFilterDownloader(t)
450
451 // Setup for a load with fallback to download (since we can't return real filter)
452 mockStore.EXPECT().
453 FetchIdentifiers(mock.Anything).
454 Return([]string{"ublock-combined-0"}, nil).Once()
455 mockStore.EXPECT().
456 Load(mock.Anything, "ublock-combined-0").
457 Return(nil, nil) // nil triggers download fallback
458 mockDownloader.EXPECT().
459 DownloadFilters(mock.Anything, mock.Anything).
460 Return([]string{jsonFile}, nil)
461 mockStore.EXPECT().
462 Compile(mock.Anything, "ublock-combined-0", mock.Anything).
463 Return(&webkit.UserContentFilter{}, nil)
464 mockDownloader.EXPECT().
465 GetCachedManifest().
466 Return(&filtering.Manifest{Version: "test"}, nil)
467
468 // Create manager
469 mgr, err := filtering.NewManager(filtering.ManagerConfig{
470 StoreDir: storeDir,
471 JSONDir: jsonDir,
472 Enabled: true,
473 AutoUpdate: false,
474 Store: mockStore,
475 Downloader: mockDownloader,
476 })
477 require.NoError(t, err)
478
479 // Track callback invocations
480 callCount := 0
481 var mu sync.Mutex
482 mgr.SetStatusCallback(func(_ filtering.FilterStatus) {
483 mu.Lock()
484 callCount++
485 mu.Unlock()
486 })
487
488 // Initialize and load
489 ctx := testContext()
490 _ = mgr.Initialize(ctx)
491
492 done := make(chan struct{})
493 go func() {
494 mgr.LoadAsync(ctx)
495 close(done)
496 }()
497 <-done
498
499 time.Sleep(100 * time.Millisecond)
500
501 // Verify callback was called multiple times
502 mu.Lock()
503 defer mu.Unlock()
504 assert.GreaterOrEqual(t, callCount, 2, "Expected callback to be called at least twice")
505}
506
507func TestManager_LoadAsync_QuarantinesInvalidDownloadedPayload(t *testing.T) {
508 tmpDir := t.TempDir()
509 storeDir := filepath.Join(tmpDir, "store")
510 jsonDir := filepath.Join(tmpDir, "json")
511
512 invalidJSONFile := filepath.Join(jsonDir, "combined-part1.json")
513 require.NoError(t, os.MkdirAll(jsonDir, 0o755))
514 require.NoError(t, os.WriteFile(invalidJSONFile, []byte(`{"invalid":`), 0o644))
515
516 mockStore := mocks.NewMockFilterStore(t)
517 mockDownloader := mocks.NewMockFilterDownloader(t)
518
519 mockStore.EXPECT().
520 FetchIdentifiers(mock.Anything).
521 Return([]string{}, nil)
522
523 mockDownloader.EXPECT().
524 DownloadFilters(mock.Anything, mock.Anything).
525 Return([]string{invalidJSONFile}, nil)
526
527 mgr, err := filtering.NewManager(filtering.ManagerConfig{
528 StoreDir: storeDir,
529 JSONDir: jsonDir,
530 Enabled: true,
531 AutoUpdate: false,
532 Store: mockStore,
533 Downloader: mockDownloader,
534 })
535 require.NoError(t, err)
536
537 ctx := testContext()
538 require.NoError(t, mgr.Initialize(ctx))
539
540 mgr.LoadAsync(ctx)
541
542 require.Eventually(t, func() bool {
543 return mgr.Status().State == filtering.StateError
544 }, 3*time.Second, 50*time.Millisecond)
545 assert.Equal(t, "Invalid downloaded filters", mgr.Status().Message)
546
547 _, statErr := os.Stat(invalidJSONFile)
548 require.ErrorIs(t, statErr, os.ErrNotExist)
549
550 quarantineDir := filepath.Join(jsonDir, "quarantine")
551 entries, err := os.ReadDir(quarantineDir)
552 require.NoError(t, err)
553 require.Len(t, entries, 1)
554}
555
556func TestManager_CheckForUpdates_CompileFailureKeepsActiveFilter(t *testing.T) {
557 tmpDir := t.TempDir()
558 storeDir := filepath.Join(tmpDir, "store")
559 jsonDir := filepath.Join(tmpDir, "json")
560
561 validJSON := `[{"trigger":{"url-filter":"test"},"action":{"type":"block"}}]`
562 jsonFile := filepath.Join(jsonDir, "combined-part1.json")
563 require.NoError(t, os.MkdirAll(jsonDir, 0o755))
564 require.NoError(t, os.WriteFile(jsonFile, []byte(validJSON), 0o644))
565
566 mockStore := mocks.NewMockFilterStore(t)
567 mockDownloader := mocks.NewMockFilterDownloader(t)
568
569 activeFilter := &webkit.UserContentFilter{}
570
571 // loadFromCache succeeds — returns activeFilter
572 mockStore.EXPECT().
573 FetchIdentifiers(mock.Anything).
574 Return([]string{"ublock-combined-0"}, nil).Once()
575 mockStore.EXPECT().
576 Load(mock.Anything, "ublock-combined-0").
577 Return(activeFilter, nil).Once()
578 mockDownloader.EXPECT().
579 GetCachedManifest().
580 Return(&filtering.Manifest{Version: "2025.12.19"}, nil)
581 mockDownloader.EXPECT().
582 IsCacheStale(filtering.CacheMaxAge).
583 Return(false).Once()
584
585 // CheckForUpdates: compile fails, should keep active filter
586 mockDownloader.EXPECT().
587 NeedsUpdate(mock.Anything).
588 Return(true, nil).Once()
589 mockDownloader.EXPECT().
590 DownloadFilters(mock.Anything, mock.Anything).
591 Return([]string{jsonFile}, nil).Once()
592 mockStore.EXPECT().
593 Compile(mock.Anything, "ublock-combined-0", mock.Anything).
594 Return(nil, errors.New("compile failed")).Once()
595
596 mgr, err := filtering.NewManager(filtering.ManagerConfig{
597 StoreDir: storeDir,
598 JSONDir: jsonDir,
599 Enabled: true,
600 AutoUpdate: true,
601 Store: mockStore,
602 Downloader: mockDownloader,
603 })
604 require.NoError(t, err)
605
606 ctx := testContext()
607 require.NoError(t, mgr.Initialize(ctx))
608 mgr.LoadAsync(ctx)
609
610 require.Eventually(t, func() bool {
611 return len(mgr.GetFilters()) > 0 && mgr.Status().State == filtering.StateActive
612 }, 3*time.Second, 50*time.Millisecond)
613
614 err = mgr.CheckForUpdates(ctx)
615 require.ErrorIs(t, err, filtering.ErrUpdateSkipped)
616 require.Len(t, mgr.GetFilters(), 1)
617 assert.Same(t, activeFilter, mgr.GetFilters()[0])
618 assert.Equal(t, filtering.StateActive, mgr.Status().State)
619}