v0.30.0 eb3068f

history_repo_test.go RawHistoryFolder

1package sqlite_test
2
3import (
4	"context"
5	"fmt"
6	"path/filepath"
7	"testing"
8	"time"
9
10	"github.com/bnema/dumber/internal/domain/entity"
11	"github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
12	"github.com/bnema/dumber/internal/logging"
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/require"
15)
16
17func historyTestCtx() context.Context {
18	logger := logging.NewFromConfigValues("debug", "console")
19	return logging.WithContext(context.Background(), logger)
20}
21
22func TestHistoryRepository_GetRecentSince(t *testing.T) {
23	ctx := historyTestCtx()
24	dbPath := filepath.Join(t.TempDir(), "dumber.db")
25
26	db, err := sqlite.NewConnection(ctx, dbPath)
27	require.NoError(t, err)
28	t.Cleanup(func() { _ = db.Close() })
29
30	repo := sqlite.NewHistoryRepository(db)
31
32	// Save some history entries
33	entries := []*entity.HistoryEntry{
34		{URL: "https://example.com", Title: "Example"},
35		{URL: "https://github.com", Title: "GitHub"},
36		{URL: "https://google.com", Title: "Google"},
37	}
38
39	for _, e := range entries {
40		require.NoError(t, repo.Save(ctx, e))
41	}
42
43	// Get recent since 30 days (all should be included)
44	results, err := repo.GetRecentSince(ctx, 30)
45	require.NoError(t, err)
46	require.Len(t, results, 3)
47
48	// Results should be sorted by last_visited DESC
49	// Since they were all just inserted, order may vary
50	urls := make(map[string]bool)
51	for _, r := range results {
52		urls[r.URL] = true
53	}
54	assert.True(t, urls["https://example.com"])
55	assert.True(t, urls["https://github.com"])
56	assert.True(t, urls["https://google.com"])
57}
58
59func TestHistoryRepository_GetRecentSince_EmptyResult(t *testing.T) {
60	ctx := historyTestCtx()
61	dbPath := filepath.Join(t.TempDir(), "dumber.db")
62
63	db, err := sqlite.NewConnection(ctx, dbPath)
64	require.NoError(t, err)
65	t.Cleanup(func() { _ = db.Close() })
66
67	repo := sqlite.NewHistoryRepository(db)
68
69	// Get recent with no data
70	results, err := repo.GetRecentSince(ctx, 30)
71	require.NoError(t, err)
72	assert.Empty(t, results)
73}
74
75func TestHistoryRepository_GetMostVisited(t *testing.T) {
76	ctx := historyTestCtx()
77	dbPath := filepath.Join(t.TempDir(), "dumber.db")
78
79	db, err := sqlite.NewConnection(ctx, dbPath)
80	require.NoError(t, err)
81	t.Cleanup(func() { _ = db.Close() })
82
83	repo := sqlite.NewHistoryRepository(db)
84
85	// Save entries with different visit counts
86	entry1 := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
87	entry2 := &entity.HistoryEntry{URL: "https://github.com", Title: "GitHub"}
88	entry3 := &entity.HistoryEntry{URL: "https://google.com", Title: "Google"}
89
90	require.NoError(t, repo.Save(ctx, entry1))
91	require.NoError(t, repo.Save(ctx, entry2))
92	require.NoError(t, repo.Save(ctx, entry3))
93
94	// Increment visit counts to create different popularities
95	// github: 5 visits, google: 3 visits, example: 1 visit
96	for i := 0; i < 4; i++ {
97		require.NoError(t, repo.IncrementVisitCount(ctx, "https://github.com"))
98	}
99	for i := 0; i < 2; i++ {
100		require.NoError(t, repo.IncrementVisitCount(ctx, "https://google.com"))
101	}
102
103	// Get most visited
104	results, err := repo.GetMostVisited(ctx, 30)
105	require.NoError(t, err)
106	require.Len(t, results, 3)
107
108	// First result should be github (5 visits)
109	assert.Equal(t, "https://github.com", results[0].URL)
110	assert.Equal(t, int64(5), results[0].VisitCount)
111
112	// Second should be google (3 visits)
113	assert.Equal(t, "https://google.com", results[1].URL)
114	assert.Equal(t, int64(3), results[1].VisitCount)
115
116	// Third should be example (1 visit)
117	assert.Equal(t, "https://example.com", results[2].URL)
118	assert.Equal(t, int64(1), results[2].VisitCount)
119}
120
121func TestHistoryRepository_GetMostVisited_EmptyResult(t *testing.T) {
122	ctx := historyTestCtx()
123	dbPath := filepath.Join(t.TempDir(), "dumber.db")
124
125	db, err := sqlite.NewConnection(ctx, dbPath)
126	require.NoError(t, err)
127	t.Cleanup(func() { _ = db.Close() })
128
129	repo := sqlite.NewHistoryRepository(db)
130
131	// Get most visited with no data
132	results, err := repo.GetMostVisited(ctx, 30)
133	require.NoError(t, err)
134	assert.Empty(t, results)
135}
136
137func TestHistoryRepository_GetRecentSince_RespectsTimeFilter(t *testing.T) {
138	ctx := historyTestCtx()
139	dbPath := filepath.Join(t.TempDir(), "dumber.db")
140
141	db, err := sqlite.NewConnection(ctx, dbPath)
142	require.NoError(t, err)
143	t.Cleanup(func() { _ = db.Close() })
144
145	repo := sqlite.NewHistoryRepository(db)
146
147	// Save an entry
148	entry := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
149	require.NoError(t, repo.Save(ctx, entry))
150
151	// Query with 1 day filter - should include recently added entry
152	results, err := repo.GetRecentSince(ctx, 1)
153	require.NoError(t, err)
154	require.Len(t, results, 1)
155
156	// Query with very large days filter - should still work
157	results, err = repo.GetRecentSince(ctx, 365)
158	require.NoError(t, err)
159	require.Len(t, results, 1)
160}
161
162func TestHistoryRepository_CRUD(t *testing.T) {
163	ctx := historyTestCtx()
164	dbPath := filepath.Join(t.TempDir(), "dumber.db")
165
166	db, err := sqlite.NewConnection(ctx, dbPath)
167	require.NoError(t, err)
168	t.Cleanup(func() { _ = db.Close() })
169
170	repo := sqlite.NewHistoryRepository(db)
171
172	// Create
173	entry := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
174	require.NoError(t, repo.Save(ctx, entry))
175
176	// Read
177	found, err := repo.FindByURL(ctx, "https://example.com")
178	require.NoError(t, err)
179	require.NotNil(t, found)
180	assert.Equal(t, "https://example.com", found.URL)
181	assert.Equal(t, "Example", found.Title)
182	assert.Equal(t, int64(1), found.VisitCount)
183
184	// Update (save again increments visit count)
185	require.NoError(t, repo.Save(ctx, entry))
186	found2, err := repo.FindByURL(ctx, "https://example.com")
187	require.NoError(t, err)
188	assert.Equal(t, int64(2), found2.VisitCount)
189
190	// Get recent
191	recent, err := repo.GetRecent(ctx, 10, 0)
192	require.NoError(t, err)
193	require.Len(t, recent, 1)
194
195	allRecent, err := repo.GetRecent(ctx, 0, 0)
196	require.NoError(t, err)
197	require.Len(t, allRecent, 1)
198
199	// Delete
200	require.NoError(t, repo.Delete(ctx, found.ID))
201
202	// Verify deleted
203	deleted, err := repo.FindByURL(ctx, "https://example.com")
204	require.NoError(t, err)
205	assert.Nil(t, deleted)
206}
207
208// Ensure the test uses an isolated timestamp for time-sensitive queries
209func TestHistoryRepository_TimeFilterIntegrity(t *testing.T) {
210	ctx := historyTestCtx()
211	dbPath := filepath.Join(t.TempDir(), "dumber.db")
212
213	db, err := sqlite.NewConnection(ctx, dbPath)
214	require.NoError(t, err)
215	t.Cleanup(func() { _ = db.Close() })
216
217	repo := sqlite.NewHistoryRepository(db)
218
219	// Save entry
220	entry := &entity.HistoryEntry{URL: "https://test.com", Title: "Test"}
221	require.NoError(t, repo.Save(ctx, entry))
222
223	// Verify entry was saved with recent timestamp
224	found, err := repo.FindByURL(ctx, "https://test.com")
225	require.NoError(t, err)
226	require.NotNil(t, found)
227
228	// LastVisited should be within the last minute
229	timeDiff := time.Since(found.LastVisited)
230	assert.Less(t, timeDiff, time.Minute, "LastVisited should be recent, got %v ago", timeDiff)
231}
232
233func TestHistoryRepository_GetRecentSince_RejectsNonPositiveDays(t *testing.T) {
234	ctx := historyTestCtx()
235	dbPath := filepath.Join(t.TempDir(), "dumber.db")
236
237	db, err := sqlite.NewConnection(ctx, dbPath)
238	require.NoError(t, err)
239	t.Cleanup(func() { _ = db.Close() })
240
241	repo := sqlite.NewHistoryRepository(db)
242
243	// days = 0 should return error
244	_, err = repo.GetRecentSince(ctx, 0)
245	require.Error(t, err)
246	assert.Contains(t, err.Error(), "days must be positive")
247
248	// days < 0 should return error
249	_, err = repo.GetRecentSince(ctx, -5)
250	require.Error(t, err)
251	assert.Contains(t, err.Error(), "days must be positive")
252}
253
254func TestHistoryRepository_GetMostVisited_RejectsNonPositiveDays(t *testing.T) {
255	ctx := historyTestCtx()
256	dbPath := filepath.Join(t.TempDir(), "dumber.db")
257
258	db, err := sqlite.NewConnection(ctx, dbPath)
259	require.NoError(t, err)
260	t.Cleanup(func() { _ = db.Close() })
261
262	repo := sqlite.NewHistoryRepository(db)
263
264	// days = 0 should return error
265	_, err = repo.GetMostVisited(ctx, 0)
266	require.Error(t, err)
267	assert.Contains(t, err.Error(), "days must be positive")
268
269	// days < 0 should return error
270	_, err = repo.GetMostVisited(ctx, -5)
271	require.Error(t, err)
272	assert.Contains(t, err.Error(), "days must be positive")
273}
274
275func TestHistoryRepository_GetAllRecentHistory(t *testing.T) {
276	ctx := historyTestCtx()
277	dbPath := filepath.Join(t.TempDir(), "dumber.db")
278
279	db, err := sqlite.NewConnection(ctx, dbPath)
280	require.NoError(t, err)
281	t.Cleanup(func() { _ = db.Close() })
282
283	repo := sqlite.NewHistoryRepository(db)
284
285	// Save some entries
286	entries := []*entity.HistoryEntry{
287		{URL: "https://example.com", Title: "Example"},
288		{URL: "https://github.com", Title: "GitHub"},
289	}
290	for _, e := range entries {
291		require.NoError(t, repo.Save(ctx, e))
292	}
293
294	// Get all recent history
295	results, err := repo.GetAllRecentHistory(ctx)
296	require.NoError(t, err)
297	require.Len(t, results, 2)
298}
299
300func TestHistoryRepository_GetAllMostVisited(t *testing.T) {
301	ctx := historyTestCtx()
302	dbPath := filepath.Join(t.TempDir(), "dumber.db")
303
304	db, err := sqlite.NewConnection(ctx, dbPath)
305	require.NoError(t, err)
306	t.Cleanup(func() { _ = db.Close() })
307
308	repo := sqlite.NewHistoryRepository(db)
309
310	// Save entries with different visit counts
311	entry1 := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
312	entry2 := &entity.HistoryEntry{URL: "https://github.com", Title: "GitHub"}
313
314	require.NoError(t, repo.Save(ctx, entry1))
315	require.NoError(t, repo.Save(ctx, entry2))
316
317	// Increment github visits
318	for i := 0; i < 3; i++ {
319		require.NoError(t, repo.IncrementVisitCount(ctx, "https://github.com"))
320	}
321
322	// Get all most visited
323	results, err := repo.GetAllMostVisited(ctx)
324	require.NoError(t, err)
325	require.Len(t, results, 2)
326
327	// First should be github (4 visits)
328	assert.Equal(t, "https://github.com", results[0].URL)
329	assert.Equal(t, int64(4), results[0].VisitCount)
330}
331
332func TestHistoryRepository_Search_SingleWord(t *testing.T) {
333	ctx := historyTestCtx()
334	dbPath := filepath.Join(t.TempDir(), "dumber.db")
335
336	db, err := sqlite.NewConnection(ctx, dbPath)
337	require.NoError(t, err)
338	t.Cleanup(func() { _ = db.Close() })
339
340	repo := sqlite.NewHistoryRepository(db)
341
342	// Save entries
343	entries := []*entity.HistoryEntry{
344		{URL: "https://github.com/user/repo", Title: "My GitHub Repository"},
345		{URL: "https://gitlab.com/project", Title: "GitLab Project"},
346		{URL: "https://example.com", Title: "Example Site"},
347	}
348	for _, e := range entries {
349		require.NoError(t, repo.Save(ctx, e))
350	}
351
352	// Search for "github" - should find the github entry
353	results, err := repo.Search(ctx, "github", 10)
354	require.NoError(t, err)
355	require.Len(t, results, 1)
356	assert.Equal(t, "https://github.com/user/repo", results[0].Entry.URL)
357}
358
359func TestHistoryRepository_Search_MultiWord(t *testing.T) {
360	ctx := historyTestCtx()
361	dbPath := filepath.Join(t.TempDir(), "dumber.db")
362
363	db, err := sqlite.NewConnection(ctx, dbPath)
364	require.NoError(t, err)
365	t.Cleanup(func() { _ = db.Close() })
366
367	repo := sqlite.NewHistoryRepository(db)
368
369	// Save entries
370	entries := []*entity.HistoryEntry{
371		{URL: "https://github.com/issues", Title: "GitHub Issues"},
372		{URL: "https://github.com/pulls", Title: "GitHub Pull Requests"},
373		{URL: "https://jira.com/issues", Title: "Jira Issues"},
374	}
375	for _, e := range entries {
376		require.NoError(t, repo.Save(ctx, e))
377	}
378
379	// Search for "github issues" - should find only the github issues entry
380	results, err := repo.Search(ctx, "github issues", 10)
381	require.NoError(t, err)
382	require.Len(t, results, 1)
383	assert.Equal(t, "https://github.com/issues", results[0].Entry.URL)
384}
385
386func TestHistoryRepository_Search_MatchesTitle(t *testing.T) {
387	ctx := historyTestCtx()
388	dbPath := filepath.Join(t.TempDir(), "dumber.db")
389
390	db, err := sqlite.NewConnection(ctx, dbPath)
391	require.NoError(t, err)
392	t.Cleanup(func() { _ = db.Close() })
393
394	repo := sqlite.NewHistoryRepository(db)
395
396	// Save entry with specific title that doesn't appear in URL
397	entry := &entity.HistoryEntry{
398		URL:   "https://docs.example.com/guide",
399		Title: "Comprehensive Documentation Guide",
400	}
401	require.NoError(t, repo.Save(ctx, entry))
402
403	// Search for "Documentation" - should find by title
404	results, err := repo.Search(ctx, "Documentation", 10)
405	require.NoError(t, err)
406	require.Len(t, results, 1)
407	assert.Equal(t, "https://docs.example.com/guide", results[0].Entry.URL)
408}
409
410func TestHistoryRepository_Search_EmptyQuery(t *testing.T) {
411	ctx := historyTestCtx()
412	dbPath := filepath.Join(t.TempDir(), "dumber.db")
413
414	db, err := sqlite.NewConnection(ctx, dbPath)
415	require.NoError(t, err)
416	t.Cleanup(func() { _ = db.Close() })
417
418	repo := sqlite.NewHistoryRepository(db)
419
420	// Save an entry
421	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
422		URL:   "https://example.com",
423		Title: "Example",
424	}))
425
426	// Empty query should return empty results, no error
427	results, err := repo.Search(ctx, "", 10)
428	require.NoError(t, err)
429	assert.Empty(t, results)
430
431	// Whitespace-only query should also return empty results
432	results, err = repo.Search(ctx, "   ", 10)
433	require.NoError(t, err)
434	assert.Empty(t, results)
435}
436
437func TestHistoryRepository_Search_NoValidTokens(t *testing.T) {
438	ctx := historyTestCtx()
439	dbPath := filepath.Join(t.TempDir(), "dumber.db")
440
441	db, err := sqlite.NewConnection(ctx, dbPath)
442	require.NoError(t, err)
443	t.Cleanup(func() { _ = db.Close() })
444
445	repo := sqlite.NewHistoryRepository(db)
446
447	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
448		URL:   "https://example.com",
449		Title: "Example",
450	}))
451
452	invalidQueries := []string{
453		"!!!",
454		"___",
455		"AND OR NOT NEAR",
456		`"***"`,
457	}
458
459	for _, q := range invalidQueries {
460		results, err := repo.Search(ctx, q, 10)
461		require.NoError(t, err, "query %q should return early without SQL errors", q)
462		assert.Empty(t, results, "query %q should have no valid tokens", q)
463	}
464}
465
466func TestHistoryRepository_Search_NoResults(t *testing.T) {
467	ctx := historyTestCtx()
468	dbPath := filepath.Join(t.TempDir(), "dumber.db")
469
470	db, err := sqlite.NewConnection(ctx, dbPath)
471	require.NoError(t, err)
472	t.Cleanup(func() { _ = db.Close() })
473
474	repo := sqlite.NewHistoryRepository(db)
475
476	// Save an entry
477	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
478		URL:   "https://example.com",
479		Title: "Example",
480	}))
481
482	// Search for something that doesn't exist
483	results, err := repo.Search(ctx, "nonexistent", 10)
484	require.NoError(t, err)
485	assert.Empty(t, results)
486}
487
488func TestHistoryRepository_Search_PrefixMatching(t *testing.T) {
489	ctx := historyTestCtx()
490	dbPath := filepath.Join(t.TempDir(), "dumber.db")
491
492	db, err := sqlite.NewConnection(ctx, dbPath)
493	require.NoError(t, err)
494	t.Cleanup(func() { _ = db.Close() })
495
496	repo := sqlite.NewHistoryRepository(db)
497
498	// Save entries
499	entries := []*entity.HistoryEntry{
500		{URL: "https://github.com", Title: "GitHub"},
501		{URL: "https://gitlab.com", Title: "GitLab"},
502		{URL: "https://example.com", Title: "Example"},
503	}
504	for _, e := range entries {
505		require.NoError(t, repo.Save(ctx, e))
506	}
507
508	// Search for "git" - should find both github and gitlab (prefix match)
509	results, err := repo.Search(ctx, "git", 10)
510	require.NoError(t, err)
511	require.Len(t, results, 2)
512
513	// Verify both git* entries are found
514	urls := make(map[string]bool)
515	for _, r := range results {
516		urls[r.Entry.URL] = true
517	}
518	assert.True(t, urls["https://github.com"])
519	assert.True(t, urls["https://gitlab.com"])
520}
521
522func TestHistoryRepository_Search_DomainLikeQuery(t *testing.T) {
523	ctx := historyTestCtx()
524	dbPath := filepath.Join(t.TempDir(), "dumber.db")
525
526	db, err := sqlite.NewConnection(ctx, dbPath)
527	require.NoError(t, err)
528	t.Cleanup(func() { _ = db.Close() })
529
530	repo := sqlite.NewHistoryRepository(db)
531
532	// Save entries with domain-like URLs
533	entries := []*entity.HistoryEntry{
534		{URL: "https://gordon.bnema.dev/", Title: "Gordon - Self-hosted Deployment Platform"},
535		{URL: "https://example.com", Title: "Example Site"},
536		{URL: "https://other.gordon.io", Title: "Another Gordon Site"},
537	}
538	for _, e := range entries {
539		require.NoError(t, repo.Save(ctx, e))
540	}
541
542	// Search with period in query (domain-like) - periods should be treated as separators
543	// "gordon.bnem" should match "gordon.bnema.dev" (tokens: gordon, bnem -> gordon, bnema)
544	results, err := repo.Search(ctx, "gordon.bnem", 10)
545	require.NoError(t, err)
546	require.NotEmpty(t, results, "domain-like query 'gordon.bnem' should match gordon.bnema.dev")
547
548	// Verify the gordon.bnema.dev entry is found
549	found := false
550	for _, r := range results {
551		if r.Entry.URL == "https://gordon.bnema.dev/" {
552			found = true
553			break
554		}
555	}
556	assert.True(t, found, "gordon.bnema.dev should be in results")
557}
558
559func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) {
560	ctx := historyTestCtx()
561	dbPath := filepath.Join(t.TempDir(), "dumber.db")
562
563	db, err := sqlite.NewConnection(ctx, dbPath)
564	require.NoError(t, err)
565	t.Cleanup(func() { _ = db.Close() })
566
567	repo := sqlite.NewHistoryRepository(db)
568
569	// Save entry
570	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
571		URL:   "https://example.com/path",
572		Title: "Example Page",
573	}))
574
575	// Search with FTS5 special characters - should not cause errors
576	specialQueries := []string{
577		"example:",     // colon
578		"example*",     // asterisk
579		"example()",    // parentheses
580		`example"test`, // quotes
581		"example^",     // caret
582		"example-test", // hyphen
583		"example/",     // slash
584	}
585
586	for _, q := range specialQueries {
587		results, err := repo.Search(ctx, q, 10)
588		require.NoError(t, err, "query %q should not cause error", q)
589		// May or may not find results, but should not error
590		_ = results
591	}
592}
593
594func TestHistoryRepository_Search_IgnoresFTSOperatorsAsTokens(t *testing.T) {
595	ctx := historyTestCtx()
596	dbPath := filepath.Join(t.TempDir(), "dumber.db")
597
598	db, err := sqlite.NewConnection(ctx, dbPath)
599	require.NoError(t, err)
600	t.Cleanup(func() { _ = db.Close() })
601
602	repo := sqlite.NewHistoryRepository(db)
603
604	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
605		URL:   "https://github.com/bnema/dumber",
606		Title: "Dumber",
607	}))
608
609	results, err := repo.Search(ctx, "AND github OR", 10)
610	require.NoError(t, err)
611	require.Len(t, results, 1)
612	assert.Equal(t, "https://github.com/bnema/dumber", results[0].Entry.URL)
613}
614
615func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) {
616	ctx := historyTestCtx()
617	dbPath := filepath.Join(t.TempDir(), "dumber.db")
618
619	db, err := sqlite.NewConnection(ctx, dbPath)
620	require.NoError(t, err)
621	t.Cleanup(func() { _ = db.Close() })
622
623	repo := sqlite.NewHistoryRepository(db)
624
625	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
626		URL:   "https://github.com/bnema/dumber",
627		Title: "Dumber",
628	}))
629
630	results, err := repo.Search(ctx, "github.com/bnema", 10)
631	require.NoError(t, err)
632	require.NotEmpty(t, results, "slash-separated query should match history")
633}
634
635func TestHistoryRepository_Search_PrefersURLMatchOverTitleOnlyMatch(t *testing.T) {
636	ctx := historyTestCtx()
637	dbPath := filepath.Join(t.TempDir(), "dumber.db")
638
639	db, err := sqlite.NewConnection(ctx, dbPath)
640	require.NoError(t, err)
641	t.Cleanup(func() { _ = db.Close() })
642
643	repo := sqlite.NewHistoryRepository(db)
644
645	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
646		URL:   "https://github.com/bnema/dumber/issues",
647		Title: "Project board",
648	}))
649	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
650		URL:   "https://tracker.example.com/board",
651		Title: "GitHub Issues Mirror",
652	}))
653
654	results, err := repo.Search(ctx, "github issues", 10)
655	require.NoError(t, err)
656	require.Len(t, results, 2)
657	assert.Equal(t, "https://github.com/bnema/dumber/issues", results[0].Entry.URL)
658}
659
660func TestHistoryRepository_Search_PrefersHostPrefixOverTitleContains(t *testing.T) {
661	ctx := historyTestCtx()
662	dbPath := filepath.Join(t.TempDir(), "dumber.db")
663
664	db, err := sqlite.NewConnection(ctx, dbPath)
665	require.NoError(t, err)
666	t.Cleanup(func() { _ = db.Close() })
667
668	repo := sqlite.NewHistoryRepository(db)
669
670	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
671		URL:   "https://github.com",
672		Title: "GitHub",
673	}))
674	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
675		URL:   "https://example.com/landing",
676		Title: "GitHub documentation mirror",
677	}))
678
679	results, err := repo.Search(ctx, "git", 10)
680	require.NoError(t, err)
681	require.Len(t, results, 2)
682	assert.Equal(t, "https://github.com", results[0].Entry.URL)
683}
684
685func TestHistoryRepository_Search_PrefersShorterRootURLForHostQuery(t *testing.T) {
686	ctx := historyTestCtx()
687	dbPath := filepath.Join(t.TempDir(), "dumber.db")
688
689	db, err := sqlite.NewConnection(ctx, dbPath)
690	require.NoError(t, err)
691	t.Cleanup(func() { _ = db.Close() })
692
693	repo := sqlite.NewHistoryRepository(db)
694
695	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
696		URL:   "https://github.com",
697		Title: "GitHub",
698	}))
699	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
700		URL:   "https://github.com/bnema/dumber/issues/123",
701		Title: "Issue 123",
702	}))
703
704	results, err := repo.Search(ctx, "github", 10)
705	require.NoError(t, err)
706	require.Len(t, results, 2)
707	assert.Equal(t, "https://github.com", results[0].Entry.URL)
708}
709
710func TestHistoryRepository_UpdateMetadata_DoesNotIncrementVisitCount(t *testing.T) {
711	ctx := historyTestCtx()
712	dbPath := filepath.Join(t.TempDir(), "dumber.db")
713
714	db, err := sqlite.NewConnection(ctx, dbPath)
715	require.NoError(t, err)
716	t.Cleanup(func() { _ = db.Close() })
717
718	repoIface := sqlite.NewHistoryRepository(db)
719	repo, ok := repoIface.(interface {
720		Save(context.Context, *entity.HistoryEntry) error
721		FindByURL(context.Context, string) (*entity.HistoryEntry, error)
722		UpdateMetadata(context.Context, *entity.HistoryEntry) error
723	})
724	require.True(t, ok)
725
726	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
727		URL:   "https://github.com/bnema/dumber",
728		Title: "Old title",
729	}))
730
731	before, err := repo.FindByURL(ctx, "https://github.com/bnema/dumber")
732	require.NoError(t, err)
733	require.NotNil(t, before)
734	require.Equal(t, int64(1), before.VisitCount)
735
736	require.NoError(t, repo.UpdateMetadata(ctx, &entity.HistoryEntry{
737		URL:   "https://github.com/bnema/dumber",
738		Title: "New title",
739	}))
740
741	after, err := repo.FindByURL(ctx, "https://github.com/bnema/dumber")
742	require.NoError(t, err)
743	require.NotNil(t, after)
744	assert.Equal(t, int64(1), after.VisitCount)
745	assert.Equal(t, "New title", after.Title)
746}
747
748func TestHistoryRepository_AboutBlank_CappedVisitCount(t *testing.T) {
749	ctx := historyTestCtx()
750	dbPath := filepath.Join(t.TempDir(), "dumber.db")
751
752	db, err := sqlite.NewConnection(ctx, dbPath)
753	require.NoError(t, err)
754	t.Cleanup(func() { _ = db.Close() })
755
756	repo := sqlite.NewHistoryRepository(db)
757
758	// Save about:blank multiple times - visit count should stay at 1
759	for i := 0; i < 5; i++ {
760		require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
761			URL:   "about:blank",
762			Title: "New Tab",
763		}))
764	}
765
766	// Verify about:blank has visit_count = 1
767	found, err := repo.FindByURL(ctx, "about:blank")
768	require.NoError(t, err)
769	require.NotNil(t, found)
770	assert.Equal(t, int64(1), found.VisitCount, "about:blank should always have visit_count = 1")
771
772	// Try incrementing visit count directly - should be ignored
773	require.NoError(t, repo.IncrementVisitCount(ctx, "about:blank"))
774
775	found2, err := repo.FindByURL(ctx, "about:blank")
776	require.NoError(t, err)
777	assert.Equal(t, int64(1), found2.VisitCount, "about:blank increment should be ignored")
778}
779
780func TestHistoryRepository_AboutBlank_NotDominant(t *testing.T) {
781	ctx := historyTestCtx()
782	dbPath := filepath.Join(t.TempDir(), "dumber.db")
783
784	db, err := sqlite.NewConnection(ctx, dbPath)
785	require.NoError(t, err)
786	t.Cleanup(func() { _ = db.Close() })
787
788	repo := sqlite.NewHistoryRepository(db)
789
790	// Save about:blank many times
791	for i := 0; i < 10; i++ {
792		require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
793			URL:   "about:blank",
794			Title: "New Tab",
795		}))
796	}
797
798	// Save a regular entry with multiple visits
799	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
800		URL:   "https://example.com",
801		Title: "Example",
802	}))
803	require.NoError(t, repo.IncrementVisitCount(ctx, "https://example.com"))
804	require.NoError(t, repo.IncrementVisitCount(ctx, "https://example.com"))
805
806	// Get most visited - example.com should be first (3 visits vs 1)
807	results, err := repo.GetAllMostVisited(ctx)
808	require.NoError(t, err)
809	require.Len(t, results, 2)
810
811	assert.Equal(t, "https://example.com", results[0].URL, "example.com should be first with higher visit count")
812	assert.Equal(t, int64(3), results[0].VisitCount)
813
814	assert.Equal(t, "about:blank", results[1].URL, "about:blank should be second with capped visit count")
815	assert.Equal(t, int64(1), results[1].VisitCount)
816}
817
818func TestHistoryRepository_Search_ReturnsEmptyForNonPositiveLimit(t *testing.T) {
819	ctx := historyTestCtx()
820	dbPath := filepath.Join(t.TempDir(), "dumber.db")
821
822	db, err := sqlite.NewConnection(ctx, dbPath)
823	require.NoError(t, err)
824	t.Cleanup(func() { _ = db.Close() })
825
826	repo := sqlite.NewHistoryRepository(db)
827	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
828		URL:   "https://github.com",
829		Title: "GitHub",
830	}))
831
832	results, err := repo.Search(ctx, "git", 0)
833	require.NoError(t, err)
834	assert.Empty(t, results)
835
836	results, err = repo.Search(ctx, "git", -1)
837	require.NoError(t, err)
838	assert.Empty(t, results)
839}
840
841func TestHistoryRepository_RejectsEmptyCanonicalDomain(t *testing.T) {
842	ctx := historyTestCtx()
843	dbPath := filepath.Join(t.TempDir(), "dumber.db")
844
845	db, err := sqlite.NewConnection(ctx, dbPath)
846	require.NoError(t, err)
847	t.Cleanup(func() { _ = db.Close() })
848
849	repo := sqlite.NewHistoryRepository(db)
850
851	entries, err := repo.GetRecentByDomain(ctx, " ", 10, 0)
852	require.Error(t, err)
853	assert.Nil(t, entries)
854	assert.Error(t, repo.DeleteByDomain(ctx, "/path"))
855}
856
857func TestHistoryRepository_DomainColumnPowersFilteringStatsAndDelete(t *testing.T) {
858	ctx := historyTestCtx()
859	dbPath := filepath.Join(t.TempDir(), "dumber.db")
860
861	db, err := sqlite.NewConnection(ctx, dbPath)
862	require.NoError(t, err)
863	t.Cleanup(func() { _ = db.Close() })
864
865	repo := sqlite.NewHistoryRepository(db)
866	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://www.example.com/a", Title: "Example A"}))
867	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://example.com/b", Title: "Example B"}))
868	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://other.test", Title: "Other"}))
869
870	exampleEntries, err := repo.GetRecentByDomain(ctx, "www.example.com", 0, 0)
871	require.NoError(t, err)
872	require.Len(t, exampleEntries, 2)
873	for _, entry := range exampleEntries {
874		assert.Contains(t, entry.URL, "example.com")
875	}
876
877	stats, err := repo.GetDomainStats(ctx, 10)
878	require.NoError(t, err)
879	require.NotEmpty(t, stats)
880	assert.Equal(t, "example.com", stats[0].Domain)
881	assert.Equal(t, int64(2), stats[0].PageCount)
882
883	require.NoError(t, repo.DeleteByDomain(ctx, "example.com"))
884
885	exampleEntries, err = repo.GetRecentByDomain(ctx, "example.com", 10, 0)
886	require.NoError(t, err)
887	assert.Empty(t, exampleEntries)
888
889	remaining, err := repo.GetAllRecentHistory(ctx)
890	require.NoError(t, err)
891	require.Len(t, remaining, 1)
892	assert.Equal(t, "https://other.test", remaining[0].URL)
893}
894func TestHistoryRepository_GetRecentWindow_StableCursorWithTimestampTies(t *testing.T) {
895	ctx := historyTestCtx()
896	dbPath := filepath.Join(t.TempDir(), "dumber.db")
897
898	db, err := sqlite.NewConnection(ctx, dbPath)
899	require.NoError(t, err)
900	t.Cleanup(func() { _ = db.Close() })
901
902	repo := sqlite.NewHistoryRepository(db)
903	tied := time.Date(2026, 6, 24, 10, 0, 0, 0, time.UTC)
904	older := tied.Add(-time.Hour)
905	for i := 1; i <= 5; i++ {
906		_, err = db.ExecContext(ctx, `INSERT INTO history (url, title, domain, last_visited, created_at) VALUES (?, ?, ?, ?, ?)`,
907			fmt.Sprintf("https://example.com/%d", i), fmt.Sprintf("Entry %d", i), "example.com", tied, tied)
908		require.NoError(t, err)
909	}
910	_, err = db.ExecContext(ctx, `INSERT INTO history (url, title, domain, last_visited, created_at) VALUES (?, ?, ?, ?, ?)`,
911		"https://example.com/older", "Older", "example.com", older, older)
912	require.NoError(t, err)
913
914	page1, err := repo.GetRecentWindow(ctx, tied.Add(time.Second), 0, 3)
915	require.NoError(t, err)
916	require.Len(t, page1, 3)
917	assert.Equal(t, []int64{5, 4, 3}, historyIDs(page1))
918
919	page2, err := repo.GetRecentWindow(ctx, page1[len(page1)-1].LastVisited, page1[len(page1)-1].ID, 3)
920	require.NoError(t, err)
921	require.Len(t, page2, 3)
922	assert.Equal(t, []int64{2, 1, 6}, historyIDs(page2))
923
924	seen := map[int64]bool{}
925	for _, entry := range append(page1, page2...) {
926		if seen[entry.ID] {
927			t.Fatalf("duplicate history id %d", entry.ID)
928		}
929		seen[entry.ID] = true
930	}
931}
932
933func historyIDs(entries []*entity.HistoryEntry) []int64 {
934	ids := make([]int64, len(entries))
935	for i, entry := range entries {
936		ids[i] = entry.ID
937	}
938	return ids
939}

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