v0.26.0 ae3a77a

manager_test.go RawHistoryFolder

1package filtering_test
2
3import (
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
19func testContext() context.Context {
20	logger := logging.NewFromConfigValues("debug", "pretty")
21	return logging.WithContext(context.Background(), logger)
22}
23
24func 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(status filtering.FilterStatus) {
78		mu.Lock()
79		statuses = append(statuses, status)
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
118func 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(status filtering.FilterStatus) {
169		mu.Lock()
170		statuses = append(statuses, status)
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
215func 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
287func 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
323func 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
354func 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
395func 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
426func 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(_ 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