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