v0.27.1 c78ce83

manager_test.go RawHistoryFolder

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}

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