1package sqlite_test
2
3import (
4 "context"
5 "path/filepath"
6 "testing"
7 "time"
8
9 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
11 "github.com/bnema/dumber/internal/logging"
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14)
15
16func historyTestCtx() context.Context {
17 logger := logging.NewFromConfigValues("debug", "console")
18 return logging.WithContext(context.Background(), logger)
19}
20
21func TestHistoryRepository_GetRecentSince(t *testing.T) {
22 ctx := historyTestCtx()
23 dbPath := filepath.Join(t.TempDir(), "dumber.db")
24
25 db, err := sqlite.NewConnection(ctx, dbPath)
26 require.NoError(t, err)
27 t.Cleanup(func() { _ = db.Close() })
28
29 repo := sqlite.NewHistoryRepository(db)
30
31 // Save some history entries
32 entries := []*entity.HistoryEntry{
33 {URL: "https://example.com", Title: "Example"},
34 {URL: "https://github.com", Title: "GitHub"},
35 {URL: "https://google.com", Title: "Google"},
36 }
37
38 for _, e := range entries {
39 require.NoError(t, repo.Save(ctx, e))
40 }
41
42 // Get recent since 30 days (all should be included)
43 results, err := repo.GetRecentSince(ctx, 30)
44 require.NoError(t, err)
45 require.Len(t, results, 3)
46
47 // Results should be sorted by last_visited DESC
48 // Since they were all just inserted, order may vary
49 urls := make(map[string]bool)
50 for _, r := range results {
51 urls[r.URL] = true
52 }
53 assert.True(t, urls["https://example.com"])
54 assert.True(t, urls["https://github.com"])
55 assert.True(t, urls["https://google.com"])
56}
57
58func TestHistoryRepository_GetRecentSince_EmptyResult(t *testing.T) {
59 ctx := historyTestCtx()
60 dbPath := filepath.Join(t.TempDir(), "dumber.db")
61
62 db, err := sqlite.NewConnection(ctx, dbPath)
63 require.NoError(t, err)
64 t.Cleanup(func() { _ = db.Close() })
65
66 repo := sqlite.NewHistoryRepository(db)
67
68 // Get recent with no data
69 results, err := repo.GetRecentSince(ctx, 30)
70 require.NoError(t, err)
71 assert.Empty(t, results)
72}
73
74func TestHistoryRepository_GetMostVisited(t *testing.T) {
75 ctx := historyTestCtx()
76 dbPath := filepath.Join(t.TempDir(), "dumber.db")
77
78 db, err := sqlite.NewConnection(ctx, dbPath)
79 require.NoError(t, err)
80 t.Cleanup(func() { _ = db.Close() })
81
82 repo := sqlite.NewHistoryRepository(db)
83
84 // Save entries with different visit counts
85 entry1 := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
86 entry2 := &entity.HistoryEntry{URL: "https://github.com", Title: "GitHub"}
87 entry3 := &entity.HistoryEntry{URL: "https://google.com", Title: "Google"}
88
89 require.NoError(t, repo.Save(ctx, entry1))
90 require.NoError(t, repo.Save(ctx, entry2))
91 require.NoError(t, repo.Save(ctx, entry3))
92
93 // Increment visit counts to create different popularities
94 // github: 5 visits, google: 3 visits, example: 1 visit
95 for i := 0; i < 4; i++ {
96 require.NoError(t, repo.IncrementVisitCount(ctx, "https://github.com"))
97 }
98 for i := 0; i < 2; i++ {
99 require.NoError(t, repo.IncrementVisitCount(ctx, "https://google.com"))
100 }
101
102 // Get most visited
103 results, err := repo.GetMostVisited(ctx, 30)
104 require.NoError(t, err)
105 require.Len(t, results, 3)
106
107 // First result should be github (5 visits)
108 assert.Equal(t, "https://github.com", results[0].URL)
109 assert.Equal(t, int64(5), results[0].VisitCount)
110
111 // Second should be google (3 visits)
112 assert.Equal(t, "https://google.com", results[1].URL)
113 assert.Equal(t, int64(3), results[1].VisitCount)
114
115 // Third should be example (1 visit)
116 assert.Equal(t, "https://example.com", results[2].URL)
117 assert.Equal(t, int64(1), results[2].VisitCount)
118}
119
120func TestHistoryRepository_GetMostVisited_EmptyResult(t *testing.T) {
121 ctx := historyTestCtx()
122 dbPath := filepath.Join(t.TempDir(), "dumber.db")
123
124 db, err := sqlite.NewConnection(ctx, dbPath)
125 require.NoError(t, err)
126 t.Cleanup(func() { _ = db.Close() })
127
128 repo := sqlite.NewHistoryRepository(db)
129
130 // Get most visited with no data
131 results, err := repo.GetMostVisited(ctx, 30)
132 require.NoError(t, err)
133 assert.Empty(t, results)
134}
135
136func TestHistoryRepository_GetRecentSince_RespectsTimeFilter(t *testing.T) {
137 ctx := historyTestCtx()
138 dbPath := filepath.Join(t.TempDir(), "dumber.db")
139
140 db, err := sqlite.NewConnection(ctx, dbPath)
141 require.NoError(t, err)
142 t.Cleanup(func() { _ = db.Close() })
143
144 repo := sqlite.NewHistoryRepository(db)
145
146 // Save an entry
147 entry := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
148 require.NoError(t, repo.Save(ctx, entry))
149
150 // Query with 1 day filter - should include recently added entry
151 results, err := repo.GetRecentSince(ctx, 1)
152 require.NoError(t, err)
153 require.Len(t, results, 1)
154
155 // Query with very large days filter - should still work
156 results, err = repo.GetRecentSince(ctx, 365)
157 require.NoError(t, err)
158 require.Len(t, results, 1)
159}
160
161func TestHistoryRepository_CRUD(t *testing.T) {
162 ctx := historyTestCtx()
163 dbPath := filepath.Join(t.TempDir(), "dumber.db")
164
165 db, err := sqlite.NewConnection(ctx, dbPath)
166 require.NoError(t, err)
167 t.Cleanup(func() { _ = db.Close() })
168
169 repo := sqlite.NewHistoryRepository(db)
170
171 // Create
172 entry := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
173 require.NoError(t, repo.Save(ctx, entry))
174
175 // Read
176 found, err := repo.FindByURL(ctx, "https://example.com")
177 require.NoError(t, err)
178 require.NotNil(t, found)
179 assert.Equal(t, "https://example.com", found.URL)
180 assert.Equal(t, "Example", found.Title)
181 assert.Equal(t, int64(1), found.VisitCount)
182
183 // Update (save again increments visit count)
184 require.NoError(t, repo.Save(ctx, entry))
185 found2, err := repo.FindByURL(ctx, "https://example.com")
186 require.NoError(t, err)
187 assert.Equal(t, int64(2), found2.VisitCount)
188
189 // Get recent
190 recent, err := repo.GetRecent(ctx, 10, 0)
191 require.NoError(t, err)
192 require.Len(t, recent, 1)
193
194 // Delete
195 require.NoError(t, repo.Delete(ctx, found.ID))
196
197 // Verify deleted
198 deleted, err := repo.FindByURL(ctx, "https://example.com")
199 require.NoError(t, err)
200 assert.Nil(t, deleted)
201}
202
203// Ensure the test uses an isolated timestamp for time-sensitive queries
204func TestHistoryRepository_TimeFilterIntegrity(t *testing.T) {
205 ctx := historyTestCtx()
206 dbPath := filepath.Join(t.TempDir(), "dumber.db")
207
208 db, err := sqlite.NewConnection(ctx, dbPath)
209 require.NoError(t, err)
210 t.Cleanup(func() { _ = db.Close() })
211
212 repo := sqlite.NewHistoryRepository(db)
213
214 // Save entry
215 entry := &entity.HistoryEntry{URL: "https://test.com", Title: "Test"}
216 require.NoError(t, repo.Save(ctx, entry))
217
218 // Verify entry was saved with recent timestamp
219 found, err := repo.FindByURL(ctx, "https://test.com")
220 require.NoError(t, err)
221 require.NotNil(t, found)
222
223 // LastVisited should be within the last minute
224 timeDiff := time.Since(found.LastVisited)
225 assert.Less(t, timeDiff, time.Minute, "LastVisited should be recent, got %v ago", timeDiff)
226}
227
228func TestHistoryRepository_GetRecentSince_RejectsNonPositiveDays(t *testing.T) {
229 ctx := historyTestCtx()
230 dbPath := filepath.Join(t.TempDir(), "dumber.db")
231
232 db, err := sqlite.NewConnection(ctx, dbPath)
233 require.NoError(t, err)
234 t.Cleanup(func() { _ = db.Close() })
235
236 repo := sqlite.NewHistoryRepository(db)
237
238 // days = 0 should return error
239 _, err = repo.GetRecentSince(ctx, 0)
240 require.Error(t, err)
241 assert.Contains(t, err.Error(), "days must be positive")
242
243 // days < 0 should return error
244 _, err = repo.GetRecentSince(ctx, -5)
245 require.Error(t, err)
246 assert.Contains(t, err.Error(), "days must be positive")
247}
248
249func TestHistoryRepository_GetMostVisited_RejectsNonPositiveDays(t *testing.T) {
250 ctx := historyTestCtx()
251 dbPath := filepath.Join(t.TempDir(), "dumber.db")
252
253 db, err := sqlite.NewConnection(ctx, dbPath)
254 require.NoError(t, err)
255 t.Cleanup(func() { _ = db.Close() })
256
257 repo := sqlite.NewHistoryRepository(db)
258
259 // days = 0 should return error
260 _, err = repo.GetMostVisited(ctx, 0)
261 require.Error(t, err)
262 assert.Contains(t, err.Error(), "days must be positive")
263
264 // days < 0 should return error
265 _, err = repo.GetMostVisited(ctx, -5)
266 require.Error(t, err)
267 assert.Contains(t, err.Error(), "days must be positive")
268}
269
270func TestHistoryRepository_GetAllRecentHistory(t *testing.T) {
271 ctx := historyTestCtx()
272 dbPath := filepath.Join(t.TempDir(), "dumber.db")
273
274 db, err := sqlite.NewConnection(ctx, dbPath)
275 require.NoError(t, err)
276 t.Cleanup(func() { _ = db.Close() })
277
278 repo := sqlite.NewHistoryRepository(db)
279
280 // Save some entries
281 entries := []*entity.HistoryEntry{
282 {URL: "https://example.com", Title: "Example"},
283 {URL: "https://github.com", Title: "GitHub"},
284 }
285 for _, e := range entries {
286 require.NoError(t, repo.Save(ctx, e))
287 }
288
289 // Get all recent history
290 results, err := repo.GetAllRecentHistory(ctx)
291 require.NoError(t, err)
292 require.Len(t, results, 2)
293}
294
295func TestHistoryRepository_GetAllMostVisited(t *testing.T) {
296 ctx := historyTestCtx()
297 dbPath := filepath.Join(t.TempDir(), "dumber.db")
298
299 db, err := sqlite.NewConnection(ctx, dbPath)
300 require.NoError(t, err)
301 t.Cleanup(func() { _ = db.Close() })
302
303 repo := sqlite.NewHistoryRepository(db)
304
305 // Save entries with different visit counts
306 entry1 := &entity.HistoryEntry{URL: "https://example.com", Title: "Example"}
307 entry2 := &entity.HistoryEntry{URL: "https://github.com", Title: "GitHub"}
308
309 require.NoError(t, repo.Save(ctx, entry1))
310 require.NoError(t, repo.Save(ctx, entry2))
311
312 // Increment github visits
313 for i := 0; i < 3; i++ {
314 require.NoError(t, repo.IncrementVisitCount(ctx, "https://github.com"))
315 }
316
317 // Get all most visited
318 results, err := repo.GetAllMostVisited(ctx)
319 require.NoError(t, err)
320 require.Len(t, results, 2)
321
322 // First should be github (4 visits)
323 assert.Equal(t, "https://github.com", results[0].URL)
324 assert.Equal(t, int64(4), results[0].VisitCount)
325}
326
327func TestHistoryRepository_Search_SingleWord(t *testing.T) {
328 ctx := historyTestCtx()
329 dbPath := filepath.Join(t.TempDir(), "dumber.db")
330
331 db, err := sqlite.NewConnection(ctx, dbPath)
332 require.NoError(t, err)
333 t.Cleanup(func() { _ = db.Close() })
334
335 repo := sqlite.NewHistoryRepository(db)
336
337 // Save entries
338 entries := []*entity.HistoryEntry{
339 {URL: "https://github.com/user/repo", Title: "My GitHub Repository"},
340 {URL: "https://gitlab.com/project", Title: "GitLab Project"},
341 {URL: "https://example.com", Title: "Example Site"},
342 }
343 for _, e := range entries {
344 require.NoError(t, repo.Save(ctx, e))
345 }
346
347 // Search for "github" - should find the github entry
348 results, err := repo.Search(ctx, "github", 10)
349 require.NoError(t, err)
350 require.Len(t, results, 1)
351 assert.Equal(t, "https://github.com/user/repo", results[0].Entry.URL)
352}
353
354func TestHistoryRepository_Search_MultiWord(t *testing.T) {
355 ctx := historyTestCtx()
356 dbPath := filepath.Join(t.TempDir(), "dumber.db")
357
358 db, err := sqlite.NewConnection(ctx, dbPath)
359 require.NoError(t, err)
360 t.Cleanup(func() { _ = db.Close() })
361
362 repo := sqlite.NewHistoryRepository(db)
363
364 // Save entries
365 entries := []*entity.HistoryEntry{
366 {URL: "https://github.com/issues", Title: "GitHub Issues"},
367 {URL: "https://github.com/pulls", Title: "GitHub Pull Requests"},
368 {URL: "https://jira.com/issues", Title: "Jira Issues"},
369 }
370 for _, e := range entries {
371 require.NoError(t, repo.Save(ctx, e))
372 }
373
374 // Search for "github issues" - should find only the github issues entry
375 results, err := repo.Search(ctx, "github issues", 10)
376 require.NoError(t, err)
377 require.Len(t, results, 1)
378 assert.Equal(t, "https://github.com/issues", results[0].Entry.URL)
379}
380
381func TestHistoryRepository_Search_MatchesTitle(t *testing.T) {
382 ctx := historyTestCtx()
383 dbPath := filepath.Join(t.TempDir(), "dumber.db")
384
385 db, err := sqlite.NewConnection(ctx, dbPath)
386 require.NoError(t, err)
387 t.Cleanup(func() { _ = db.Close() })
388
389 repo := sqlite.NewHistoryRepository(db)
390
391 // Save entry with specific title that doesn't appear in URL
392 entry := &entity.HistoryEntry{
393 URL: "https://docs.example.com/guide",
394 Title: "Comprehensive Documentation Guide",
395 }
396 require.NoError(t, repo.Save(ctx, entry))
397
398 // Search for "Documentation" - should find by title
399 results, err := repo.Search(ctx, "Documentation", 10)
400 require.NoError(t, err)
401 require.Len(t, results, 1)
402 assert.Equal(t, "https://docs.example.com/guide", results[0].Entry.URL)
403}
404
405func TestHistoryRepository_Search_EmptyQuery(t *testing.T) {
406 ctx := historyTestCtx()
407 dbPath := filepath.Join(t.TempDir(), "dumber.db")
408
409 db, err := sqlite.NewConnection(ctx, dbPath)
410 require.NoError(t, err)
411 t.Cleanup(func() { _ = db.Close() })
412
413 repo := sqlite.NewHistoryRepository(db)
414
415 // Save an entry
416 require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
417 URL: "https://example.com",
418 Title: "Example",
419 }))
420
421 // Empty query should return empty results, no error
422 results, err := repo.Search(ctx, "", 10)
423 require.NoError(t, err)
424 assert.Empty(t, results)
425
426 // Whitespace-only query should also return empty results
427 results, err = repo.Search(ctx, " ", 10)
428 require.NoError(t, err)
429 assert.Empty(t, results)
430}
431
432func TestHistoryRepository_Search_NoResults(t *testing.T) {
433 ctx := historyTestCtx()
434 dbPath := filepath.Join(t.TempDir(), "dumber.db")
435
436 db, err := sqlite.NewConnection(ctx, dbPath)
437 require.NoError(t, err)
438 t.Cleanup(func() { _ = db.Close() })
439
440 repo := sqlite.NewHistoryRepository(db)
441
442 // Save an entry
443 require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
444 URL: "https://example.com",
445 Title: "Example",
446 }))
447
448 // Search for something that doesn't exist
449 results, err := repo.Search(ctx, "nonexistent", 10)
450 require.NoError(t, err)
451 assert.Empty(t, results)
452}
453
454func TestHistoryRepository_Search_PrefixMatching(t *testing.T) {
455 ctx := historyTestCtx()
456 dbPath := filepath.Join(t.TempDir(), "dumber.db")
457
458 db, err := sqlite.NewConnection(ctx, dbPath)
459 require.NoError(t, err)
460 t.Cleanup(func() { _ = db.Close() })
461
462 repo := sqlite.NewHistoryRepository(db)
463
464 // Save entries
465 entries := []*entity.HistoryEntry{
466 {URL: "https://github.com", Title: "GitHub"},
467 {URL: "https://gitlab.com", Title: "GitLab"},
468 {URL: "https://example.com", Title: "Example"},
469 }
470 for _, e := range entries {
471 require.NoError(t, repo.Save(ctx, e))
472 }
473
474 // Search for "git" - should find both github and gitlab (prefix match)
475 results, err := repo.Search(ctx, "git", 10)
476 require.NoError(t, err)
477 require.Len(t, results, 2)
478
479 // Verify both git* entries are found
480 urls := make(map[string]bool)
481 for _, r := range results {
482 urls[r.Entry.URL] = true
483 }
484 assert.True(t, urls["https://github.com"])
485 assert.True(t, urls["https://gitlab.com"])
486}
487
488func TestHistoryRepository_Search_DomainLikeQuery(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 with domain-like URLs
499 entries := []*entity.HistoryEntry{
500 {URL: "https://gordon.bnema.dev/", Title: "Gordon - Self-hosted Deployment Platform"},
501 {URL: "https://example.com", Title: "Example Site"},
502 {URL: "https://other.gordon.io", Title: "Another Gordon Site"},
503 }
504 for _, e := range entries {
505 require.NoError(t, repo.Save(ctx, e))
506 }
507
508 // Search with period in query (domain-like) - periods should be treated as separators
509 // "gordon.bnem" should match "gordon.bnema.dev" (tokens: gordon, bnem -> gordon, bnema)
510 results, err := repo.Search(ctx, "gordon.bnem", 10)
511 require.NoError(t, err)
512 require.NotEmpty(t, results, "domain-like query 'gordon.bnem' should match gordon.bnema.dev")
513
514 // Verify the gordon.bnema.dev entry is found
515 found := false
516 for _, r := range results {
517 if r.Entry.URL == "https://gordon.bnema.dev/" {
518 found = true
519 break
520 }
521 }
522 assert.True(t, found, "gordon.bnema.dev should be in results")
523}
524
525func TestHistoryRepository_Search_SpecialCharacters(t *testing.T) {
526 ctx := historyTestCtx()
527 dbPath := filepath.Join(t.TempDir(), "dumber.db")
528
529 db, err := sqlite.NewConnection(ctx, dbPath)
530 require.NoError(t, err)
531 t.Cleanup(func() { _ = db.Close() })
532
533 repo := sqlite.NewHistoryRepository(db)
534
535 // Save entry
536 require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
537 URL: "https://example.com/path",
538 Title: "Example Page",
539 }))
540
541 // Search with FTS5 special characters - should not cause errors
542 specialQueries := []string{
543 "example:", // colon
544 "example*", // asterisk
545 "example()", // parentheses
546 `example"test`, // quotes
547 "example^", // caret
548 "example-test", // hyphen
549 "example/", // slash
550 }
551
552 for _, q := range specialQueries {
553 results, err := repo.Search(ctx, q, 10)
554 require.NoError(t, err, "query %q should not cause error", q)
555 // May or may not find results, but should not error
556 _ = results
557 }
558}
559
560func TestHistoryRepository_Search_SlashSeparatedQuery(t *testing.T) {
561 ctx := historyTestCtx()
562 dbPath := filepath.Join(t.TempDir(), "dumber.db")
563
564 db, err := sqlite.NewConnection(ctx, dbPath)
565 require.NoError(t, err)
566 t.Cleanup(func() { _ = db.Close() })
567
568 repo := sqlite.NewHistoryRepository(db)
569
570 require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
571 URL: "https://github.com/bnema/dumber",
572 Title: "Dumber",
573 }))
574
575 results, err := repo.Search(ctx, "github.com/bnema", 10)
576 require.NoError(t, err)
577 require.NotEmpty(t, results, "slash-separated query should match history")
578}
579
580func TestHistoryRepository_AboutBlank_CappedVisitCount(t *testing.T) {
581 ctx := historyTestCtx()
582 dbPath := filepath.Join(t.TempDir(), "dumber.db")
583
584 db, err := sqlite.NewConnection(ctx, dbPath)
585 require.NoError(t, err)
586 t.Cleanup(func() { _ = db.Close() })
587
588 repo := sqlite.NewHistoryRepository(db)
589
590 // Save about:blank multiple times - visit count should stay at 1
591 for i := 0; i < 5; i++ {
592 require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
593 URL: "about:blank",
594 Title: "New Tab",
595 }))
596 }
597
598 // Verify about:blank has visit_count = 1
599 found, err := repo.FindByURL(ctx, "about:blank")
600 require.NoError(t, err)
601 require.NotNil(t, found)
602 assert.Equal(t, int64(1), found.VisitCount, "about:blank should always have visit_count = 1")
603
604 // Try incrementing visit count directly - should be ignored
605 require.NoError(t, repo.IncrementVisitCount(ctx, "about:blank"))
606
607 found2, err := repo.FindByURL(ctx, "about:blank")
608 require.NoError(t, err)
609 assert.Equal(t, int64(1), found2.VisitCount, "about:blank increment should be ignored")
610}
611
612func TestHistoryRepository_AboutBlank_NotDominant(t *testing.T) {
613 ctx := historyTestCtx()
614 dbPath := filepath.Join(t.TempDir(), "dumber.db")
615
616 db, err := sqlite.NewConnection(ctx, dbPath)
617 require.NoError(t, err)
618 t.Cleanup(func() { _ = db.Close() })
619
620 repo := sqlite.NewHistoryRepository(db)
621
622 // Save about:blank many times
623 for i := 0; i < 10; i++ {
624 require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
625 URL: "about:blank",
626 Title: "New Tab",
627 }))
628 }
629
630 // Save a regular entry with multiple visits
631 require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
632 URL: "https://example.com",
633 Title: "Example",
634 }))
635 require.NoError(t, repo.IncrementVisitCount(ctx, "https://example.com"))
636 require.NoError(t, repo.IncrementVisitCount(ctx, "https://example.com"))
637
638 // Get most visited - example.com should be first (3 visits vs 1)
639 results, err := repo.GetAllMostVisited(ctx)
640 require.NoError(t, err)
641 require.Len(t, results, 2)
642
643 assert.Equal(t, "https://example.com", results[0].URL, "example.com should be first with higher visit count")
644 assert.Equal(t, int64(3), results[0].VisitCount)
645
646 assert.Equal(t, "about:blank", results[1].URL, "about:blank should be second with capped visit count")
647 assert.Equal(t, int64(1), results[1].VisitCount)
648}