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}