v0.33.0 62a09f5

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_LoadAsync_ChecksForUpdatesAfterCacheLoadEvenWhenCacheIsFresh(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	mockStore := mocks.NewMockFilterStore(t)
233	mockDownloader := mocks.NewMockFilterDownloader(t)
234	updateChecked := make(chan struct{})
235
236	mockStore.EXPECT().
237		FetchIdentifiers(mock.Anything).
238		Return([]string{"ublock-combined-0"}, nil)
239	mockStore.EXPECT().
240		Load(mock.Anything, "ublock-combined-0").
241		Return(&webkit.UserContentFilter{}, nil)
242	mockDownloader.EXPECT().
243		GetCachedManifest().
244		Return(&filtering.Manifest{
245			Version: "2026.03.27",
246			Combined: filtering.CombinedInfo{
247				Files: []string{"combined-part1.json"},
248			},
249		}, nil)
250	mockDownloader.EXPECT().
251		NeedsUpdate(mock.Anything).
252		Run(func(_ context.Context) { close(updateChecked) }).
253		Return(false, nil).
254		Once()
255
256	mgr, err := filtering.NewManager(filtering.ManagerConfig{
257		StoreDir:   storeDir,
258		JSONDir:    jsonDir,
259		Enabled:    true,
260		AutoUpdate: true,
261		Store:      mockStore,
262		Downloader: mockDownloader,
263	})
264	require.NoError(t, err)
265
266	ctx := testContext()
267	require.NoError(t, mgr.Initialize(ctx))
268	mgr.LoadAsync(ctx)
269
270	require.Eventually(t, func() bool {
271		select {
272		case <-updateChecked:
273			return true
274		default:
275			return false
276		}
277	}, 3*time.Second, 50*time.Millisecond)
278}
279
280func TestManager_CheckForUpdates_DownloadsWhenNewVersionAvailable(t *testing.T) {
281	// Setup temp directories
282	tmpDir := t.TempDir()
283	storeDir := filepath.Join(tmpDir, "store")
284	jsonDir := filepath.Join(tmpDir, "json")
285
286	// Create test JSON files
287	testJSON := `[{"trigger":{"url-filter":"test"},"action":{"type":"block"}}]`
288	jsonFile := filepath.Join(jsonDir, "combined-part1.json")
289	require.NoError(t, os.MkdirAll(jsonDir, 0o755))
290	require.NoError(t, os.WriteFile(jsonFile, []byte(testJSON), 0o644))
291
292	// Create mocks
293	mockStore := mocks.NewMockFilterStore(t)
294	mockDownloader := mocks.NewMockFilterDownloader(t)
295
296	// NeedsUpdate returns true (new version available)
297	mockDownloader.EXPECT().
298		NeedsUpdate(mock.Anything).
299		Return(true, nil)
300
301	// Download triggered
302	mockDownloader.EXPECT().
303		DownloadFilters(mock.Anything, mock.Anything).
304		Return([]string{jsonFile}, nil)
305
306	// Compile called
307	mockStore.EXPECT().
308		Compile(mock.Anything, "ublock-combined-0", mock.Anything).
309		Return(&webkit.UserContentFilter{}, nil)
310
311	mockStore.EXPECT().
312		FetchIdentifiers(mock.Anything).
313		Return([]string{"ublock-combined-0"}, nil)
314
315	// GetCachedManifest for version
316	mockDownloader.EXPECT().
317		GetCachedManifest().
318		Return(&filtering.Manifest{Version: "2025.12.20"}, nil)
319
320	// Create manager with mocks
321	mgr, err := filtering.NewManager(filtering.ManagerConfig{
322		StoreDir:   storeDir,
323		JSONDir:    jsonDir,
324		Enabled:    true,
325		AutoUpdate: true,
326		Store:      mockStore,
327		Downloader: mockDownloader,
328	})
329	require.NoError(t, err)
330
331	// Track status changes
332	var statuses []filtering.FilterStatus
333	var mu sync.Mutex
334	mgr.SetStatusCallback(func(s filtering.FilterStatus) {
335		mu.Lock()
336		statuses = append(statuses, s)
337		mu.Unlock()
338	})
339
340	// Call CheckForUpdates
341	ctx := testContext()
342	err = mgr.CheckForUpdates(ctx)
343	require.NoError(t, err)
344
345	// Verify final status is active with new version
346	mu.Lock()
347	defer mu.Unlock()
348
349	if len(statuses) > 0 {
350		finalStatus := statuses[len(statuses)-1]
351		assert.Equal(t, filtering.StateActive, finalStatus.State)
352		assert.Equal(t, "2025.12.20", finalStatus.Version)
353	}
354}
355
356func TestManager_CheckForUpdates_PrunesObsoleteCompiledFiltersAfterSuccessfulUpdate(t *testing.T) {
357	tmpDir := t.TempDir()
358	storeDir := filepath.Join(tmpDir, "store")
359	jsonDir := filepath.Join(tmpDir, "json")
360
361	validJSON := `[{
362		"trigger":{"url-filter":"test"},
363		"action":{"type":"block"}
364	}]`
365	jsonFile1 := filepath.Join(jsonDir, "combined-part1.json")
366	jsonFile2 := filepath.Join(jsonDir, "combined-part2.json")
367	require.NoError(t, os.MkdirAll(jsonDir, 0o755))
368	require.NoError(t, os.WriteFile(jsonFile1, []byte(validJSON), 0o644))
369	require.NoError(t, os.WriteFile(jsonFile2, []byte(validJSON), 0o644))
370
371	mockStore := mocks.NewMockFilterStore(t)
372	mockDownloader := mocks.NewMockFilterDownloader(t)
373
374	mockDownloader.EXPECT().
375		NeedsUpdate(mock.Anything).
376		Return(true, nil)
377	mockDownloader.EXPECT().
378		DownloadFilters(mock.Anything, mock.Anything).
379		Return([]string{jsonFile1, jsonFile2}, nil)
380	mockStore.EXPECT().
381		Compile(mock.Anything, "ublock-combined-0", jsonFile1).
382		Return(&webkit.UserContentFilter{}, nil)
383	mockStore.EXPECT().
384		Compile(mock.Anything, "ublock-combined-1", jsonFile2).
385		Return(&webkit.UserContentFilter{}, nil)
386	mockDownloader.EXPECT().
387		GetCachedManifest().
388		Return(&filtering.Manifest{
389			Version: "2026.03.28",
390			Combined: filtering.CombinedInfo{
391				Files: []string{"combined-part1.json", "combined-part2.json"},
392			},
393		}, nil)
394	mockStore.EXPECT().
395		FetchIdentifiers(mock.Anything).
396		Return([]string{"ublock-combined", "ublock-combined-0", "ublock-combined-1", "ublock-combined-2", "other-filter"}, nil)
397	mockStore.EXPECT().
398		Remove(mock.Anything, "ublock-combined").
399		Return(nil)
400	mockStore.EXPECT().
401		Remove(mock.Anything, "ublock-combined-2").
402		Return(nil)
403
404	mgr, err := filtering.NewManager(filtering.ManagerConfig{
405		StoreDir:   storeDir,
406		JSONDir:    jsonDir,
407		Enabled:    true,
408		AutoUpdate: true,
409		Store:      mockStore,
410		Downloader: mockDownloader,
411	})
412	require.NoError(t, err)
413
414	ctx := testContext()
415	require.NoError(t, mgr.CheckForUpdates(ctx))
416}
417
418func TestManager_CheckForUpdates_SkipsWhenUpToDate(t *testing.T) {
419	// Setup temp directories
420	tmpDir := t.TempDir()
421	storeDir := filepath.Join(tmpDir, "store")
422	jsonDir := filepath.Join(tmpDir, "json")
423
424	// Create mocks
425	mockStore := mocks.NewMockFilterStore(t)
426	mockDownloader := mocks.NewMockFilterDownloader(t)
427
428	// NeedsUpdate returns false (already up to date)
429	mockDownloader.EXPECT().
430		NeedsUpdate(mock.Anything).
431		Return(false, nil)
432
433	// Download should NOT be called (we verify this via no expectation)
434
435	// Create manager with mocks
436	mgr, err := filtering.NewManager(filtering.ManagerConfig{
437		StoreDir:   storeDir,
438		JSONDir:    jsonDir,
439		Enabled:    true,
440		AutoUpdate: true,
441		Store:      mockStore,
442		Downloader: mockDownloader,
443	})
444	require.NoError(t, err)
445
446	// Call CheckForUpdates
447	ctx := testContext()
448	err = mgr.CheckForUpdates(ctx)
449	require.NoError(t, err)
450
451	// Mock expectations will verify DownloadFilters was not called
452}
453
454func TestManager_CheckForUpdates_SkipsWhenDisabled(t *testing.T) {
455	// Setup temp directories
456	tmpDir := t.TempDir()
457	storeDir := filepath.Join(tmpDir, "store")
458	jsonDir := filepath.Join(tmpDir, "json")
459
460	// Create mocks
461	mockStore := mocks.NewMockFilterStore(t)
462	mockDownloader := mocks.NewMockFilterDownloader(t)
463
464	// No expectations - nothing should be called when disabled
465
466	// Create manager with mocks - autoUpdate disabled
467	mgr, err := filtering.NewManager(filtering.ManagerConfig{
468		StoreDir:   storeDir,
469		JSONDir:    jsonDir,
470		Enabled:    true,
471		AutoUpdate: false, // Disabled
472		Store:      mockStore,
473		Downloader: mockDownloader,
474	})
475	require.NoError(t, err)
476
477	// Call CheckForUpdates
478	ctx := testContext()
479	err = mgr.CheckForUpdates(ctx)
480	require.NoError(t, err)
481
482	// Mock expectations will verify nothing was called
483}
484
485func TestManager_Clear_RemovesFilterAndCache(t *testing.T) {
486	// Setup temp directories
487	tmpDir := t.TempDir()
488	storeDir := filepath.Join(tmpDir, "store")
489	jsonDir := filepath.Join(tmpDir, "json")
490
491	// Create mocks
492	mockStore := mocks.NewMockFilterStore(t)
493	mockDownloader := mocks.NewMockFilterDownloader(t)
494
495	// FetchIdentifiers + Remove should be called for each part
496	mockStore.EXPECT().
497		FetchIdentifiers(mock.Anything).
498		Return([]string{"ublock-combined-0", "ublock-combined-1"}, nil)
499	mockStore.EXPECT().
500		Remove(mock.Anything, "ublock-combined-0").
501		Return(nil)
502	mockStore.EXPECT().
503		Remove(mock.Anything, "ublock-combined-1").
504		Return(nil)
505
506	// ClearCache should be called
507	mockDownloader.EXPECT().
508		ClearCache().
509		Return(nil)
510
511	// Create manager with mocks
512	mgr, err := filtering.NewManager(filtering.ManagerConfig{
513		StoreDir:   storeDir,
514		JSONDir:    jsonDir,
515		Enabled:    true,
516		AutoUpdate: false,
517		Store:      mockStore,
518		Downloader: mockDownloader,
519	})
520	require.NoError(t, err)
521
522	// Clear filters
523	ctx := testContext()
524	err = mgr.Clear(ctx)
525	require.NoError(t, err)
526
527	// Verify status is uninitialized
528	status := mgr.Status()
529	assert.Equal(t, filtering.StateUninitialized, status.State)
530}
531
532func TestManager_Initialize_DisabledByConfig(t *testing.T) {
533	// Setup temp directories
534	tmpDir := t.TempDir()
535	storeDir := filepath.Join(tmpDir, "store")
536	jsonDir := filepath.Join(tmpDir, "json")
537
538	// Create mocks (no expectations - nothing should be called)
539	mockStore := mocks.NewMockFilterStore(t)
540	mockDownloader := mocks.NewMockFilterDownloader(t)
541
542	// Create manager with filtering disabled
543	mgr, err := filtering.NewManager(filtering.ManagerConfig{
544		StoreDir:   storeDir,
545		JSONDir:    jsonDir,
546		Enabled:    false, // Disabled
547		AutoUpdate: false,
548		Store:      mockStore,
549		Downloader: mockDownloader,
550	})
551	require.NoError(t, err)
552
553	// Initialize
554	ctx := testContext()
555	err = mgr.Initialize(ctx)
556	require.NoError(t, err)
557
558	// Verify status is disabled
559	status := mgr.Status()
560	assert.Equal(t, filtering.StateDisabled, status.State)
561}
562
563func TestManager_StatusCallback_CalledOnStateChange(t *testing.T) {
564	// Setup temp directories
565	tmpDir := t.TempDir()
566	storeDir := filepath.Join(tmpDir, "store")
567	jsonDir := filepath.Join(tmpDir, "json")
568
569	// Create test JSON file
570	testJSON := `[{"trigger":{"url-filter":"test"},"action":{"type":"block"}}]`
571	jsonFile := filepath.Join(jsonDir, "combined-part1.json")
572	require.NoError(t, os.MkdirAll(jsonDir, 0o755))
573	require.NoError(t, os.WriteFile(jsonFile, []byte(testJSON), 0o644))
574
575	// Create mocks
576	mockStore := mocks.NewMockFilterStore(t)
577	mockDownloader := mocks.NewMockFilterDownloader(t)
578
579	// Setup for a load with fallback to download (since we can't return real filter)
580	mockStore.EXPECT().
581		FetchIdentifiers(mock.Anything).
582		Return([]string{"ublock-combined-0"}, nil).Once()
583	mockStore.EXPECT().
584		Load(mock.Anything, "ublock-combined-0").
585		Return(nil, nil) // nil triggers download fallback
586	mockDownloader.EXPECT().
587		GetCachedFilterPaths().
588		Return(nil)
589	mockDownloader.EXPECT().
590		DownloadFilters(mock.Anything, mock.Anything).
591		Return([]string{jsonFile}, nil)
592	mockStore.EXPECT().
593		Compile(mock.Anything, "ublock-combined-0", mock.Anything).
594		Return(&webkit.UserContentFilter{}, nil)
595	mockDownloader.EXPECT().
596		GetCachedManifest().
597		Return(&filtering.Manifest{Version: "test"}, nil)
598
599	// Create manager
600	mgr, err := filtering.NewManager(filtering.ManagerConfig{
601		StoreDir:   storeDir,
602		JSONDir:    jsonDir,
603		Enabled:    true,
604		AutoUpdate: false,
605		Store:      mockStore,
606		Downloader: mockDownloader,
607	})
608	require.NoError(t, err)
609
610	// Track callback invocations
611	callCount := 0
612	var mu sync.Mutex
613	mgr.SetStatusCallback(func(_ filtering.FilterStatus) {
614		mu.Lock()
615		callCount++
616		mu.Unlock()
617	})
618
619	// Initialize and load
620	ctx := testContext()
621	_ = mgr.Initialize(ctx)
622
623	done := make(chan struct{})
624	go func() {
625		mgr.LoadAsync(ctx)
626		close(done)
627	}()
628	<-done
629
630	time.Sleep(100 * time.Millisecond)
631
632	// Verify callback was called multiple times
633	mu.Lock()
634	defer mu.Unlock()
635	assert.GreaterOrEqual(t, callCount, 2, "Expected callback to be called at least twice")
636}
637
638func TestManager_LoadAsync_QuarantinesInvalidDownloadedPayload(t *testing.T) {
639	tmpDir := t.TempDir()
640	storeDir := filepath.Join(tmpDir, "store")
641	jsonDir := filepath.Join(tmpDir, "json")
642
643	invalidJSONFile := filepath.Join(jsonDir, "combined-part1.json")
644	require.NoError(t, os.MkdirAll(jsonDir, 0o755))
645	require.NoError(t, os.WriteFile(invalidJSONFile, []byte(`{"invalid":`), 0o644))
646
647	mockStore := mocks.NewMockFilterStore(t)
648	mockDownloader := mocks.NewMockFilterDownloader(t)
649
650	mockStore.EXPECT().
651		FetchIdentifiers(mock.Anything).
652		Return([]string{}, nil)
653	mockDownloader.EXPECT().
654		GetCachedFilterPaths().
655		Return(nil)
656
657	mockDownloader.EXPECT().
658		DownloadFilters(mock.Anything, mock.Anything).
659		Return([]string{invalidJSONFile}, nil)
660
661	mgr, err := filtering.NewManager(filtering.ManagerConfig{
662		StoreDir:   storeDir,
663		JSONDir:    jsonDir,
664		Enabled:    true,
665		AutoUpdate: false,
666		Store:      mockStore,
667		Downloader: mockDownloader,
668	})
669	require.NoError(t, err)
670
671	ctx := testContext()
672	require.NoError(t, mgr.Initialize(ctx))
673
674	mgr.LoadAsync(ctx)
675
676	require.Eventually(t, func() bool {
677		return mgr.Status().State == filtering.StateError
678	}, 3*time.Second, 50*time.Millisecond)
679	assert.Equal(t, "Invalid downloaded filters", mgr.Status().Message)
680
681	_, statErr := os.Stat(invalidJSONFile)
682	require.ErrorIs(t, statErr, os.ErrNotExist)
683
684	quarantineDir := filepath.Join(jsonDir, "quarantine")
685	entries, err := os.ReadDir(quarantineDir)
686	require.NoError(t, err)
687	require.Len(t, entries, 1)
688}
689
690func TestManager_CheckForUpdates_CompileFailureKeepsActiveFilter(t *testing.T) {
691	tmpDir := t.TempDir()
692	storeDir := filepath.Join(tmpDir, "store")
693	jsonDir := filepath.Join(tmpDir, "json")
694
695	validJSON := `[{"trigger":{"url-filter":"test"},"action":{"type":"block"}}]`
696	jsonFile := filepath.Join(jsonDir, "combined-part1.json")
697	require.NoError(t, os.MkdirAll(jsonDir, 0o755))
698	require.NoError(t, os.WriteFile(jsonFile, []byte(validJSON), 0o644))
699
700	mockStore := mocks.NewMockFilterStore(t)
701	mockDownloader := mocks.NewMockFilterDownloader(t)
702
703	activeFilter := &webkit.UserContentFilter{}
704
705	// loadFromCache succeeds — returns activeFilter
706	mockStore.EXPECT().
707		FetchIdentifiers(mock.Anything).
708		Return([]string{"ublock-combined-0"}, nil).Once()
709	mockStore.EXPECT().
710		Load(mock.Anything, "ublock-combined-0").
711		Return(activeFilter, nil).Once()
712	mockDownloader.EXPECT().
713		GetCachedManifest().
714		Return(&filtering.Manifest{Version: "2025.12.19"}, nil)
715
716	// Background CheckForUpdates after cache load should observe no update.
717	mockDownloader.EXPECT().
718		NeedsUpdate(mock.Anything).
719		Return(false, nil).Once()
720
721	// Explicit CheckForUpdates: compile fails, should keep active filter
722	mockDownloader.EXPECT().
723		NeedsUpdate(mock.Anything).
724		Return(true, nil).Once()
725	mockDownloader.EXPECT().
726		DownloadFilters(mock.Anything, mock.Anything).
727		Return([]string{jsonFile}, nil).Once()
728	mockStore.EXPECT().
729		Compile(mock.Anything, "ublock-combined-0", mock.Anything).
730		Return(nil, errors.New("compile failed")).Once()
731
732	mgr, err := filtering.NewManager(filtering.ManagerConfig{
733		StoreDir:   storeDir,
734		JSONDir:    jsonDir,
735		Enabled:    true,
736		AutoUpdate: true,
737		Store:      mockStore,
738		Downloader: mockDownloader,
739	})
740	require.NoError(t, err)
741
742	ctx := testContext()
743	require.NoError(t, mgr.Initialize(ctx))
744	mgr.LoadAsync(ctx)
745
746	require.Eventually(t, func() bool {
747		return len(mgr.GetFilters()) > 0 && mgr.Status().State == filtering.StateActive
748	}, 3*time.Second, 50*time.Millisecond)
749
750	err = mgr.CheckForUpdates(ctx)
751	require.ErrorIs(t, err, filtering.ErrUpdateSkipped)
752	require.Len(t, mgr.GetFilters(), 1)
753	assert.Same(t, activeFilter, mgr.GetFilters()[0])
754	assert.Equal(t, filtering.StateActive, mgr.Status().State)
755}

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