detached a373232

feat(filtering): add content filter interfaces and manager tests

commit
a37323273f372aabd4fda32559873e66e3d9c65c
author
brice · · 2025-12-18 18:36 UTC
committer
brice · · 2025-12-18 18:56 UTC
parent
453cbd8
signature
unsigned
2 files · +556 -0
A internal/infrastructure/filtering/interfaces.go +60 -0
@@ -0,0 +1,60 @@
1+package filtering
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/puregotk-webkit/webkit"
7+)
8+
9+// FilterStore abstracts the WebKit UserContentFilterStore operations.
10+// This interface enables testing without requiring GTK/WebKit.
11+type FilterStore interface {
12+ // Compile compiles a JSON filter file and stores it with the given identifier.
13+ Compile(ctx context.Context, identifier string, jsonPath string) (*webkit.UserContentFilter, error)
14+
15+ // Load loads a previously compiled filter by its identifier.
16+ Load(ctx context.Context, identifier string) (*webkit.UserContentFilter, error)
17+
18+ // Remove removes a compiled filter by its identifier.
19+ Remove(ctx context.Context, identifier string) error
20+
21+ // HasCompiledFilter checks if a compiled filter exists for the given identifier.
22+ HasCompiledFilter(ctx context.Context, identifier string) bool
23+
24+ // FetchIdentifiers returns all stored filter identifiers.
25+ FetchIdentifiers(ctx context.Context) ([]string, error)
26+
27+ // Path returns the storage path for compiled filters.
28+ Path() string
29+}
30+
31+// FilterDownloader abstracts the filter download operations.
32+// This interface enables testing without requiring network access.
33+type FilterDownloader interface {
34+ // GetCachedManifest reads the locally cached manifest.json.
35+ GetCachedManifest() (*Manifest, error)
36+
37+ // FetchManifest downloads the latest manifest.json from the remote source.
38+ FetchManifest(ctx context.Context) (*Manifest, error)
39+
40+ // DownloadFilters downloads filter JSON files from the remote source.
41+ DownloadFilters(ctx context.Context, onProgress func(DownloadProgress)) ([]string, error)
42+
43+ // NeedsUpdate checks if filters need to be updated based on manifest version.
44+ NeedsUpdate(ctx context.Context) (bool, error)
45+
46+ // ClearCache removes all cached filter files.
47+ ClearCache() error
48+
49+ // HasCachedFilters checks if all filter JSON files are cached.
50+ HasCachedFilters() bool
51+
52+ // GetCachedFilterPaths returns paths to cached filter JSON files.
53+ GetCachedFilterPaths() []string
54+}
55+
56+// Ensure concrete types implement the interfaces.
57+var (
58+ _ FilterStore = (*Store)(nil)
59+ _ FilterDownloader = (*Downloader)(nil)
60+)
@@ -0,0 +1,60 @@
1+package filtering
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/puregotk-webkit/webkit"
7+)
8+
9+// FilterStore abstracts the WebKit UserContentFilterStore operations.
10+// This interface enables testing without requiring GTK/WebKit.
11+type FilterStore interface {
12+ // Compile compiles a JSON filter file and stores it with the given identifier.
13+ Compile(ctx context.Context, identifier string, jsonPath string) (*webkit.UserContentFilter, error)
14+
15+ // Load loads a previously compiled filter by its identifier.
16+ Load(ctx context.Context, identifier string) (*webkit.UserContentFilter, error)
17+
18+ // Remove removes a compiled filter by its identifier.
19+ Remove(ctx context.Context, identifier string) error
20+
21+ // HasCompiledFilter checks if a compiled filter exists for the given identifier.
22+ HasCompiledFilter(ctx context.Context, identifier string) bool
23+
24+ // FetchIdentifiers returns all stored filter identifiers.
25+ FetchIdentifiers(ctx context.Context) ([]string, error)
26+
27+ // Path returns the storage path for compiled filters.
28+ Path() string
29+}
30+
31+// FilterDownloader abstracts the filter download operations.
32+// This interface enables testing without requiring network access.
33+type FilterDownloader interface {
34+ // GetCachedManifest reads the locally cached manifest.json.
35+ GetCachedManifest() (*Manifest, error)
36+
37+ // FetchManifest downloads the latest manifest.json from the remote source.
38+ FetchManifest(ctx context.Context) (*Manifest, error)
39+
40+ // DownloadFilters downloads filter JSON files from the remote source.
41+ DownloadFilters(ctx context.Context, onProgress func(DownloadProgress)) ([]string, error)
42+
43+ // NeedsUpdate checks if filters need to be updated based on manifest version.
44+ NeedsUpdate(ctx context.Context) (bool, error)
45+
46+ // ClearCache removes all cached filter files.
47+ ClearCache() error
48+
49+ // HasCachedFilters checks if all filter JSON files are cached.
50+ HasCachedFilters() bool
51+
52+ // GetCachedFilterPaths returns paths to cached filter JSON files.
53+ GetCachedFilterPaths() []string
54+}
55+
56+// Ensure concrete types implement the interfaces.
57+var (
58+ _ FilterStore = (*Store)(nil)
59+ _ FilterDownloader = (*Downloader)(nil)
60+)
A internal/infrastructure/filtering/manager_test.go +496 -0
@@ -0,0 +1,496 @@
1+package filtering_test
2+
3+import (
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+
19+func testContext() context.Context {
20+ logger := logging.NewFromConfigValues("debug", "pretty")
21+ return logging.WithContext(context.Background(), logger)
22+}
23+
24+func 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(s filtering.FilterStatus) {
78+ mu.Lock()
79+ statuses = append(statuses, s)
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+
118+func 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(s filtering.FilterStatus) {
169+ mu.Lock()
170+ statuses = append(statuses, s)
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+
215+func 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+
287+func 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+
323+func 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+
354+func 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+
395+func 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+
426+func 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(s 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+}
@@ -0,0 +1,496 @@
1+package filtering_test
2+
3+import (
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+
19+func testContext() context.Context {
20+ logger := logging.NewFromConfigValues("debug", "pretty")
21+ return logging.WithContext(context.Background(), logger)
22+}
23+
24+func 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(s filtering.FilterStatus) {
78+ mu.Lock()
79+ statuses = append(statuses, s)
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+
118+func 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(s filtering.FilterStatus) {
169+ mu.Lock()
170+ statuses = append(statuses, s)
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+
215+func 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+
287+func 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+
323+func 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+
354+func 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+
395+func 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+
426+func 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(s 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+}

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