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