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