v0.28.0 3cb3770

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

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