main 5041353

history_recorder_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close