v0.29.0 21b3150

manager_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close