1package usecase
2
3import (
4 "context"
5 "path/filepath"
6 "sync"
7 "testing"
8 "time"
9
10 "github.com/bnema/dumber/internal/domain/entity"
11 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
12 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
13 "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"
15)
16
17func TestCanonicalizeURLForHistory_StripsHashTrackingAndTrailingSlash(t *testing.T) {
18 got := canonicalizeURLForHistory(
19 "https://Example.com/path/?utm_source=newsletter&utm_campaign=launch&a=2&b=1#section-1",
20 historyCanonicalizationOptions{StripTrackingParams: true},
21 )
22
23 require.Equal(t, "https://example.com/path?a=2&b=1", got)
24}
25
26func TestCanonicalizeURLForHistory_OptionallyKeepsTrackingParams(t *testing.T) {
27 got := canonicalizeURLForHistory(
28 "https://example.com/path/?utm_source=newsletter&b=1#section-1",
29 historyCanonicalizationOptions{StripTrackingParams: false},
30 )
31
32 require.Equal(t, "https://example.com/path?b=1&utm_source=newsletter", got)
33}
34
35func TestRecordHistory_IgnoresHashOnlyTransitions(t *testing.T) {
36 ctx := context.Background()
37 repo := repomocks.NewMockHistoryRepository(t)
38
39 repo.EXPECT().FindByURL(mock.Anything, "https://example.com/docs").Return(nil, nil).Once()
40 repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
41 return entry.URL == "https://example.com/docs" && entry.VisitCount == 1
42 })).Return(nil).Once()
43
44 uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault)
45 uc.RecordHistory(ctx, "pane-1", "https://example.com/docs#intro")
46 uc.RecordHistory(ctx, "pane-1", "https://example.com/docs#api")
47 uc.Close()
48}
49
50func TestRecordHistory_DedupIsPerPaneAndCoalescesVisits(t *testing.T) {
51 ctx := context.Background()
52 repo := repomocks.NewMockHistoryRepository(t)
53
54 repo.EXPECT().FindByURL(mock.Anything, "https://example.com/article").Return(nil, nil).Once()
55 repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
56 return entry.URL == "https://example.com/article" && entry.VisitCount == 2
57 })).Return(nil).Once()
58
59 uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault)
60 uc.RecordHistory(ctx, "pane-1", "https://example.com/article?utm_source=feed")
61 uc.RecordHistory(ctx, "pane-1", "https://example.com/article?utm_source=feed")
62 uc.RecordHistory(ctx, "pane-2", "https://example.com/article")
63 uc.Close()
64}
65
66func TestUpdateHistoryTitle_UsesMetadataUpdateWithoutIncrementingVisits(t *testing.T) {
67 ctx := context.Background()
68 dbPath := filepath.Join(t.TempDir(), "dumber.db")
69 db, err := sqlite.NewConnection(ctx, dbPath)
70 require.NoError(t, err)
71 t.Cleanup(func() { _ = db.Close() })
72
73 repo := sqlite.NewHistoryRepository(db)
74 require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{
75 URL: "https://example.com/article",
76 Title: "Old",
77 }))
78
79 before, err := repo.FindByURL(ctx, "https://example.com/article")
80 require.NoError(t, err)
81 require.NotNil(t, before)
82
83 uc := NewNavigateUseCase(repo, nil, entity.ZoomDefault)
84 uc.UpdateHistoryTitle(ctx, "https://example.com/article", "New")
85 uc.Close()
86
87 after, err := repo.FindByURL(ctx, "https://example.com/article")
88 require.NoError(t, err)
89 require.NotNil(t, after)
90 require.Equal(t, "New", after.Title)
91 require.Equal(t, before.VisitCount, after.VisitCount)
92}
93
94func TestHistoryWorker_ReenqueueDuringFlushIsPersistedOnShutdown(t *testing.T) {
95 ctx := context.Background()
96
97 // Stateful store — Save writes here, FindByURL reads from here.
98 var mu sync.Mutex
99 store := make(map[string]*entity.HistoryEntry)
100
101 // Fires once during the first Save to re-enqueue a title update while
102 // the flush is in progress. This is the exact race that caused the
103 // concurrent map crash (fixed by swapping maps in flushPending).
104 var saveOnce sync.Once
105 var uc *NavigateUseCase
106
107 repo := repomocks.NewMockHistoryRepository(t)
108
109 repo.EXPECT().FindByURL(mock.Anything, mock.Anything).RunAndReturn(
110 func(_ context.Context, url string) (*entity.HistoryEntry, error) {
111 mu.Lock()
112 defer mu.Unlock()
113 e := store[url]
114 if e == nil {
115 return nil, nil
116 }
117 clone := *e
118 return &clone, nil
119 },
120 ).Maybe()
121
122 repo.EXPECT().Save(mock.Anything, mock.Anything).RunAndReturn(
123 func(_ context.Context, entry *entity.HistoryEntry) error {
124 clone := *entry
125 mu.Lock()
126 store[entry.URL] = &clone
127 mu.Unlock()
128
129 // Re-enqueue a title update during the first Save.
130 saveOnce.Do(func() {
131 if entry.URL == "https://example.com/article" {
132 uc.UpdateHistoryTitle(ctx, entry.URL, "Queued title")
133 }
134 })
135 return nil
136 },
137 ).Maybe()
138
139 uc = NewNavigateUseCase(repo, nil, entity.ZoomDefault)
140 uc.RecordHistory(ctx, "pane-1", "https://example.com/article")
141
142 // The history worker flushes on a timer; there is no exported sync
143 // mechanism, so we sleep before shutting down. Using 5× the flush
144 // interval for headroom — this is a known flaky pattern, but
145 // restructuring the worker for deterministic sync is out of scope.
146 // Close() performs a final flush, so items re-enqueued during the
147 // timed flush are still captured on shutdown.
148 time.Sleep(historyWorkerFlushInterval * 5)
149 uc.Close()
150
151 mu.Lock()
152 entry := store["https://example.com/article"]
153 mu.Unlock()
154 require.NotNil(t, entry)
155 require.Equal(t, int64(1), entry.VisitCount)
156 require.Equal(t, "Queued title", entry.Title)
157}