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