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_LoadAsync_ChecksForUpdatesAfterCacheLoadEvenWhenCacheIsFresh(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 mockStore := mocks.NewMockFilterStore(t)
233 mockDownloader := mocks.NewMockFilterDownloader(t)
234 updateChecked := make(chan struct{})
235
236 mockStore.EXPECT().
237 FetchIdentifiers(mock.Anything).
238 Return([]string{"ublock-combined-0"}, nil)
239 mockStore.EXPECT().
240 Load(mock.Anything, "ublock-combined-0").
241 Return(&webkit.UserContentFilter{}, nil)
242 mockDownloader.EXPECT().
243 GetCachedManifest().
244 Return(&filtering.Manifest{
245 Version: "2026.03.27",
246 Combined: filtering.CombinedInfo{
247 Files: []string{"combined-part1.json"},
248 },
249 }, nil)
250 mockDownloader.EXPECT().
251 NeedsUpdate(mock.Anything).
252 Run(func(_ context.Context) { close(updateChecked) }).
253 Return(false, nil).
254 Once()
255
256 mgr, err := filtering.NewManager(filtering.ManagerConfig{
257 StoreDir: storeDir,
258 JSONDir: jsonDir,
259 Enabled: true,
260 AutoUpdate: true,
261 Store: mockStore,
262 Downloader: mockDownloader,
263 })
264 require.NoError(t, err)
265
266 ctx := testContext()
267 require.NoError(t, mgr.Initialize(ctx))
268 mgr.LoadAsync(ctx)
269
270 require.Eventually(t, func() bool {
271 select {
272 case <-updateChecked:
273 return true
274 default:
275 return false
276 }
277 }, 3*time.Second, 50*time.Millisecond)
278}
279
280func TestManager_CheckForUpdates_DownloadsWhenNewVersionAvailable(t *testing.T) {
281 // Setup temp directories
282 tmpDir := t.TempDir()
283 storeDir := filepath.Join(tmpDir, "store")
284 jsonDir := filepath.Join(tmpDir, "json")
285
286 // Create test JSON files
287 testJSON := `[{"trigger":{"url-filter":"test"},"action":{"type":"block"}}]`
288 jsonFile := filepath.Join(jsonDir, "combined-part1.json")
289 require.NoError(t, os.MkdirAll(jsonDir, 0o755))
290 require.NoError(t, os.WriteFile(jsonFile, []byte(testJSON), 0o644))
291
292 // Create mocks
293 mockStore := mocks.NewMockFilterStore(t)
294 mockDownloader := mocks.NewMockFilterDownloader(t)
295
296 // NeedsUpdate returns true (new version available)
297 mockDownloader.EXPECT().
298 NeedsUpdate(mock.Anything).
299 Return(true, nil)
300
301 // Download triggered
302 mockDownloader.EXPECT().
303 DownloadFilters(mock.Anything, mock.Anything).
304 Return([]string{jsonFile}, nil)
305
306 // Compile called
307 mockStore.EXPECT().
308 Compile(mock.Anything, "ublock-combined-0", mock.Anything).
309 Return(&webkit.UserContentFilter{}, nil)
310
311 mockStore.EXPECT().
312 FetchIdentifiers(mock.Anything).
313 Return([]string{"ublock-combined-0"}, nil)
314
315 // GetCachedManifest for version
316 mockDownloader.EXPECT().
317 GetCachedManifest().
318 Return(&filtering.Manifest{Version: "2025.12.20"}, nil)
319
320 // Create manager with mocks
321 mgr, err := filtering.NewManager(filtering.ManagerConfig{
322 StoreDir: storeDir,
323 JSONDir: jsonDir,
324 Enabled: true,
325 AutoUpdate: true,
326 Store: mockStore,
327 Downloader: mockDownloader,
328 })
329 require.NoError(t, err)
330
331 // Track status changes
332 var statuses []filtering.FilterStatus
333 var mu sync.Mutex
334 mgr.SetStatusCallback(func(s filtering.FilterStatus) {
335 mu.Lock()
336 statuses = append(statuses, s)
337 mu.Unlock()
338 })
339
340 // Call CheckForUpdates
341 ctx := testContext()
342 err = mgr.CheckForUpdates(ctx)
343 require.NoError(t, err)
344
345 // Verify final status is active with new version
346 mu.Lock()
347 defer mu.Unlock()
348
349 if len(statuses) > 0 {
350 finalStatus := statuses[len(statuses)-1]
351 assert.Equal(t, filtering.StateActive, finalStatus.State)
352 assert.Equal(t, "2025.12.20", finalStatus.Version)
353 }
354}
355
356func TestManager_CheckForUpdates_PrunesObsoleteCompiledFiltersAfterSuccessfulUpdate(t *testing.T) {
357 tmpDir := t.TempDir()
358 storeDir := filepath.Join(tmpDir, "store")
359 jsonDir := filepath.Join(tmpDir, "json")
360
361 validJSON := `[{
362 "trigger":{"url-filter":"test"},
363 "action":{"type":"block"}
364 }]`
365 jsonFile1 := filepath.Join(jsonDir, "combined-part1.json")
366 jsonFile2 := filepath.Join(jsonDir, "combined-part2.json")
367 require.NoError(t, os.MkdirAll(jsonDir, 0o755))
368 require.NoError(t, os.WriteFile(jsonFile1, []byte(validJSON), 0o644))
369 require.NoError(t, os.WriteFile(jsonFile2, []byte(validJSON), 0o644))
370
371 mockStore := mocks.NewMockFilterStore(t)
372 mockDownloader := mocks.NewMockFilterDownloader(t)
373
374 mockDownloader.EXPECT().
375 NeedsUpdate(mock.Anything).
376 Return(true, nil)
377 mockDownloader.EXPECT().
378 DownloadFilters(mock.Anything, mock.Anything).
379 Return([]string{jsonFile1, jsonFile2}, nil)
380 mockStore.EXPECT().
381 Compile(mock.Anything, "ublock-combined-0", jsonFile1).
382 Return(&webkit.UserContentFilter{}, nil)
383 mockStore.EXPECT().
384 Compile(mock.Anything, "ublock-combined-1", jsonFile2).
385 Return(&webkit.UserContentFilter{}, nil)
386 mockDownloader.EXPECT().
387 GetCachedManifest().
388 Return(&filtering.Manifest{
389 Version: "2026.03.28",
390 Combined: filtering.CombinedInfo{
391 Files: []string{"combined-part1.json", "combined-part2.json"},
392 },
393 }, nil)
394 mockStore.EXPECT().
395 FetchIdentifiers(mock.Anything).
396 Return([]string{"ublock-combined", "ublock-combined-0", "ublock-combined-1", "ublock-combined-2", "other-filter"}, nil)
397 mockStore.EXPECT().
398 Remove(mock.Anything, "ublock-combined").
399 Return(nil)
400 mockStore.EXPECT().
401 Remove(mock.Anything, "ublock-combined-2").
402 Return(nil)
403
404 mgr, err := filtering.NewManager(filtering.ManagerConfig{
405 StoreDir: storeDir,
406 JSONDir: jsonDir,
407 Enabled: true,
408 AutoUpdate: true,
409 Store: mockStore,
410 Downloader: mockDownloader,
411 })
412 require.NoError(t, err)
413
414 ctx := testContext()
415 require.NoError(t, mgr.CheckForUpdates(ctx))
416}
417
418func TestManager_CheckForUpdates_SkipsWhenUpToDate(t *testing.T) {
419 // Setup temp directories
420 tmpDir := t.TempDir()
421 storeDir := filepath.Join(tmpDir, "store")
422 jsonDir := filepath.Join(tmpDir, "json")
423
424 // Create mocks
425 mockStore := mocks.NewMockFilterStore(t)
426 mockDownloader := mocks.NewMockFilterDownloader(t)
427
428 // NeedsUpdate returns false (already up to date)
429 mockDownloader.EXPECT().
430 NeedsUpdate(mock.Anything).
431 Return(false, nil)
432
433 // Download should NOT be called (we verify this via no expectation)
434
435 // Create manager with mocks
436 mgr, err := filtering.NewManager(filtering.ManagerConfig{
437 StoreDir: storeDir,
438 JSONDir: jsonDir,
439 Enabled: true,
440 AutoUpdate: true,
441 Store: mockStore,
442 Downloader: mockDownloader,
443 })
444 require.NoError(t, err)
445
446 // Call CheckForUpdates
447 ctx := testContext()
448 err = mgr.CheckForUpdates(ctx)
449 require.NoError(t, err)
450
451 // Mock expectations will verify DownloadFilters was not called
452}
453
454func TestManager_CheckForUpdates_SkipsWhenDisabled(t *testing.T) {
455 // Setup temp directories
456 tmpDir := t.TempDir()
457 storeDir := filepath.Join(tmpDir, "store")
458 jsonDir := filepath.Join(tmpDir, "json")
459
460 // Create mocks
461 mockStore := mocks.NewMockFilterStore(t)
462 mockDownloader := mocks.NewMockFilterDownloader(t)
463
464 // No expectations - nothing should be called when disabled
465
466 // Create manager with mocks - autoUpdate disabled
467 mgr, err := filtering.NewManager(filtering.ManagerConfig{
468 StoreDir: storeDir,
469 JSONDir: jsonDir,
470 Enabled: true,
471 AutoUpdate: false, // Disabled
472 Store: mockStore,
473 Downloader: mockDownloader,
474 })
475 require.NoError(t, err)
476
477 // Call CheckForUpdates
478 ctx := testContext()
479 err = mgr.CheckForUpdates(ctx)
480 require.NoError(t, err)
481
482 // Mock expectations will verify nothing was called
483}
484
485func TestManager_Clear_RemovesFilterAndCache(t *testing.T) {
486 // Setup temp directories
487 tmpDir := t.TempDir()
488 storeDir := filepath.Join(tmpDir, "store")
489 jsonDir := filepath.Join(tmpDir, "json")
490
491 // Create mocks
492 mockStore := mocks.NewMockFilterStore(t)
493 mockDownloader := mocks.NewMockFilterDownloader(t)
494
495 // FetchIdentifiers + Remove should be called for each part
496 mockStore.EXPECT().
497 FetchIdentifiers(mock.Anything).
498 Return([]string{"ublock-combined-0", "ublock-combined-1"}, nil)
499 mockStore.EXPECT().
500 Remove(mock.Anything, "ublock-combined-0").
501 Return(nil)
502 mockStore.EXPECT().
503 Remove(mock.Anything, "ublock-combined-1").
504 Return(nil)
505
506 // ClearCache should be called
507 mockDownloader.EXPECT().
508 ClearCache().
509 Return(nil)
510
511 // Create manager with mocks
512 mgr, err := filtering.NewManager(filtering.ManagerConfig{
513 StoreDir: storeDir,
514 JSONDir: jsonDir,
515 Enabled: true,
516 AutoUpdate: false,
517 Store: mockStore,
518 Downloader: mockDownloader,
519 })
520 require.NoError(t, err)
521
522 // Clear filters
523 ctx := testContext()
524 err = mgr.Clear(ctx)
525 require.NoError(t, err)
526
527 // Verify status is uninitialized
528 status := mgr.Status()
529 assert.Equal(t, filtering.StateUninitialized, status.State)
530}
531
532func TestManager_Initialize_DisabledByConfig(t *testing.T) {
533 // Setup temp directories
534 tmpDir := t.TempDir()
535 storeDir := filepath.Join(tmpDir, "store")
536 jsonDir := filepath.Join(tmpDir, "json")
537
538 // Create mocks (no expectations - nothing should be called)
539 mockStore := mocks.NewMockFilterStore(t)
540 mockDownloader := mocks.NewMockFilterDownloader(t)
541
542 // Create manager with filtering disabled
543 mgr, err := filtering.NewManager(filtering.ManagerConfig{
544 StoreDir: storeDir,
545 JSONDir: jsonDir,
546 Enabled: false, // Disabled
547 AutoUpdate: false,
548 Store: mockStore,
549 Downloader: mockDownloader,
550 })
551 require.NoError(t, err)
552
553 // Initialize
554 ctx := testContext()
555 err = mgr.Initialize(ctx)
556 require.NoError(t, err)
557
558 // Verify status is disabled
559 status := mgr.Status()
560 assert.Equal(t, filtering.StateDisabled, status.State)
561}
562
563func TestManager_StatusCallback_CalledOnStateChange(t *testing.T) {
564 // Setup temp directories
565 tmpDir := t.TempDir()
566 storeDir := filepath.Join(tmpDir, "store")
567 jsonDir := filepath.Join(tmpDir, "json")
568
569 // Create test JSON file
570 testJSON := `[{"trigger":{"url-filter":"test"},"action":{"type":"block"}}]`
571 jsonFile := filepath.Join(jsonDir, "combined-part1.json")
572 require.NoError(t, os.MkdirAll(jsonDir, 0o755))
573 require.NoError(t, os.WriteFile(jsonFile, []byte(testJSON), 0o644))
574
575 // Create mocks
576 mockStore := mocks.NewMockFilterStore(t)
577 mockDownloader := mocks.NewMockFilterDownloader(t)
578
579 // Setup for a load with fallback to download (since we can't return real filter)
580 mockStore.EXPECT().
581 FetchIdentifiers(mock.Anything).
582 Return([]string{"ublock-combined-0"}, nil).Once()
583 mockStore.EXPECT().
584 Load(mock.Anything, "ublock-combined-0").
585 Return(nil, nil) // nil triggers download fallback
586 mockDownloader.EXPECT().
587 GetCachedFilterPaths().
588 Return(nil)
589 mockDownloader.EXPECT().
590 DownloadFilters(mock.Anything, mock.Anything).
591 Return([]string{jsonFile}, nil)
592 mockStore.EXPECT().
593 Compile(mock.Anything, "ublock-combined-0", mock.Anything).
594 Return(&webkit.UserContentFilter{}, nil)
595 mockDownloader.EXPECT().
596 GetCachedManifest().
597 Return(&filtering.Manifest{Version: "test"}, nil)
598
599 // Create manager
600 mgr, err := filtering.NewManager(filtering.ManagerConfig{
601 StoreDir: storeDir,
602 JSONDir: jsonDir,
603 Enabled: true,
604 AutoUpdate: false,
605 Store: mockStore,
606 Downloader: mockDownloader,
607 })
608 require.NoError(t, err)
609
610 // Track callback invocations
611 callCount := 0
612 var mu sync.Mutex
613 mgr.SetStatusCallback(func(_ filtering.FilterStatus) {
614 mu.Lock()
615 callCount++
616 mu.Unlock()
617 })
618
619 // Initialize and load
620 ctx := testContext()
621 _ = mgr.Initialize(ctx)
622
623 done := make(chan struct{})
624 go func() {
625 mgr.LoadAsync(ctx)
626 close(done)
627 }()
628 <-done
629
630 time.Sleep(100 * time.Millisecond)
631
632 // Verify callback was called multiple times
633 mu.Lock()
634 defer mu.Unlock()
635 assert.GreaterOrEqual(t, callCount, 2, "Expected callback to be called at least twice")
636}
637
638func TestManager_LoadAsync_QuarantinesInvalidDownloadedPayload(t *testing.T) {
639 tmpDir := t.TempDir()
640 storeDir := filepath.Join(tmpDir, "store")
641 jsonDir := filepath.Join(tmpDir, "json")
642
643 invalidJSONFile := filepath.Join(jsonDir, "combined-part1.json")
644 require.NoError(t, os.MkdirAll(jsonDir, 0o755))
645 require.NoError(t, os.WriteFile(invalidJSONFile, []byte(`{"invalid":`), 0o644))
646
647 mockStore := mocks.NewMockFilterStore(t)
648 mockDownloader := mocks.NewMockFilterDownloader(t)
649
650 mockStore.EXPECT().
651 FetchIdentifiers(mock.Anything).
652 Return([]string{}, nil)
653 mockDownloader.EXPECT().
654 GetCachedFilterPaths().
655 Return(nil)
656
657 mockDownloader.EXPECT().
658 DownloadFilters(mock.Anything, mock.Anything).
659 Return([]string{invalidJSONFile}, nil)
660
661 mgr, err := filtering.NewManager(filtering.ManagerConfig{
662 StoreDir: storeDir,
663 JSONDir: jsonDir,
664 Enabled: true,
665 AutoUpdate: false,
666 Store: mockStore,
667 Downloader: mockDownloader,
668 })
669 require.NoError(t, err)
670
671 ctx := testContext()
672 require.NoError(t, mgr.Initialize(ctx))
673
674 mgr.LoadAsync(ctx)
675
676 require.Eventually(t, func() bool {
677 return mgr.Status().State == filtering.StateError
678 }, 3*time.Second, 50*time.Millisecond)
679 assert.Equal(t, "Invalid downloaded filters", mgr.Status().Message)
680
681 _, statErr := os.Stat(invalidJSONFile)
682 require.ErrorIs(t, statErr, os.ErrNotExist)
683
684 quarantineDir := filepath.Join(jsonDir, "quarantine")
685 entries, err := os.ReadDir(quarantineDir)
686 require.NoError(t, err)
687 require.Len(t, entries, 1)
688}
689
690func TestManager_CheckForUpdates_CompileFailureKeepsActiveFilter(t *testing.T) {
691 tmpDir := t.TempDir()
692 storeDir := filepath.Join(tmpDir, "store")
693 jsonDir := filepath.Join(tmpDir, "json")
694
695 validJSON := `[{"trigger":{"url-filter":"test"},"action":{"type":"block"}}]`
696 jsonFile := filepath.Join(jsonDir, "combined-part1.json")
697 require.NoError(t, os.MkdirAll(jsonDir, 0o755))
698 require.NoError(t, os.WriteFile(jsonFile, []byte(validJSON), 0o644))
699
700 mockStore := mocks.NewMockFilterStore(t)
701 mockDownloader := mocks.NewMockFilterDownloader(t)
702
703 activeFilter := &webkit.UserContentFilter{}
704
705 // loadFromCache succeeds — returns activeFilter
706 mockStore.EXPECT().
707 FetchIdentifiers(mock.Anything).
708 Return([]string{"ublock-combined-0"}, nil).Once()
709 mockStore.EXPECT().
710 Load(mock.Anything, "ublock-combined-0").
711 Return(activeFilter, nil).Once()
712 mockDownloader.EXPECT().
713 GetCachedManifest().
714 Return(&filtering.Manifest{Version: "2025.12.19"}, nil)
715
716 // Background CheckForUpdates after cache load should observe no update.
717 mockDownloader.EXPECT().
718 NeedsUpdate(mock.Anything).
719 Return(false, nil).Once()
720
721 // Explicit CheckForUpdates: compile fails, should keep active filter
722 mockDownloader.EXPECT().
723 NeedsUpdate(mock.Anything).
724 Return(true, nil).Once()
725 mockDownloader.EXPECT().
726 DownloadFilters(mock.Anything, mock.Anything).
727 Return([]string{jsonFile}, nil).Once()
728 mockStore.EXPECT().
729 Compile(mock.Anything, "ublock-combined-0", mock.Anything).
730 Return(nil, errors.New("compile failed")).Once()
731
732 mgr, err := filtering.NewManager(filtering.ManagerConfig{
733 StoreDir: storeDir,
734 JSONDir: jsonDir,
735 Enabled: true,
736 AutoUpdate: true,
737 Store: mockStore,
738 Downloader: mockDownloader,
739 })
740 require.NoError(t, err)
741
742 ctx := testContext()
743 require.NoError(t, mgr.Initialize(ctx))
744 mgr.LoadAsync(ctx)
745
746 require.Eventually(t, func() bool {
747 return len(mgr.GetFilters()) > 0 && mgr.Status().State == filtering.StateActive
748 }, 3*time.Second, 50*time.Millisecond)
749
750 err = mgr.CheckForUpdates(ctx)
751 require.ErrorIs(t, err, filtering.ErrUpdateSkipped)
752 require.Len(t, mgr.GetFilters(), 1)
753 assert.Same(t, activeFilter, mgr.GetFilters()[0])
754 assert.Equal(t, filtering.StateActive, mgr.Status().State)
755}