v0.31.0 bf0756b

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
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}

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