v0.33.2 57f21d6

history_repo_test.go RawHistoryFolder

1package sqlite_test
2
3import (
4	"context"
5	"database/sql"
6	"fmt"
7	"path/filepath"
8	"strings"
9	"testing"
10	"time"
11
12	"github.com/bnema/dumber/internal/domain/entity"
13	repository "github.com/bnema/dumber/internal/domain/repository"
14	"github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
15	"github.com/bnema/dumber/internal/infrastructure/persistence/sqlite/sqlc"
16	"github.com/bnema/dumber/internal/logging"
17	"github.com/stretchr/testify/assert"
18	"github.com/stretchr/testify/require"
19)
20
21func historyTestCtx() context.Context {
22	logger := logging.NewFromConfigValues("debug", "console")
23	return logging.WithContext(context.Background(), logger)
24}
25
26func TestHistoryRepository_GetRecentSince(t *testing.T) {
27	ctx := historyTestCtx()
28	dbPath := filepath.Join(t.TempDir(), "dumber.db")
29
30	db, err := sqlite.NewConnection(ctx, dbPath)
31	require.NoError(t, err)
32	t.Cleanup(func() { _ = db.Close() })
33
34	repo := sqlite.NewHistoryRepository(db)
35
36	// Save some history entries
37	entries := []*entity.HistoryEntry{
38		{URL: "https://example.com", Title: "Example"},
39		{URL: "https://github.com", Title: "GitHub"},
40		{URL: "https://google.com", Title: "Google"},
41	}
42
43	for _, e := range entries {
44		require.NoError(t, repo.Save(ctx, e))
45	}
46
47	// Get recent since 30 days (all should be included)
48	results, err := repo.GetRecentSince(ctx, 30)
49	require.NoError(t, err)
50	require.Len(t, results, 3)
51
52	// Results should be sorted by last_visited DESC
53	// Since they were all just inserted, order may vary
54	urls := make(map[string]bool)
55	for _, r := range results {
56		urls[r.URL] = true
57	}
58	assert.True(t, urls["https://example.com"])
59	assert.True(t, urls["https://github.com"])
60	assert.True(t, urls["https://google.com"])
61}
62
63func TestHistoryRepository_GetRecentSince_EmptyResult(t *testing.T) {
64	ctx := historyTestCtx()
65	dbPath := filepath.Join(t.TempDir(), "dumber.db")
66
67	db, err := sqlite.NewConnection(ctx, dbPath)
68	require.NoError(t, err)
69	t.Cleanup(func() { _ = db.Close() })
70
71	repo := sqlite.NewHistoryRepository(db)
72
73	// Get recent with no data
74	results, err := repo.GetRecentSince(ctx, 30)
75	require.NoError(t, err)
76	assert.Empty(t, results)
77}
78
79func TestHistoryRepository_GetMostVisited(t *testing.T) {
80	ctx := historyTestCtx()
81	dbPath := filepath.Join(t.TempDir(), "dumber.db")
82
83	db, err := sqlite.NewConnection(ctx, dbPath)
84	require.NoError(t, err)
85	t.Cleanup(func() { _ = db.Close() })
86
87	repo := sqlite.NewHistoryRepository(db)
88
89	// Save entries with different visit counts
90	entry1 := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
91	entry2 := &entity.HistoryEntry{URL: "https://github.com", Title: "GitHub"}
92	entry3 := &entity.HistoryEntry{URL: "https://google.com", Title: "Google"}
93
94	require.NoError(t, repo.Save(ctx, entry1))
95	require.NoError(t, repo.Save(ctx, entry2))
96	require.NoError(t, repo.Save(ctx, entry3))
97
98	// Increment visit counts to create different popularities
99	// github: 5 visits, google: 3 visits, example: 1 visit
100	for range 4 {
101		require.NoError(t, repo.IncrementVisitCount(ctx, "https://github.com"))
102	}
103	for range 2 {
104		require.NoError(t, repo.IncrementVisitCount(ctx, "https://google.com"))
105	}
106
107	// Get most visited
108	results, err := repo.GetMostVisited(ctx, 30)
109	require.NoError(t, err)
110	require.Len(t, results, 3)
111
112	// First result should be github (5 visits)
113	assert.Equal(t, "https://github.com", results[0].URL)
114	assert.Equal(t, int64(5), results[0].VisitCount)
115
116	// Second should be google (3 visits)
117	assert.Equal(t, "https://google.com", results[1].URL)
118	assert.Equal(t, int64(3), results[1].VisitCount)
119
120	// Third should be example (1 visit)
121	assert.Equal(t, "https://example.com", results[2].URL)
122	assert.Equal(t, int64(1), results[2].VisitCount)
123}
124
125func TestHistoryRepository_GetMostVisited_EmptyResult(t *testing.T) {
126	ctx := historyTestCtx()
127	dbPath := filepath.Join(t.TempDir(), "dumber.db")
128
129	db, err := sqlite.NewConnection(ctx, dbPath)
130	require.NoError(t, err)
131	t.Cleanup(func() { _ = db.Close() })
132
133	repo := sqlite.NewHistoryRepository(db)
134
135	// Get most visited with no data
136	results, err := repo.GetMostVisited(ctx, 30)
137	require.NoError(t, err)
138	assert.Empty(t, results)
139}
140
141func TestHistoryRepository_GetRecentSince_RespectsTimeFilter(t *testing.T) {
142	ctx := historyTestCtx()
143	dbPath := filepath.Join(t.TempDir(), "dumber.db")
144
145	db, err := sqlite.NewConnection(ctx, dbPath)
146	require.NoError(t, err)
147	t.Cleanup(func() { _ = db.Close() })
148
149	repo := sqlite.NewHistoryRepository(db)
150
151	// Save an entry
152	entry := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
153	require.NoError(t, repo.Save(ctx, entry))
154
155	// Query with 1 day filter - should include recently added entry
156	results, err := repo.GetRecentSince(ctx, 1)
157	require.NoError(t, err)
158	require.Len(t, results, 1)
159
160	// Query with very large days filter - should still work
161	results, err = repo.GetRecentSince(ctx, 365)
162	require.NoError(t, err)
163	require.Len(t, results, 1)
164}
165
166func TestHistoryRepository_CRUD(t *testing.T) {
167	ctx := historyTestCtx()
168	dbPath := filepath.Join(t.TempDir(), "dumber.db")
169
170	db, err := sqlite.NewConnection(ctx, dbPath)
171	require.NoError(t, err)
172	t.Cleanup(func() { _ = db.Close() })
173
174	repo := sqlite.NewHistoryRepository(db)
175
176	// Create
177	entry := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
178	require.NoError(t, repo.Save(ctx, entry))
179
180	// Read
181	found, err := repo.FindByURL(ctx, "https://example.com")
182	require.NoError(t, err)
183	require.NotNil(t, found)
184	assert.Equal(t, "https://example.com", found.URL)
185	assert.Equal(t, "Example", found.Title)
186	assert.Equal(t, int64(1), found.VisitCount)
187
188	// Update (save again increments visit count)
189	require.NoError(t, repo.Save(ctx, entry))
190	found2, err := repo.FindByURL(ctx, "https://example.com")
191	require.NoError(t, err)
192	assert.Equal(t, int64(2), found2.VisitCount)
193
194	// Get recent
195	recent, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{})
196	require.NoError(t, err)
197	require.Len(t, recent, 1)
198
199	allRecent, err := repo.GetRecent(ctx, 0, 0, repository.HistoryScope{})
200	require.NoError(t, err)
201	require.Len(t, allRecent, 1)
202
203	// Delete
204	require.NoError(t, repo.Delete(ctx, found.ID))
205
206	// Verify deleted
207	deleted, err := repo.FindByURL(ctx, "https://example.com")
208	require.NoError(t, err)
209	assert.Nil(t, deleted)
210}
211
212// Ensure the test uses an isolated timestamp for time-sensitive queries
213func TestHistoryRepository_TimeFilterIntegrity(t *testing.T) {
214	ctx := historyTestCtx()
215	dbPath := filepath.Join(t.TempDir(), "dumber.db")
216
217	db, err := sqlite.NewConnection(ctx, dbPath)
218	require.NoError(t, err)
219	t.Cleanup(func() { _ = db.Close() })
220
221	repo := sqlite.NewHistoryRepository(db)
222
223	// Save entry
224	entry := &entity.HistoryEntry{URL: "https://test.com", Title: "Test"}
225	require.NoError(t, repo.Save(ctx, entry))
226
227	// Verify entry was saved with recent timestamp
228	found, err := repo.FindByURL(ctx, "https://test.com")
229	require.NoError(t, err)
230	require.NotNil(t, found)
231
232	// LastVisited should be within the last minute
233	timeDiff := time.Since(found.LastVisited)
234	assert.Less(t, timeDiff, time.Minute, "LastVisited should be recent, got %v ago", timeDiff)
235}
236
237func TestHistoryRepository_GetRecentSince_RejectsNonPositiveDays(t *testing.T) {
238	ctx := historyTestCtx()
239	dbPath := filepath.Join(t.TempDir(), "dumber.db")
240
241	db, err := sqlite.NewConnection(ctx, dbPath)
242	require.NoError(t, err)
243	t.Cleanup(func() { _ = db.Close() })
244
245	repo := sqlite.NewHistoryRepository(db)
246
247	// days = 0 should return error
248	_, err = repo.GetRecentSince(ctx, 0)
249	require.Error(t, err)
250	assert.Contains(t, err.Error(), "days must be positive")
251
252	// days < 0 should return error
253	_, err = repo.GetRecentSince(ctx, -5)
254	require.Error(t, err)
255	assert.Contains(t, err.Error(), "days must be positive")
256}
257
258func TestHistoryRepository_GetMostVisited_RejectsNonPositiveDays(t *testing.T) {
259	ctx := historyTestCtx()
260	dbPath := filepath.Join(t.TempDir(), "dumber.db")
261
262	db, err := sqlite.NewConnection(ctx, dbPath)
263	require.NoError(t, err)
264	t.Cleanup(func() { _ = db.Close() })
265
266	repo := sqlite.NewHistoryRepository(db)
267
268	// days = 0 should return error
269	_, err = repo.GetMostVisited(ctx, 0)
270	require.Error(t, err)
271	assert.Contains(t, err.Error(), "days must be positive")
272
273	// days < 0 should return error
274	_, err = repo.GetMostVisited(ctx, -5)
275	require.Error(t, err)
276	assert.Contains(t, err.Error(), "days must be positive")
277}
278
279func TestHistoryRepository_GetAllRecentHistory(t *testing.T) {
280	ctx := historyTestCtx()
281	dbPath := filepath.Join(t.TempDir(), "dumber.db")
282
283	db, err := sqlite.NewConnection(ctx, dbPath)
284	require.NoError(t, err)
285	t.Cleanup(func() { _ = db.Close() })
286
287	repo := sqlite.NewHistoryRepository(db)
288
289	// Save some entries
290	entries := []*entity.HistoryEntry{
291		{URL: "https://example.com", Title: "Example"},
292		{URL: "https://github.com", Title: "GitHub"},
293	}
294	for _, e := range entries {
295		require.NoError(t, repo.Save(ctx, e))
296	}
297
298	// Get all recent history
299	results, err := repo.GetAllRecentHistory(ctx)
300	require.NoError(t, err)
301	require.Len(t, results, 2)
302}
303
304func TestHistoryRepository_GetAllMostVisited(t *testing.T) {
305	ctx := historyTestCtx()
306	dbPath := filepath.Join(t.TempDir(), "dumber.db")
307
308	db, err := sqlite.NewConnection(ctx, dbPath)
309	require.NoError(t, err)
310	t.Cleanup(func() { _ = db.Close() })
311
312	repo := sqlite.NewHistoryRepository(db)
313
314	// Save entries with different visit counts
315	entry1 := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
316	entry2 := &entity.HistoryEntry{URL: "https://github.com", Title: "GitHub"}
317
318	require.NoError(t, repo.Save(ctx, entry1))
319	require.NoError(t, repo.Save(ctx, entry2))
320
321	// Increment github visits
322	for range 3 {
323		require.NoError(t, repo.IncrementVisitCount(ctx, "https://github.com"))
324	}
325
326	// Get all most visited
327	results, err := repo.GetAllMostVisited(ctx)
328	require.NoError(t, err)
329	require.Len(t, results, 2)
330
331	// First should be github (4 visits)
332	assert.Equal(t, "https://github.com", results[0].URL)
333	assert.Equal(t, int64(4), results[0].VisitCount)
334}
335
336func TestHistoryRepository_Search_SingleWord(t *testing.T) {
337	ctx := historyTestCtx()
338	dbPath := filepath.Join(t.TempDir(), "dumber.db")
339
340	db, err := sqlite.NewConnection(ctx, dbPath)
341	require.NoError(t, err)
342	t.Cleanup(func() { _ = db.Close() })
343
344	repo := sqlite.NewHistoryRepository(db)
345
346	// Save entries
347	entries := []*entity.HistoryEntry{
348		{URL: "https://github.com/user/repo", Title: "My GitHub Repository"},
349		{URL: "https://gitlab.com/project", Title: "GitLab Project"},
350		{URL: "https://example.com", Title: "Example Site"},
351	}
352	for _, e := range entries {
353		require.NoError(t, repo.Save(ctx, e))
354	}
355
356	// Search for "github" - should find the github entry
357	results, err := repo.Search(ctx, "github", 10, repository.HistoryScope{})
358	require.NoError(t, err)
359	require.Len(t, results, 1)
360	assert.Equal(t, "https://github.com/user/repo", results[0].Entry.URL)
361}
362
363func TestHistoryRepository_Search_MultiWord(t *testing.T) {
364	ctx := historyTestCtx()
365	dbPath := filepath.Join(t.TempDir(), "dumber.db")
366
367	db, err := sqlite.NewConnection(ctx, dbPath)
368	require.NoError(t, err)
369	t.Cleanup(func() { _ = db.Close() })
370
371	repo := sqlite.NewHistoryRepository(db)
372
373	// Save entries
374	entries := []*entity.HistoryEntry{
375		{URL: "https://github.com/issues", Title: "GitHub Issues"},
376		{URL: "https://github.com/pulls", Title: "GitHub Pull Requests"},
377		{URL: "https://jira.com/issues", Title: "Jira Issues"},
378	}
379	for _, e := range entries {
380		require.NoError(t, repo.Save(ctx, e))
381	}
382
383	// Search for "github issues" - should find only the github issues entry
384	results, err := repo.Search(ctx, "github issues", 10, repository.HistoryScope{})
385	require.NoError(t, err)
386	require.Len(t, results, 1)
387	assert.Equal(t, "https://github.com/issues", results[0].Entry.URL)
388}
389
390func TestHistoryRepository_Search_MatchesTitle(t *testing.T) {
391	ctx := historyTestCtx()
392	dbPath := filepath.Join(t.TempDir(), "dumber.db")
393
394	db, err := sqlite.NewConnection(ctx, dbPath)
395	require.NoError(t, err)
396	t.Cleanup(func() { _ = db.Close() })
397
398	repo := sqlite.NewHistoryRepository(db)
399
400	// Save entry with specific title that doesn't appear in URL
401	entry := &entity.HistoryEntry{
402		URL:   "https://docs.example.com/guide",
403		Title: "Comprehensive Documentation Guide",
404	}
405	require.NoError(t, repo.Save(ctx, entry))
406
407	// Search for "Documentation" - should find by title
408	results, err := repo.Search(ctx, "Documentation", 10, repository.HistoryScope{})
409	require.NoError(t, err)
410	require.Len(t, results, 1)
411	assert.Equal(t, "https://docs.example.com/guide", results[0].Entry.URL)
412}
413
414func TestHistoryRepository_Search_EmptyQuery(t *testing.T) {
415	ctx := historyTestCtx()
416	dbPath := filepath.Join(t.TempDir(), "dumber.db")
417
418	db, err := sqlite.NewConnection(ctx, dbPath)
419	require.NoError(t, err)
420	t.Cleanup(func() { _ = db.Close() })
421
422	repo := sqlite.NewHistoryRepository(db)
423
424	// Save an entry
425	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
426		URL:   "https://example.com",
427		Title: "Example",
428	}))
429
430	// Empty query should return empty results, no error
431	results, err := repo.Search(ctx, "", 10, repository.HistoryScope{})
432	require.NoError(t, err)
433	assert.Empty(t, results)
434
435	// Whitespace-only query should also return empty results
436	results, err = repo.Search(ctx, "   ", 10, repository.HistoryScope{})
437	require.NoError(t, err)
438	assert.Empty(t, results)
439}
440
441func TestHistoryRepository_Search_NoValidTokens(t *testing.T) {
442	ctx := historyTestCtx()
443	dbPath := filepath.Join(t.TempDir(), "dumber.db")
444
445	db, err := sqlite.NewConnection(ctx, dbPath)
446	require.NoError(t, err)
447	t.Cleanup(func() { _ = db.Close() })
448
449	repo := sqlite.NewHistoryRepository(db)
450
451	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
452		URL:   "https://example.com",
453		Title: "Example",
454	}))
455
456	invalidQueries := []string{
457		"!!!",
458		"___",
459		"AND OR NOT NEAR",
460		`"***"`,
461	}
462
463	for _, q := range invalidQueries {
464		results, err := repo.Search(ctx, q, 10, repository.HistoryScope{})
465		require.NoError(t, err, "query %q should return early without SQL errors", q)
466		assert.Empty(t, results, "query %q should have no valid tokens", q)
467	}
468}
469
470func TestHistoryRepository_Search_NoResults(t *testing.T) {
471	ctx := historyTestCtx()
472	dbPath := filepath.Join(t.TempDir(), "dumber.db")
473
474	db, err := sqlite.NewConnection(ctx, dbPath)
475	require.NoError(t, err)
476	t.Cleanup(func() { _ = db.Close() })
477
478	repo := sqlite.NewHistoryRepository(db)
479
480	// Save an entry
481	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
482		URL:   "https://example.com",
483		Title: "Example",
484	}))
485
486	// Search for something that doesn't exist
487	results, err := repo.Search(ctx, "nonexistent", 10, repository.HistoryScope{})
488	require.NoError(t, err)
489	assert.Empty(t, results)
490}
491
492func TestHistoryRepository_Search_PrefixMatching(t *testing.T) {
493	ctx := historyTestCtx()
494	dbPath := filepath.Join(t.TempDir(), "dumber.db")
495
496	db, err := sqlite.NewConnection(ctx, dbPath)
497	require.NoError(t, err)
498	t.Cleanup(func() { _ = db.Close() })
499
500	repo := sqlite.NewHistoryRepository(db)
501
502	// Save entries
503	entries := []*entity.HistoryEntry{
504		{URL: "https://github.com", Title: "GitHub"},
505		{URL: "https://gitlab.com", Title: "GitLab"},
506		{URL: "https://example.com", Title: "Example"},
507	}
508	for _, e := range entries {
509		require.NoError(t, repo.Save(ctx, e))
510	}
511
512	// Search for "git" - should find both github and gitlab (prefix match)
513	results, err := repo.Search(ctx, "git", 10, repository.HistoryScope{})
514	require.NoError(t, err)
515	require.Len(t, results, 2)
516
517	// Verify both git* entries are found
518	urls := make(map[string]bool)
519	for _, r := range results {
520		urls[r.Entry.URL] = true
521	}
522	assert.True(t, urls["https://github.com"])
523	assert.True(t, urls["https://gitlab.com"])
524}
525
526func TestHistoryRepository_Search_DomainLikeQuery(t *testing.T) {
527	ctx := historyTestCtx()
528	dbPath := filepath.Join(t.TempDir(), "dumber.db")
529
530	db, err := sqlite.NewConnection(ctx, dbPath)
531	require.NoError(t, err)
532	t.Cleanup(func() { _ = db.Close() })
533
534	repo := sqlite.NewHistoryRepository(db)
535
536	// Save entries with domain-like URLs
537	entries := []*entity.HistoryEntry{
538		{URL: "https://gordon.bnema.dev/", Title: "Gordon - Self-hosted Deployment Platform"},
539		{URL: "https://example.com", Title: "Example Site"},
540		{URL: "https://other.gordon.io", Title: "Another Gordon Site"},
541	}
542	for _, e := range entries {
543		require.NoError(t, repo.Save(ctx, e))
544	}
545
546	// Search with period in query (domain-like) - periods should be treated as separators
547	// "gordon.bnem" should match "gordon.bnema.dev" (tokens: gordon, bnem -> gordon, bnema)
548	results, err := repo.Search(ctx, "gordon.bnem", 10, repository.HistoryScope{})
549	require.NoError(t, err)
550	require.NotEmpty(t, results, "domain-like query 'gordon.bnem' should match gordon.bnema.dev")
551
552	// Verify the gordon.bnema.dev entry is found
553	found := false
554	for _, r := range results {
555		if r.Entry.URL == "https://gordon.bnema.dev/" {
556			found = true
557			break
558		}
559	}
560	assert.True(t, found, "gordon.bnema.dev should be in results")
561}
562
563func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) {
564	ctx := historyTestCtx()
565	dbPath := filepath.Join(t.TempDir(), "dumber.db")
566
567	db, err := sqlite.NewConnection(ctx, dbPath)
568	require.NoError(t, err)
569	t.Cleanup(func() { _ = db.Close() })
570
571	repo := sqlite.NewHistoryRepository(db)
572
573	// Save entry
574	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
575		URL:   "https://example.com/path",
576		Title: "Example Page",
577	}))
578
579	// Search with FTS5 special characters - should not cause errors
580	specialQueries := []string{
581		"example:",     // colon
582		"example*",     // asterisk
583		"example()",    // parentheses
584		`example"test`, // quotes
585		"example^",     // caret
586		"example-test", // hyphen
587		"example/",     // slash
588	}
589
590	for _, q := range specialQueries {
591		results, err := repo.Search(ctx, q, 10, repository.HistoryScope{})
592		require.NoError(t, err, "query %q should not cause error", q)
593		// May or may not find results, but should not error
594		_ = results
595	}
596}
597
598func TestHistoryRepository_Search_IgnoresFTSOperatorsAsTokens(t *testing.T) {
599	ctx := historyTestCtx()
600	dbPath := filepath.Join(t.TempDir(), "dumber.db")
601
602	db, err := sqlite.NewConnection(ctx, dbPath)
603	require.NoError(t, err)
604	t.Cleanup(func() { _ = db.Close() })
605
606	repo := sqlite.NewHistoryRepository(db)
607
608	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
609		URL:   "https://github.com/bnema/dumber",
610		Title: "Dumber",
611	}))
612
613	results, err := repo.Search(ctx, "AND github OR", 10, repository.HistoryScope{})
614	require.NoError(t, err)
615	require.Len(t, results, 1)
616	assert.Equal(t, "https://github.com/bnema/dumber", results[0].Entry.URL)
617}
618
619func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) {
620	ctx := historyTestCtx()
621	dbPath := filepath.Join(t.TempDir(), "dumber.db")
622
623	db, err := sqlite.NewConnection(ctx, dbPath)
624	require.NoError(t, err)
625	t.Cleanup(func() { _ = db.Close() })
626
627	repo := sqlite.NewHistoryRepository(db)
628
629	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
630		URL:   "https://github.com/bnema/dumber",
631		Title: "Dumber",
632	}))
633
634	results, err := repo.Search(ctx, "github.com/bnema", 10, repository.HistoryScope{})
635	require.NoError(t, err)
636	require.NotEmpty(t, results, "slash-separated query should match history")
637}
638
639func TestHistoryRepository_Search_PrefersURLMatchOverTitleOnlyMatch(t *testing.T) {
640	ctx := historyTestCtx()
641	dbPath := filepath.Join(t.TempDir(), "dumber.db")
642
643	db, err := sqlite.NewConnection(ctx, dbPath)
644	require.NoError(t, err)
645	t.Cleanup(func() { _ = db.Close() })
646
647	repo := sqlite.NewHistoryRepository(db)
648
649	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
650		URL:   "https://github.com/bnema/dumber/issues",
651		Title: "Project board",
652	}))
653	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
654		URL:   "https://tracker.example.com/board",
655		Title: "GitHub Issues Mirror",
656	}))
657
658	results, err := repo.Search(ctx, "github issues", 10, repository.HistoryScope{})
659	require.NoError(t, err)
660	require.Len(t, results, 2)
661	assert.Equal(t, "https://github.com/bnema/dumber/issues", results[0].Entry.URL)
662}
663
664func TestHistoryRepository_Search_PrefersHostPrefixOverTitleContains(t *testing.T) {
665	ctx := historyTestCtx()
666	dbPath := filepath.Join(t.TempDir(), "dumber.db")
667
668	db, err := sqlite.NewConnection(ctx, dbPath)
669	require.NoError(t, err)
670	t.Cleanup(func() { _ = db.Close() })
671
672	repo := sqlite.NewHistoryRepository(db)
673
674	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
675		URL:   "https://github.com",
676		Title: "GitHub",
677	}))
678	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
679		URL:   "https://example.com/landing",
680		Title: "GitHub documentation mirror",
681	}))
682
683	results, err := repo.Search(ctx, "git", 10, repository.HistoryScope{})
684	require.NoError(t, err)
685	require.Len(t, results, 2)
686	assert.Equal(t, "https://github.com", results[0].Entry.URL)
687}
688
689func TestHistoryRepository_Search_PrefersShorterRootURLForHostQuery(t *testing.T) {
690	ctx := historyTestCtx()
691	dbPath := filepath.Join(t.TempDir(), "dumber.db")
692
693	db, err := sqlite.NewConnection(ctx, dbPath)
694	require.NoError(t, err)
695	t.Cleanup(func() { _ = db.Close() })
696
697	repo := sqlite.NewHistoryRepository(db)
698
699	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
700		URL:   "https://github.com",
701		Title: "GitHub",
702	}))
703	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
704		URL:   "https://github.com/bnema/dumber/issues/123",
705		Title: "Issue 123",
706	}))
707
708	results, err := repo.Search(ctx, "github", 10, repository.HistoryScope{})
709	require.NoError(t, err)
710	require.Len(t, results, 2)
711	assert.Equal(t, "https://github.com", results[0].Entry.URL)
712}
713
714func TestHistoryRepository_UpdateMetadata_DoesNotIncrementVisitCount(t *testing.T) {
715	ctx := historyTestCtx()
716	dbPath := filepath.Join(t.TempDir(), "dumber.db")
717
718	db, err := sqlite.NewConnection(ctx, dbPath)
719	require.NoError(t, err)
720	t.Cleanup(func() { _ = db.Close() })
721
722	repoIface := sqlite.NewHistoryRepository(db)
723	repo, ok := repoIface.(interface {
724		Save(context.Context, *entity.HistoryEntry) error
725		FindByURL(context.Context, string) (*entity.HistoryEntry, error)
726		UpdateMetadata(context.Context, *entity.HistoryEntry) error
727	})
728	require.True(t, ok)
729
730	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
731		URL:   "https://github.com/bnema/dumber",
732		Title: "Old title",
733	}))
734
735	before, err := repo.FindByURL(ctx, "https://github.com/bnema/dumber")
736	require.NoError(t, err)
737	require.NotNil(t, before)
738	require.Equal(t, int64(1), before.VisitCount)
739
740	require.NoError(t, repo.UpdateMetadata(ctx, &entity.HistoryEntry{
741		URL:   "https://github.com/bnema/dumber",
742		Title: "New title",
743	}))
744
745	after, err := repo.FindByURL(ctx, "https://github.com/bnema/dumber")
746	require.NoError(t, err)
747	require.NotNil(t, after)
748	assert.Equal(t, int64(1), after.VisitCount)
749	assert.Equal(t, "New title", after.Title)
750}
751
752func TestHistoryRepository_AboutBlank_CappedVisitCount(t *testing.T) {
753	ctx := historyTestCtx()
754	dbPath := filepath.Join(t.TempDir(), "dumber.db")
755
756	db, err := sqlite.NewConnection(ctx, dbPath)
757	require.NoError(t, err)
758	t.Cleanup(func() { _ = db.Close() })
759
760	repo := sqlite.NewHistoryRepository(db)
761
762	// Save about:blank multiple times - visit count should stay at 1
763	for range 5 {
764		require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
765			URL:   "about:blank",
766			Title: "New Tab",
767		}))
768	}
769
770	// Verify about:blank has visit_count = 1
771	found, err := repo.FindByURL(ctx, "about:blank")
772	require.NoError(t, err)
773	require.NotNil(t, found)
774	assert.Equal(t, int64(1), found.VisitCount, "about:blank should always have visit_count = 1")
775
776	// Try incrementing visit count directly - should be ignored
777	require.NoError(t, repo.IncrementVisitCount(ctx, "about:blank"))
778
779	found2, err := repo.FindByURL(ctx, "about:blank")
780	require.NoError(t, err)
781	assert.Equal(t, int64(1), found2.VisitCount, "about:blank increment should be ignored")
782}
783
784func TestHistoryRepository_AboutBlank_NotDominant(t *testing.T) {
785	ctx := historyTestCtx()
786	dbPath := filepath.Join(t.TempDir(), "dumber.db")
787
788	db, err := sqlite.NewConnection(ctx, dbPath)
789	require.NoError(t, err)
790	t.Cleanup(func() { _ = db.Close() })
791
792	repo := sqlite.NewHistoryRepository(db)
793
794	// Save about:blank many times
795	for range 10 {
796		require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
797			URL:   "about:blank",
798			Title: "New Tab",
799		}))
800	}
801
802	// Save a regular entry with multiple visits
803	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
804		URL:   "https://example.com",
805		Title: "Example",
806	}))
807	require.NoError(t, repo.IncrementVisitCount(ctx, "https://example.com"))
808	require.NoError(t, repo.IncrementVisitCount(ctx, "https://example.com"))
809
810	// Get most visited - example.com should be first (3 visits vs 1)
811	results, err := repo.GetAllMostVisited(ctx)
812	require.NoError(t, err)
813	require.Len(t, results, 2)
814
815	assert.Equal(t, "https://example.com", results[0].URL, "example.com should be first with higher visit count")
816	assert.Equal(t, int64(3), results[0].VisitCount)
817
818	assert.Equal(t, "about:blank", results[1].URL, "about:blank should be second with capped visit count")
819	assert.Equal(t, int64(1), results[1].VisitCount)
820}
821
822func TestHistoryRepository_Search_ReturnsEmptyForNonPositiveLimit(t *testing.T) {
823	ctx := historyTestCtx()
824	dbPath := filepath.Join(t.TempDir(), "dumber.db")
825
826	db, err := sqlite.NewConnection(ctx, dbPath)
827	require.NoError(t, err)
828	t.Cleanup(func() { _ = db.Close() })
829
830	repo := sqlite.NewHistoryRepository(db)
831	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
832		URL:   "https://github.com",
833		Title: "GitHub",
834	}))
835
836	results, err := repo.Search(ctx, "git", 0, repository.HistoryScope{})
837	require.NoError(t, err)
838	assert.Empty(t, results)
839
840	results, err = repo.Search(ctx, "git", -1, repository.HistoryScope{})
841	require.NoError(t, err)
842	assert.Empty(t, results)
843}
844
845func TestHistoryRepository_RejectsEmptyCanonicalDomain(t *testing.T) {
846	ctx := historyTestCtx()
847	dbPath := filepath.Join(t.TempDir(), "dumber.db")
848
849	db, err := sqlite.NewConnection(ctx, dbPath)
850	require.NoError(t, err)
851	t.Cleanup(func() { _ = db.Close() })
852
853	repo := sqlite.NewHistoryRepository(db)
854
855	entries, err := repo.GetRecentByDomain(ctx, " ", 10, 0)
856	require.Error(t, err)
857	assert.Nil(t, entries)
858	assert.Error(t, repo.DeleteByDomain(ctx, "/path"))
859}
860
861func TestHistoryRepository_DomainColumnPowersFilteringStatsAndDelete(t *testing.T) {
862	ctx := historyTestCtx()
863	dbPath := filepath.Join(t.TempDir(), "dumber.db")
864
865	db, err := sqlite.NewConnection(ctx, dbPath)
866	require.NoError(t, err)
867	t.Cleanup(func() { _ = db.Close() })
868
869	repo := sqlite.NewHistoryRepository(db)
870	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://www.example.com/a", Title: "Example A"}))
871	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://example.com/b", Title: "Example B"}))
872	require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://other.test", Title: "Other"}))
873
874	exampleEntries, err := repo.GetRecentByDomain(ctx, "www.example.com", 0, 0)
875	require.NoError(t, err)
876	require.Len(t, exampleEntries, 2)
877	for _, entry := range exampleEntries {
878		assert.Contains(t, entry.URL, "example.com")
879	}
880
881	stats, err := repo.GetDomainStats(ctx, 10)
882	require.NoError(t, err)
883	require.NotEmpty(t, stats)
884	assert.Equal(t, "example.com", stats[0].Domain)
885	assert.Equal(t, int64(2), stats[0].PageCount)
886
887	require.NoError(t, repo.DeleteByDomain(ctx, "example.com"))
888
889	exampleEntries, err = repo.GetRecentByDomain(ctx, "example.com", 10, 0)
890	require.NoError(t, err)
891	assert.Empty(t, exampleEntries)
892
893	remaining, err := repo.GetAllRecentHistory(ctx)
894	require.NoError(t, err)
895	require.Len(t, remaining, 1)
896	assert.Equal(t, "https://other.test", remaining[0].URL)
897}
898func TestHistoryRepository_GetRecentWindow_StableCursorWithTimestampTies(t *testing.T) {
899	ctx := historyTestCtx()
900	dbPath := filepath.Join(t.TempDir(), "dumber.db")
901
902	db, err := sqlite.NewConnection(ctx, dbPath)
903	require.NoError(t, err)
904	t.Cleanup(func() { _ = db.Close() })
905
906	repo := sqlite.NewHistoryRepository(db)
907	tied := time.Date(2026, 6, 24, 10, 0, 0, 0, time.UTC)
908	older := tied.Add(-time.Hour)
909	for i := 1; i <= 5; i++ {
910		_, err = db.ExecContext(ctx, `INSERT INTO history (url, title, domain, last_visited, created_at) VALUES (?, ?, ?, ?, ?)`,
911			fmt.Sprintf("https://example.com/%d", i), fmt.Sprintf("Entry %d", i), "example.com", tied, tied)
912		require.NoError(t, err)
913	}
914	_, err = db.ExecContext(ctx, `INSERT INTO history (url, title, domain, last_visited, created_at) VALUES (?, ?, ?, ?, ?)`,
915		"https://example.com/older", "Older", "example.com", older, older)
916	require.NoError(t, err)
917
918	page1, err := repo.GetRecentWindow(ctx, tied.Add(time.Second), 0, 3)
919	require.NoError(t, err)
920	require.Len(t, page1, 3)
921	assert.Equal(t, []int64{5, 4, 3}, historyIDs(page1))
922
923	page2, err := repo.GetRecentWindow(ctx, page1[len(page1)-1].LastVisited, page1[len(page1)-1].ID, 3)
924	require.NoError(t, err)
925	require.Len(t, page2, 3)
926	assert.Equal(t, []int64{2, 1, 6}, historyIDs(page2))
927
928	seen := map[int64]bool{}
929	for _, entry := range append(page1, page2...) {
930		if seen[entry.ID] {
931			t.Fatalf("duplicate history id %d", entry.ID)
932		}
933		seen[entry.ID] = true
934	}
935}
936
937func historyIDs(entries []*entity.HistoryEntry) []int64 {
938	ids := make([]int64, len(entries))
939	for i, entry := range entries {
940		ids[i] = entry.ID
941	}
942	return ids
943}
944
945// insertHistoryRow seeds a history row with an explicit visit count and
946// last_visited so age-scope tests can use stable, non-now timestamps. The
947// timestamp is written in the same UTC second-precision format that SQLite's
948// CURRENT_TIMESTAMP uses in production.
949func insertHistoryRow(
950	t *testing.T,
951	ctx context.Context,
952	db *sql.DB,
953	url, title string,
954	visitCount int,
955	lastVisited time.Time,
956) {
957	t.Helper()
958	timestamp := sqliteTimestamp(lastVisited)
959	_, err := db.ExecContext(ctx,
960		`INSERT INTO history (url, title, last_visited, created_at, visit_count) VALUES (?, ?, ?, ?, ?)`,
961		url, title, timestamp, timestamp, visitCount)
962	require.NoError(t, err)
963}
964
965// sqliteTimestamp formats a time the way SQLite's CURRENT_TIMESTAMP stores it:
966// UTC at second precision without a timezone suffix.
967func sqliteTimestamp(t time.Time) string {
968	return t.UTC().Format("2006-01-02 15:04:05")
969}
970
971func TestHistoryRepository_GetMostVisitedWithin_ExcludesOldHighCountURL(t *testing.T) {
972	ctx := historyTestCtx()
973	db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
974	require.NoError(t, err)
975	t.Cleanup(func() { _ = db.Close() })
976	repo := sqlite.NewHistoryRepository(db)
977
978	now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
979	cutoff := now.AddDate(0, 0, -30)
980	insertHistoryRow(t, ctx, db, "https://old.example.com", "Old Popular", 50, now.AddDate(0, 0, -40))
981	insertHistoryRow(t, ctx, db, "https://recent.example.com", "Recent", 2, now.AddDate(0, 0, -1))
982
983	results, err := repo.GetMostVisitedWithin(ctx, 10, repository.HistoryScope{Cutoff: cutoff})
984	require.NoError(t, err)
985	require.Len(t, results, 1)
986	assert.Equal(t, "https://recent.example.com", results[0].URL)
987
988	// A zero cutoff means all stored history, so the old popular URL returns.
989	all, err := repo.GetMostVisitedWithin(ctx, 10, repository.HistoryScope{})
990	require.NoError(t, err)
991	require.Len(t, all, 2)
992	assert.Equal(t, "https://old.example.com", all[0].URL)
993	assert.Equal(t, int64(50), all[0].VisitCount)
994}
995
996func TestHistoryRepository_GetMostVisitedWithin_BoundsResults(t *testing.T) {
997	ctx := historyTestCtx()
998	db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
999	require.NoError(t, err)
1000	t.Cleanup(func() { _ = db.Close() })
1001	repo := sqlite.NewHistoryRepository(db)
1002
1003	now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1004	insertHistoryRow(t, ctx, db, "https://one.example.com", "One", 5, now.Add(-time.Hour))
1005	insertHistoryRow(t, ctx, db, "https://two.example.com", "Two", 3, now.Add(-2*time.Hour))
1006	insertHistoryRow(t, ctx, db, "https://three.example.com", "Three", 1, now.Add(-3*time.Hour))
1007
1008	results, err := repo.GetMostVisitedWithin(ctx, 2, repository.HistoryScope{})
1009	require.NoError(t, err)
1010	require.Len(t, results, 2)
1011	assert.Equal(t, []string{"https://one.example.com", "https://two.example.com"}, historyURLs(results))
1012}
1013
1014func TestHistoryRepository_GetRecent_ScopeExcludesOldEntries(t *testing.T) {
1015	ctx := historyTestCtx()
1016	db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1017	require.NoError(t, err)
1018	t.Cleanup(func() { _ = db.Close() })
1019	repo := sqlite.NewHistoryRepository(db)
1020
1021	now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1022	cutoff := now.AddDate(0, 0, -30)
1023	insertHistoryRow(t, ctx, db, "https://old.example.com", "Old", 1, now.AddDate(0, 0, -40))
1024	insertHistoryRow(t, ctx, db, "https://recent.example.com", "Recent", 1, now.AddDate(0, 0, -1))
1025
1026	scoped, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{Cutoff: cutoff})
1027	require.NoError(t, err)
1028	require.Len(t, scoped, 1)
1029	assert.Equal(t, "https://recent.example.com", scoped[0].URL)
1030
1031	all, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{})
1032	require.NoError(t, err)
1033	require.Len(t, all, 2)
1034
1035	bounded, err := repo.GetRecent(ctx, 1, 0, repository.HistoryScope{})
1036	require.NoError(t, err)
1037	require.Len(t, bounded, 1)
1038}
1039
1040func TestHistoryRepository_Search_ScopeExcludesOldTitleOnlyMatch(t *testing.T) {
1041	ctx := historyTestCtx()
1042	db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1043	require.NoError(t, err)
1044	t.Cleanup(func() { _ = db.Close() })
1045	repo := sqlite.NewHistoryRepository(db)
1046
1047	now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1048	cutoff := now.AddDate(0, 0, -30)
1049	// Matches only in the title, but is older than the window.
1050	insertHistoryRow(t, ctx, db, "https://old.example.com/ancient", "Widget Old Guide", 1, now.AddDate(0, 0, -40))
1051	// Matches in the title and is inside the window.
1052	insertHistoryRow(t, ctx, db, "https://recent.example.com/guide", "Widget Recent Guide", 1, now.AddDate(0, 0, -1))
1053	// Matches in the URL, but is older than the window.
1054	insertHistoryRow(t, ctx, db, "https://widget.example.com/page", "No match here", 1, now.AddDate(0, 0, -40))
1055
1056	scoped, err := repo.Search(ctx, "widget", 10, repository.HistoryScope{Cutoff: cutoff})
1057	require.NoError(t, err)
1058	require.Len(t, scoped, 1)
1059	assert.Equal(t, "https://recent.example.com/guide", scoped[0].Entry.URL)
1060
1061	// A zero cutoff keeps the general, unrestricted search behavior.
1062	all, err := repo.Search(ctx, "widget", 10, repository.HistoryScope{})
1063	require.NoError(t, err)
1064	require.Len(t, all, 3)
1065}
1066
1067func historyURLs(entries []*entity.HistoryEntry) []string {
1068	urls := make([]string, len(entries))
1069	for i, entry := range entries {
1070		urls[i] = entry.URL
1071	}
1072	return urls
1073}
1074
1075func TestHistoryRepository_ScopeCutoffIsInclusiveAtSecondPrecision(t *testing.T) {
1076	ctx := historyTestCtx()
1077	db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1078	require.NoError(t, err)
1079	t.Cleanup(func() { _ = db.Close() })
1080	repo := sqlite.NewHistoryRepository(db)
1081
1082	cutoff := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1083	insertHistoryRow(t, ctx, db, "https://exact.example.com", "Exact", 1, cutoff)
1084	insertHistoryRow(t, ctx, db, "https://before.example.com", "Before", 1, cutoff.Add(-time.Second))
1085
1086	results, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{Cutoff: cutoff})
1087	require.NoError(t, err)
1088	require.Len(t, results, 1, "the entry exactly on the cutoff must be included")
1089	assert.Equal(t, "https://exact.example.com", results[0].URL)
1090}
1091
1092func TestHistoryRepository_UnboundedScopePreservesNullLastVisited(t *testing.T) {
1093	ctx := historyTestCtx()
1094	db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1095	require.NoError(t, err)
1096	t.Cleanup(func() { _ = db.Close() })
1097	repo := sqlite.NewHistoryRepository(db)
1098
1099	_, err = db.ExecContext(
1100		ctx,
1101		`INSERT INTO history (url, title, last_visited, visit_count) VALUES (?, ?, NULL, ?)`,
1102		"https://null-last-visited.example.com",
1103		"Legacy Null Row",
1104		1,
1105	)
1106	require.NoError(t, err)
1107
1108	cutoff := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1109
1110	recent, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{})
1111	require.NoError(t, err)
1112	require.Len(t, recent, 1, "unbounded recent history must keep NULL last_visited rows")
1113
1114	mostVisited, err := repo.GetMostVisitedWithin(ctx, 10, repository.HistoryScope{})
1115	require.NoError(t, err)
1116	require.Len(t, mostVisited, 1, "unbounded most-visited must keep NULL last_visited rows")
1117
1118	matches, err := repo.Search(ctx, "legacy", 10, repository.HistoryScope{})
1119	require.NoError(t, err)
1120	require.Len(t, matches, 1, "unbounded search must keep NULL last_visited rows")
1121
1122	scoped, err := repo.GetRecent(ctx, 10, 0, repository.HistoryScope{Cutoff: cutoff})
1123	require.NoError(t, err)
1124	assert.Empty(t, scoped, "a bounded window must not include rows without a last_visited timestamp")
1125}
1126
1127func TestHistoryRepository_Search_FiltersBeforeLimitOnURLMatch(t *testing.T) {
1128	ctx := historyTestCtx()
1129	db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1130	require.NoError(t, err)
1131	t.Cleanup(func() { _ = db.Close() })
1132	repo := sqlite.NewHistoryRepository(db)
1133
1134	now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1135	cutoff := now.AddDate(0, 0, -30)
1136	// The old row outranks the recent row (higher visit count and domain boost),
1137	// so it would consume the single LIMIT slot if filtering happened after LIMIT.
1138	insertHistoryRow(t, ctx, db, "https://urlsecret.example.com/", "Old", 50, now.AddDate(0, 0, -40))
1139	insertHistoryRow(t, ctx, db, "https://urlsecret-recent.example.com/", "Recent", 1, now.AddDate(0, 0, -1))
1140
1141	results, err := repo.Search(ctx, "urlsecret", 1, repository.HistoryScope{Cutoff: cutoff})
1142	require.NoError(t, err)
1143	require.Len(t, results, 1, "URL FTS must filter by age before applying LIMIT")
1144	assert.Equal(t, "https://urlsecret-recent.example.com/", results[0].Entry.URL)
1145
1146	// Without a scope the old high-priority row wins the same query.
1147	all, err := repo.Search(ctx, "urlsecret", 10, repository.HistoryScope{})
1148	require.NoError(t, err)
1149	require.Len(t, all, 2)
1150	assert.Equal(t, "https://urlsecret.example.com/", all[0].Entry.URL)
1151}
1152
1153func TestHistoryRepository_Search_FiltersBeforeLimitOnTitleMatch(t *testing.T) {
1154	ctx := historyTestCtx()
1155	db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1156	require.NoError(t, err)
1157	t.Cleanup(func() { _ = db.Close() })
1158	repo := sqlite.NewHistoryRepository(db)
1159
1160	now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1161	cutoff := now.AddDate(0, 0, -30)
1162	// The query only matches titles, so only the title FTS path is exercised.
1163	// The old row has the higher visit count and would win an unfiltered LIMIT.
1164	insertHistoryRow(t, ctx, db, "https://old.example.com/ancient", "Titlesecret Old Guide", 50, now.AddDate(0, 0, -40))
1165	insertHistoryRow(t, ctx, db, "https://recent.example.com/entry", "Titlesecret Recent Guide", 1, now.AddDate(0, 0, -1))
1166
1167	results, err := repo.Search(ctx, "titlesecret", 1, repository.HistoryScope{Cutoff: cutoff})
1168	require.NoError(t, err)
1169	require.Len(t, results, 1, "title FTS must filter by age before applying LIMIT")
1170	assert.Equal(t, "https://recent.example.com/entry", results[0].Entry.URL)
1171
1172	all, err := repo.Search(ctx, "titlesecret", 10, repository.HistoryScope{})
1173	require.NoError(t, err)
1174	require.Len(t, all, 2)
1175}
1176
1177func TestHistoryRepository_GetRecent_BoundedLimitOffsetAndCutoff(t *testing.T) {
1178	ctx := historyTestCtx()
1179	db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1180	require.NoError(t, err)
1181	t.Cleanup(func() { _ = db.Close() })
1182	repo := sqlite.NewHistoryRepository(db)
1183
1184	now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1185	cutoff := now.AddDate(0, 0, -30)
1186	insertHistoryRow(t, ctx, db, "https://one.example.com", "One", 1, now.Add(-time.Hour))
1187	insertHistoryRow(t, ctx, db, "https://two.example.com", "Two", 1, now.Add(-2*time.Hour))
1188	insertHistoryRow(t, ctx, db, "https://three.example.com", "Three", 1, now.Add(-3*time.Hour))
1189	insertHistoryRow(t, ctx, db, "https://stale.example.com", "Stale", 1, now.AddDate(0, 0, -40))
1190
1191	scope := repository.HistoryScope{Cutoff: cutoff}
1192
1193	page, err := repo.GetRecent(ctx, 1, 1, scope)
1194	require.NoError(t, err)
1195	require.Len(t, page, 1)
1196	assert.Equal(t, "https://two.example.com", page[0].URL)
1197
1198	// A non-positive limit means all eligible rows, with the offset applied and
1199	// the stale row excluded by the cutoff.
1200	rest, err := repo.GetRecent(ctx, 0, 1, scope)
1201	require.NoError(t, err)
1202	require.Len(t, rest, 2)
1203	assert.Equal(t, []string{"https://two.example.com", "https://three.example.com"}, historyURLs(rest))
1204}
1205
1206func TestHistoryRepository_Search_BoundedScopeEmptyResult(t *testing.T) {
1207	ctx := historyTestCtx()
1208	db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1209	require.NoError(t, err)
1210	t.Cleanup(func() { _ = db.Close() })
1211	repo := sqlite.NewHistoryRepository(db)
1212
1213	now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1214	cutoff := now.AddDate(0, 0, -30)
1215	insertHistoryRow(t, ctx, db, "https://old.example.com/ancient", "Ephemeral Secret", 1, now.AddDate(0, 0, -40))
1216
1217	scoped, err := repo.Search(ctx, "ephemeral", 10, repository.HistoryScope{Cutoff: cutoff})
1218	require.NoError(t, err)
1219	assert.Empty(t, scoped, "a bounded search must not return age-excluded matches")
1220
1221	all, err := repo.Search(ctx, "ephemeral", 10, repository.HistoryScope{})
1222	require.NoError(t, err)
1223	require.Len(t, all, 1)
1224}
1225
1226func TestHistoryRepository_GetMostVisitedWithin_NonPositiveLimitIsEmpty(t *testing.T) {
1227	ctx := historyTestCtx()
1228	db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1229	require.NoError(t, err)
1230	t.Cleanup(func() { _ = db.Close() })
1231	repo := sqlite.NewHistoryRepository(db)
1232
1233	now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1234	insertHistoryRow(t, ctx, db, "https://example.com", "Example", 5, now.Add(-time.Hour))
1235
1236	unbounded, err := repo.GetMostVisitedWithin(ctx, 0, repository.HistoryScope{})
1237	require.NoError(t, err)
1238	assert.Empty(t, unbounded)
1239
1240	bounded, err := repo.GetMostVisitedWithin(ctx, -5, repository.HistoryScope{Cutoff: now.AddDate(0, 0, -30)})
1241	require.NoError(t, err)
1242	assert.Empty(t, bounded)
1243}
1244
1245// queryPlan records the actual driver's EXPLAIN QUERY PLAN output for a query.
1246func queryPlan(t *testing.T, ctx context.Context, db *sql.DB, query string, args ...any) []string {
1247	t.Helper()
1248
1249	rows, err := db.QueryContext(ctx, "EXPLAIN QUERY PLAN "+query, args...)
1250	require.NoError(t, err)
1251	defer func() { _ = rows.Close() }()
1252
1253	var details []string
1254	for rows.Next() {
1255		var id, parent, notUsed int
1256		var detail string
1257		require.NoError(t, rows.Scan(&id, &parent, &notUsed, &detail))
1258		details = append(details, detail)
1259	}
1260	require.NoError(t, rows.Err())
1261	return details
1262}
1263
1264func TestHistoryRepository_BoundedRecentPlanUsesCutoffIndex(t *testing.T) {
1265	ctx := historyTestCtx()
1266	db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1267	require.NoError(t, err)
1268	t.Cleanup(func() { _ = db.Close() })
1269
1270	// Exercise the exact generated statement that historyRepo.GetRecent runs for
1271	// a bounded scope (see GetRecentHistorySinceCutoff), passed verbatim including
1272	// sqlc's leading `-- name:` comment. This fails to compile if the bounded
1273	// variant disappears, and the plan assertions fail if it regresses to a
1274	// nullable `(? IS NULL OR last_visited >= ?)` predicate, which plans as a
1275	// SCAN using idx_history_last_visited rather than a SEARCH.
1276	cutoff := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
1277	plan := queryPlan(t, ctx, db, sqlc.GetRecentHistorySinceCutoff, sql.NullTime{Time: cutoff.UTC(), Valid: true}, 0, 50)
1278
1279	joined := strings.Join(plan, "\n")
1280	require.Contains(t, joined, "SEARCH", "bounded recent query must use an index search")
1281	require.Contains(t, joined, "idx_history_last_visited", "bounded recent query must use the last_visited index")
1282}
1283
1284func TestHistoryRepository_ScopeCutoffSubSecondNormalization(t *testing.T) {
1285	ctx := historyTestCtx()
1286	db, err := sqlite.NewConnection(ctx, filepath.Join(t.TempDir(), "dumber.db"))
1287	require.NoError(t, err)
1288	t.Cleanup(func() { _ = db.Close() })
1289	repo := sqlite.NewHistoryRepository(db)
1290
1291	// Stored at exactly 12:00:05 with no fractional part, matching
1292	// CURRENT_TIMESTAMP.
1293	stored := time.Date(2026, 6, 24, 12, 0, 5, 0, time.UTC)
1294	insertHistoryRow(t, ctx, db, "https://subsecond.example.com/marker", "Subsecond Marker", 1, stored)
1295	scope := repository.HistoryScope{Cutoff: stored.Add(500 * time.Millisecond)}
1296
1297	recent, err := repo.GetRecent(ctx, 10, 0, scope)
1298	require.NoError(t, err)
1299	require.Len(t, recent, 1, "a sub-second cutoff must normalize to the stored second and include the row")
1300	assert.Equal(t, "https://subsecond.example.com/marker", recent[0].URL)
1301
1302	mostVisited, err := repo.GetMostVisitedWithin(ctx, 10, scope)
1303	require.NoError(t, err)
1304	require.Len(t, mostVisited, 1)
1305
1306	matches, err := repo.Search(ctx, "subsecond", 10, scope)
1307	require.NoError(t, err)
1308	require.Len(t, matches, 1, "the shared FTS cutoff helper must apply the same normalization")
1309	assert.Equal(t, "https://subsecond.example.com/marker", matches[0].Entry.URL)
1310}

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