1package usecase
2
3import (
4 "context"
5 "errors"
6 "sync"
7 "testing"
8 "time"
9
10 "github.com/bnema/dumber/internal/application/dto"
11 "github.com/bnema/dumber/internal/domain/entity"
12 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
13 "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"
15)
16
17type recordingHistoryChangeSink struct {
18 mu sync.Mutex
19 changes []dto.HistoryChange
20}
21
22func (s *recordingHistoryChangeSink) OnHistoryChanged(_ context.Context, change dto.HistoryChange) {
23 s.mu.Lock()
24 defer s.mu.Unlock()
25 s.changes = append(s.changes, change)
26}
27
28func (s *recordingHistoryChangeSink) snapshot() []dto.HistoryChange {
29 s.mu.Lock()
30 defer s.mu.Unlock()
31 out := make([]dto.HistoryChange, len(s.changes))
32 copy(out, s.changes)
33 return out
34}
35
36func TestNormalizeHistoryChangeSink_TreatsTypedNilAsNoop(t *testing.T) {
37 var sink *recordingHistoryChangeSink
38
39 normalized := normalizeHistoryChangeSink(sink)
40
41 require.NotNil(t, normalized)
42 require.NotPanics(t, func() {
43 normalized.OnHistoryChanged(context.Background(), dto.HistoryChange{Reasons: []dto.HistoryChangeReason{dto.HistoryChangeReasonVisit}})
44 })
45}
46
47type metadataUpdatingHistoryRepository struct {
48 *repomocks.MockHistoryRepository
49 mu sync.Mutex
50 entry *entity.HistoryEntry
51}
52
53func (r *metadataUpdatingHistoryRepository) UpdateMetadata(_ context.Context, entry *entity.HistoryEntry) error {
54 r.mu.Lock()
55 defer r.mu.Unlock()
56 clone := *entry
57 r.entry = &clone
58 return nil
59}
60
61func (r *metadataUpdatingHistoryRepository) snapshotEntry() *entity.HistoryEntry {
62 r.mu.Lock()
63 defer r.mu.Unlock()
64 if r.entry == nil {
65 return nil
66 }
67 clone := *r.entry
68 return &clone
69}
70
71func TestCanonicalizeURLForHistory_StripsHashTrackingAndTrailingSlash(t *testing.T) {
72 got := canonicalizeURLForHistory(
73 "https://Example.com/path/?utm_source=newsletter&utm_campaign=launch&a=2&b=1#section-1",
74 historyCanonicalizationOptions{StripTrackingParams: true},
75 )
76
77 require.Equal(t, "https://example.com/path?a=2&b=1", got)
78}
79
80func TestCanonicalizeURLForHistory_OptionallyKeepsTrackingParams(t *testing.T) {
81 got := canonicalizeURLForHistory(
82 "https://example.com/path/?utm_source=newsletter&b=1#section-1",
83 historyCanonicalizationOptions{StripTrackingParams: false},
84 )
85
86 require.Equal(t, "https://example.com/path?b=1&utm_source=newsletter", got)
87}
88
89func TestCanonicalizeURLForHistory_RemovesUserinfo(t *testing.T) {
90 got := canonicalizeURLForHistory(
91 "https://user:password@example.com/path#section-1",
92 historyCanonicalizationOptions{StripTrackingParams: true},
93 )
94
95 require.Equal(t, "https://example.com/path", got)
96}
97
98func TestCanonicalizeURLForHistory_RemovesSchemeDefaultPorts(t *testing.T) {
99 require.Equal(t, "http://example.com/path", canonicalizeURLForHistory(
100 "http://Example.com:80/path",
101 historyCanonicalizationOptions{StripTrackingParams: true},
102 ))
103 require.Equal(t, "https://example.com/path", canonicalizeURLForHistory(
104 "https://Example.com:443/path",
105 historyCanonicalizationOptions{StripTrackingParams: true},
106 ))
107 require.Equal(t, "https://example.com:8443/path", canonicalizeURLForHistory(
108 "https://Example.com:8443/path",
109 historyCanonicalizationOptions{StripTrackingParams: true},
110 ))
111}
112
113func TestHistoryRecorder_RecordHistory_IgnoresNonWebSchemes(t *testing.T) {
114 ctx := context.Background()
115 sink := &recordingHistoryChangeSink{}
116 repo := repomocks.NewMockHistoryRepository(t)
117
118 uc := NewHistoryRecorderUseCase(repo, sink)
119 uc.RecordHistory(ctx, "pane-1", "file:///private/history.txt")
120 uc.RecordHistory(ctx, "pane-1", "data:text/plain,secret")
121 uc.RecordHistory(ctx, "pane-1", "dumb://history")
122 uc.RecordHistory(ctx, "pane-1", "about:blank")
123 uc.Close()
124
125 require.Empty(t, sink.snapshot())
126}
127
128func TestHistoryRecorder_UpdateHistoryTitle_IgnoresNonWebSchemes(t *testing.T) {
129 ctx := context.Background()
130 sink := &recordingHistoryChangeSink{}
131 repo := repomocks.NewMockHistoryRepository(t)
132
133 uc := NewHistoryRecorderUseCase(repo, sink)
134 uc.UpdateHistoryTitle(ctx, "file:///private/history.txt", "Secret")
135 uc.UpdateHistoryTitle(ctx, "dumb://history", "History")
136 uc.Close()
137
138 require.Empty(t, sink.snapshot())
139}
140
141func TestIsHashOnlyTransition_UsesCanonicalComparison(t *testing.T) {
142 tests := []struct {
143 name string
144 previous string
145 current string
146 }{
147 {
148 name: "query order and tracking params",
149 previous: "https://example.com/docs?utm_source=newsletter&b=2&a=1#intro",
150 current: "https://example.com/docs/?a=1&b=2#api",
151 },
152 {
153 name: "default port",
154 previous: "https://example.com:443/docs#intro",
155 current: "https://example.com/docs#api",
156 },
157 }
158
159 for _, tt := range tests {
160 t.Run(tt.name, func(t *testing.T) {
161 require.True(t, isHashOnlyTransition(tt.previous, tt.current))
162 })
163 }
164}
165
166func TestHistoryRecorder_RecordHistory_CoalescesVisitsAndPublishesOneChange(t *testing.T) {
167 ctx := context.Background()
168 sink := &recordingHistoryChangeSink{}
169 repo := repomocks.NewMockHistoryRepository(t)
170
171 repo.EXPECT().FindByURL(mock.Anything, "https://example.com/article").Return(nil, nil).Once()
172 repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
173 return entry.URL == "https://example.com/article" && entry.VisitCount == 2
174 })).Return(nil).Once()
175
176 uc := NewHistoryRecorderUseCase(repo, sink)
177 uc.RecordHistory(ctx, "pane-1", "https://example.com/article?utm_source=feed")
178 uc.RecordHistory(ctx, "pane-2", "https://example.com/article")
179 uc.Close()
180
181 changes := sink.snapshot()
182 require.Len(t, changes, 1)
183 require.Equal(t, []dto.HistoryChangeReason{dto.HistoryChangeReasonVisit}, changes[0].Reasons)
184 require.Equal(t, 2, changes[0].VisitCount)
185 require.Zero(t, changes[0].TitleCount)
186}
187
188func TestHistoryRecorder_CloseDrainsPendingAndPublishes(t *testing.T) {
189 ctx := context.Background()
190 sink := &recordingHistoryChangeSink{}
191 repo := repomocks.NewMockHistoryRepository(t)
192
193 repo.EXPECT().FindByURL(mock.Anything, "https://example.com/close").Return(nil, nil).Once()
194 repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
195 return entry.URL == "https://example.com/close" && entry.VisitCount == 1
196 })).Return(nil).Once()
197
198 uc := NewHistoryRecorderUseCase(repo, sink)
199 uc.RecordHistory(ctx, "pane-1", "https://example.com/close")
200 uc.Close()
201
202 changes := sink.snapshot()
203 require.Len(t, changes, 1)
204 require.Equal(t, 1, changes[0].VisitCount)
205}
206
207func TestHistoryRecorder_CloseCancelsInFlightWorkerFlushAndDrains(t *testing.T) {
208 ctx := context.Background()
209 sink := &recordingHistoryChangeSink{}
210 repo := repomocks.NewMockHistoryRepository(t)
211 const historyURL = "https://example.com/cancel-close"
212
213 findStarted := make(chan struct{})
214 var findOnce sync.Once
215 repo.EXPECT().FindByURL(mock.Anything, historyURL).RunAndReturn(
216 func(ctx context.Context, _ string) (*entity.HistoryEntry, error) {
217 first := false
218 findOnce.Do(func() { first = true })
219 if first {
220 close(findStarted)
221 <-ctx.Done()
222 return nil, ctx.Err()
223 }
224 return nil, nil
225 },
226 ).Twice()
227 repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
228 return entry.URL == historyURL && entry.VisitCount == 1
229 })).Return(nil).Once()
230
231 uc := NewHistoryRecorderUseCase(repo, sink)
232 uc.RecordHistory(ctx, "pane-1", historyURL)
233
234 select {
235 case <-findStarted:
236 case <-time.After(time.Second):
237 t.Fatal("timed out waiting for periodic history flush to start")
238 }
239
240 closed := make(chan struct{})
241 go func() {
242 uc.Close()
243 close(closed)
244 }()
245
246 select {
247 case <-closed:
248 case <-time.After(time.Second):
249 t.Fatal("timed out waiting for history recorder shutdown")
250 }
251
252 changes := sink.snapshot()
253 require.Len(t, changes, 1)
254 require.Equal(t, 1, changes[0].VisitCount)
255}
256
257func TestHistoryRecorder_FailedPersistencePublishesNoChange(t *testing.T) {
258 ctx := context.Background()
259 sink := &recordingHistoryChangeSink{}
260 repo := repomocks.NewMockHistoryRepository(t)
261
262 repo.EXPECT().FindByURL(mock.Anything, "https://example.com/fail").Return(nil, errors.New("db down")).Times(3)
263
264 uc := NewHistoryRecorderUseCase(repo, sink)
265 uc.RecordHistory(ctx, "pane-1", "https://example.com/fail")
266 uc.Close()
267
268 require.Empty(t, sink.snapshot())
269}
270
271func TestHistoryRecorder_RecordHistory_DoesNotUpdateDedupeStateWhenEnqueueFails(t *testing.T) {
272 ctx := context.Background()
273 uc := &HistoryRecorderUseCase{
274 recentVisits: make(map[string]paneHistoryState),
275 historyQueue: make(chan historyRecord, 1),
276 }
277 uc.historyQueue <- historyRecord{url: "https://queued.example", visits: 1}
278
279 uc.RecordHistory(ctx, "pane-1", "https://example.com/full")
280
281 uc.recentMu.Lock()
282 _, ok := uc.recentVisits["pane-1"]
283 uc.recentMu.Unlock()
284 require.False(t, ok, "failed enqueue must not suppress a later retry through dedupe state")
285}
286
287func TestHistoryRecorder_UpdateHistoryTitle_PublishesTitleOnlyWithoutIncrementingVisits(t *testing.T) {
288 ctx := context.Background()
289 sink := &recordingHistoryChangeSink{}
290 repo := &metadataUpdatingHistoryRepository{
291 MockHistoryRepository: repomocks.NewMockHistoryRepository(t),
292 entry: &entity.HistoryEntry{
293 URL: "https://example.com/article",
294 Title: "Old",
295 VisitCount: 7,
296 },
297 }
298 repo.EXPECT().FindByURL(mock.Anything, "https://example.com/article").RunAndReturn(
299 func(context.Context, string) (*entity.HistoryEntry, error) {
300 return repo.snapshotEntry(), nil
301 },
302 ).Once()
303
304 uc := NewHistoryRecorderUseCase(repo, sink)
305 uc.UpdateHistoryTitle(ctx, "https://example.com/article", "New")
306 uc.Close()
307
308 after := repo.snapshotEntry()
309 require.NotNil(t, after)
310 require.Equal(t, "New", after.Title)
311 require.Equal(t, int64(7), after.VisitCount)
312
313 changes := sink.snapshot()
314 require.Len(t, changes, 1)
315 require.Equal(t, []dto.HistoryChangeReason{dto.HistoryChangeReasonTitle}, changes[0].Reasons)
316 require.Zero(t, changes[0].VisitCount)
317 require.Equal(t, 1, changes[0].TitleCount)
318}
319
320func TestHistoryRecorder_FlushPendingHistoryDiscardsTitleWhenHistoryRowMissing(t *testing.T) {
321 ctx := context.Background()
322 sink := &recordingHistoryChangeSink{}
323 repo := repomocks.NewMockHistoryRepository(t)
324 const historyURL = "https://example.com/missing-title"
325
326 repo.EXPECT().FindByURL(mock.Anything, historyURL).Return(nil, nil).Once()
327
328 uc := &HistoryRecorderUseCase{historyRepo: repo, changeSink: sink, ctx: ctx}
329 pending := newPendingHistoryRecords()
330 pending.add(historyRecord{url: historyURL, title: "Missing"})
331
332 err := uc.flushPendingHistory(ctx, pending)
333
334 require.NoError(t, err)
335 require.Empty(t, pending.visits)
336 require.Empty(t, pending.titles)
337 require.Empty(t, sink.snapshot())
338}
339
340func TestHistoryRecorder_RecordHistory_IgnoresHashOnlyTransitionsAndPublishesNoSecondChange(t *testing.T) {
341 ctx := context.Background()
342 sink := &recordingHistoryChangeSink{}
343 repo := repomocks.NewMockHistoryRepository(t)
344
345 repo.EXPECT().FindByURL(mock.Anything, "https://example.com/docs").Return(nil, nil).Once()
346 repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
347 return entry.URL == "https://example.com/docs" && entry.VisitCount == 1
348 })).Return(nil).Once()
349
350 uc := NewHistoryRecorderUseCase(repo, sink)
351 uc.RecordHistory(ctx, "pane-1", "https://example.com/docs#intro")
352 uc.RecordHistory(ctx, "pane-1", "https://example.com/docs#api")
353 uc.Close()
354
355 changes := sink.snapshot()
356 require.Len(t, changes, 1)
357 require.Equal(t, 1, changes[0].VisitCount)
358}
359
360func TestHistoryRecorder_RecordHistory_DedupIsPerPane(t *testing.T) {
361 ctx := context.Background()
362 sink := &recordingHistoryChangeSink{}
363 repo := repomocks.NewMockHistoryRepository(t)
364
365 repo.EXPECT().FindByURL(mock.Anything, "https://example.com/dedupe").Return(nil, nil).Once()
366 repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
367 return entry.URL == "https://example.com/dedupe" && entry.VisitCount == 2
368 })).Return(nil).Once()
369
370 uc := NewHistoryRecorderUseCase(repo, sink)
371 uc.RecordHistory(ctx, "pane-1", "https://example.com/dedupe")
372 uc.RecordHistory(ctx, "pane-1", "https://example.com/dedupe")
373 uc.RecordHistory(ctx, "pane-2", "https://example.com/dedupe")
374 uc.Close()
375
376 changes := sink.snapshot()
377 require.Len(t, changes, 1)
378 require.Equal(t, 2, changes[0].VisitCount)
379}
380
381func TestHistoryRecorder_ClearPaneHistorySerializesWithRecorderAndAllowsPaneReuse(t *testing.T) {
382 ctx := context.Background()
383 sink := &recordingHistoryChangeSink{}
384 repo := repomocks.NewMockHistoryRepository(t)
385 const historyURL = "https://example.com/reused-pane"
386
387 repo.EXPECT().FindByURL(mock.Anything, historyURL).Return(nil, nil).Once()
388 repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
389 return entry.URL == historyURL && entry.VisitCount == 2
390 })).Return(nil).Once()
391
392 uc := NewHistoryRecorderUseCase(repo, sink)
393 uc.RecordHistory(ctx, "pane-1", historyURL)
394 uc.ClearPaneHistory("pane-1")
395 uc.RecordHistory(ctx, "pane-1", historyURL)
396 uc.Close()
397
398 changes := sink.snapshot()
399 require.Len(t, changes, 1)
400 require.Equal(t, 2, changes[0].VisitCount)
401}
402
403func TestHistoryRecorder_FlushPendingHistoryTracksPartialFallbackVisitWrites(t *testing.T) {
404 ctx := context.Background()
405 sink := &recordingHistoryChangeSink{}
406 repo := repomocks.NewMockHistoryRepository(t)
407 const historyURL = "https://example.com/partial"
408 increments := 0
409
410 repo.EXPECT().FindByURL(mock.Anything, historyURL).Return(&entity.HistoryEntry{URL: historyURL}, nil).Once()
411 repo.EXPECT().IncrementVisitCount(mock.Anything, historyURL).RunAndReturn(func(context.Context, string) error {
412 increments++
413 if increments == 3 {
414 return errors.New("increment failed")
415 }
416 return nil
417 }).Times(3)
418
419 uc := &HistoryRecorderUseCase{historyRepo: repo, changeSink: sink, ctx: ctx}
420 pending := newPendingHistoryRecords()
421 pending.add(historyRecord{url: historyURL, visits: 3})
422
423 err := uc.flushPendingHistory(ctx, pending)
424
425 require.ErrorIs(t, err, errHistoryPersistenceIncomplete)
426 require.Equal(t, map[string]int{historyURL: 1}, pending.visits)
427 require.Empty(t, pending.titles)
428 changes := sink.snapshot()
429 require.Len(t, changes, 1)
430 require.Equal(t, 2, changes[0].VisitCount)
431}
432
433func TestHistoryRecorder_HandleHistoryControlKeepsDedupeWhenFlushFails(t *testing.T) {
434 ctx := context.Background()
435 repo := repomocks.NewMockHistoryRepository(t)
436 const historyURL = "https://example.com/fail-reset"
437
438 repo.EXPECT().FindByURL(mock.Anything, historyURL).Return(nil, errors.New("db down")).Once()
439
440 uc := &HistoryRecorderUseCase{
441 historyRepo: repo,
442 changeSink: &recordingHistoryChangeSink{},
443 recentVisits: map[string]paneHistoryState{
444 "pane-1": {lastCanonicalURL: historyURL},
445 },
446 ctx: ctx,
447 }
448 pending := newPendingHistoryRecords()
449 pending.add(historyRecord{url: historyURL, visits: 1})
450 req := historyControlRequest{kind: historyControlFlushAndReset, ctx: ctx, done: make(chan error, 1)}
451
452 uc.handleHistoryControl(pending, req)
453
454 require.ErrorIs(t, <-req.done, errHistoryPersistenceIncomplete)
455 require.Contains(t, uc.recentVisits, "pane-1")
456}
457
458func TestHistoryRecorder_BeginHistoryMutationFlushesPendingAndResetsDedupe(t *testing.T) {
459 ctx := context.Background()
460 sink := &recordingHistoryChangeSink{}
461 repo := repomocks.NewMockHistoryRepository(t)
462
463 repo.EXPECT().FindByURL(mock.Anything, "https://example.com/reset").Return(nil, nil).Twice()
464 repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
465 return entry.URL == "https://example.com/reset" && entry.VisitCount == 1
466 })).Return(nil).Twice()
467
468 uc := NewHistoryRecorderUseCase(repo, sink)
469 uc.RecordHistory(ctx, "pane-1", "https://example.com/reset")
470
471 release, err := uc.BeginHistoryMutation(ctx)
472 require.NoError(t, err)
473 release()
474
475 uc.RecordHistory(ctx, "pane-1", "https://example.com/reset")
476 uc.Close()
477
478 changes := sink.snapshot()
479 require.Len(t, changes, 2)
480 require.Equal(t, 1, changes[0].VisitCount)
481 require.Equal(t, 1, changes[1].VisitCount)
482}
483
484func TestHistoryRecorder_BeginHistoryMutationUsesCallerContextForFlush(t *testing.T) {
485 type contextKey string
486 ctx := context.WithValue(context.Background(), contextKey("mutation"), "flush")
487 sink := &recordingHistoryChangeSink{}
488 repo := repomocks.NewMockHistoryRepository(t)
489
490 repo.EXPECT().FindByURL(mock.Anything, "https://example.com/context").RunAndReturn(
491 func(ctx context.Context, _ string) (*entity.HistoryEntry, error) {
492 require.Equal(t, "flush", ctx.Value(contextKey("mutation")))
493 return nil, nil
494 },
495 ).Once()
496 repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
497 return entry.URL == "https://example.com/context" && entry.VisitCount == 1
498 })).RunAndReturn(func(ctx context.Context, _ *entity.HistoryEntry) error {
499 require.Equal(t, "flush", ctx.Value(contextKey("mutation")))
500 return nil
501 }).Once()
502
503 uc := NewHistoryRecorderUseCase(repo, sink)
504 uc.RecordHistory(context.Background(), "pane-1", "https://example.com/context")
505
506 release, err := uc.BeginHistoryMutation(ctx)
507 require.NoError(t, err)
508 release()
509 uc.Close()
510}
511
512func TestHistoryRecorder_BeginHistoryMutationWaitsForInFlightShutdownDrain(t *testing.T) {
513 ctx := context.Background()
514 saveStarted := make(chan struct{})
515 allowSave := make(chan struct{})
516 repo := repomocks.NewMockHistoryRepository(t)
517
518 repo.EXPECT().FindByURL(mock.Anything, "https://example.com/shutdown").Return(nil, nil).Once()
519 repo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(entry *entity.HistoryEntry) bool {
520 return entry.URL == "https://example.com/shutdown" && entry.VisitCount == 1
521 })).RunAndReturn(func(context.Context, *entity.HistoryEntry) error {
522 close(saveStarted)
523 <-allowSave
524 return nil
525 }).Once()
526
527 uc := NewHistoryRecorderUseCase(repo, nil)
528 uc.RecordHistory(ctx, "pane-1", "https://example.com/shutdown")
529
530 closeDone := make(chan struct{})
531 go func() {
532 uc.Close()
533 close(closeDone)
534 }()
535 select {
536 case <-saveStarted:
537 case <-time.After(time.Second):
538 t.Fatal("timed out waiting for history save to start")
539 }
540
541 mutationDone := make(chan error, 1)
542 beginStarted := make(chan struct{})
543 go func() {
544 close(beginStarted)
545 release, err := uc.BeginHistoryMutation(ctx)
546 if release != nil {
547 release()
548 }
549 mutationDone <- err
550 }()
551 select {
552 case <-beginStarted:
553 case <-time.After(time.Second):
554 t.Fatal("timed out waiting for history mutation to start")
555 }
556
557 select {
558 case err := <-mutationDone:
559 t.Fatalf("history mutation returned before shutdown drain finished: %v", err)
560 case <-time.After(50 * time.Millisecond):
561 }
562
563 close(allowSave)
564 select {
565 case err := <-mutationDone:
566 require.NoError(t, err)
567 case <-time.After(time.Second):
568 t.Fatal("timed out waiting for history mutation to finish")
569 }
570 select {
571 case <-closeDone:
572 case <-time.After(time.Second):
573 t.Fatal("timed out waiting for history recorder shutdown")
574 }
575}
576
577func TestHistoryWorker_ReenqueueDuringFinalShutdownFlushIsRejected(t *testing.T) {
578 assertReenqueueDuringFlush(t, false, "")
579}
580
581func TestHistoryWorker_ReenqueueDuringPeriodicFlushIsPersisted(t *testing.T) {
582 assertReenqueueDuringFlush(t, true, "Queued title")
583}
584
585func assertReenqueueDuringFlush(t *testing.T, waitForPeriodicFlush bool, expectedTitle string) {
586 t.Helper()
587 ctx := context.Background()
588
589 var mu sync.Mutex
590 store := make(map[string]*entity.HistoryEntry)
591 saved := make(chan string, 4)
592 var saveOnce sync.Once
593 var uc *HistoryRecorderUseCase
594
595 repo := repomocks.NewMockHistoryRepository(t)
596
597 repo.EXPECT().FindByURL(mock.Anything, mock.Anything).RunAndReturn(
598 func(_ context.Context, url string) (*entity.HistoryEntry, error) {
599 mu.Lock()
600 defer mu.Unlock()
601 e := store[url]
602 if e == nil {
603 return nil, nil
604 }
605 clone := *e
606 return &clone, nil
607 },
608 ).Maybe()
609
610 repo.EXPECT().Save(mock.Anything, mock.Anything).RunAndReturn(
611 func(_ context.Context, entry *entity.HistoryEntry) error {
612 clone := *entry
613 mu.Lock()
614 store[entry.URL] = &clone
615 mu.Unlock()
616
617 saveOnce.Do(func() {
618 if entry.URL == "https://example.com/article" {
619 uc.UpdateHistoryTitle(ctx, entry.URL, "Queued title")
620 }
621 })
622 select {
623 case saved <- entry.URL:
624 default:
625 }
626 return nil
627 },
628 ).Maybe()
629
630 uc = NewHistoryRecorderUseCase(repo, nil)
631 uc.RecordHistory(ctx, "pane-1", "https://example.com/article")
632 if waitForPeriodicFlush {
633 require.Eventually(t, func() bool {
634 select {
635 case url := <-saved:
636 return url == "https://example.com/article"
637 default:
638 return false
639 }
640 }, time.Second, 10*time.Millisecond, "periodic flush should save the initial visit before Close")
641 }
642 uc.Close()
643
644 mu.Lock()
645 entry := store["https://example.com/article"]
646 mu.Unlock()
647 require.NotNil(t, entry)
648 require.Equal(t, int64(1), entry.VisitCount)
649 require.Equal(t, expectedTitle, entry.Title)
650}