main 5041353

search_history_test.go RawHistoryFolder

1package usecase_test
2
3import (
4	"context"
5	"errors"
6	"testing"
7	"time"
8
9	"github.com/bnema/dumber/internal/application/dto"
10	"github.com/bnema/dumber/internal/application/usecase"
11	"github.com/bnema/dumber/internal/domain/entity"
12	repository "github.com/bnema/dumber/internal/domain/repository"
13	repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
14	"github.com/stretchr/testify/assert"
15	"github.com/stretchr/testify/mock"
16	"github.com/stretchr/testify/require"
17)
18
19type recordingHistoryChangeSink struct {
20	changes  []dto.HistoryChange
21	onChange func(dto.HistoryChange)
22}
23
24func (s *recordingHistoryChangeSink) OnHistoryChanged(_ context.Context, change dto.HistoryChange) {
25	if s.onChange != nil {
26		s.onChange(change)
27	}
28	s.changes = append(s.changes, change)
29}
30
31type recordingHistoryMutationCoordinator struct {
32	beginCount   int
33	releaseCount int
34	err          error
35}
36
37func (c *recordingHistoryMutationCoordinator) BeginHistoryMutation(context.Context) (func(), error) {
38	c.beginCount++
39	if c.err != nil {
40		return nil, c.err
41	}
42	return func() { c.releaseCount++ }, nil
43}
44
45type nilPanicHistoryMutationCoordinator struct {
46	called bool
47}
48
49func (c *nilPanicHistoryMutationCoordinator) BeginHistoryMutation(context.Context) (func(), error) {
50	c.called = true
51	return func() {}, nil
52}
53
54func requirePublishedHistoryChange(t *testing.T, sink *recordingHistoryChangeSink, reason dto.HistoryChangeReason) dto.HistoryChange {
55	t.Helper()
56	require.Len(t, sink.changes, 1)
57	change := sink.changes[0]
58	assert.Equal(t, []dto.HistoryChangeReason{reason}, change.Reasons)
59	assert.False(t, change.ChangedAt.IsZero())
60	return change
61}
62
63func TestSearchHistoryUseCase_GetRecent_ZeroLimitMeansAll(t *testing.T) {
64	ctx := testContext()
65	historyRepo := repomocks.NewMockHistoryRepository(t)
66	entries := []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Example"}}
67	historyRepo.EXPECT().GetRecent(mock.Anything, 0, 0, repository.HistoryScope{}).Return(entries, nil).Once()
68
69	uc := usecase.NewSearchHistoryUseCase(historyRepo)
70	result, err := uc.GetRecent(ctx, 0, 0)
71
72	require.NoError(t, err)
73	require.Len(t, result, 1)
74}
75
76func TestSearchHistoryUseCase_GetRecent_NegativeLimitDefaultsToPageSize(t *testing.T) {
77	ctx := testContext()
78	historyRepo := repomocks.NewMockHistoryRepository(t)
79	historyRepo.EXPECT().GetRecent(mock.Anything, 50, 0, repository.HistoryScope{}).Return([]*entity.HistoryEntry{}, nil).Once()
80
81	uc := usecase.NewSearchHistoryUseCase(historyRepo)
82	result, err := uc.GetRecent(ctx, -1, 0)
83
84	require.NoError(t, err)
85	assert.Empty(t, result)
86}
87
88func TestSearchHistoryUseCase_GetRecent_ClampsOversizedLimit(t *testing.T) {
89	ctx := testContext()
90	historyRepo := repomocks.NewMockHistoryRepository(t)
91	historyRepo.EXPECT().GetRecent(mock.Anything, 500, 10, repository.HistoryScope{}).Return([]*entity.HistoryEntry{}, nil).Once()
92
93	uc := usecase.NewSearchHistoryUseCase(historyRepo)
94	result, err := uc.GetRecent(ctx, 10_000, 10)
95
96	require.NoError(t, err)
97	assert.Empty(t, result)
98}
99
100func TestSearchHistoryUseCase_Search_ClampsOversizedLimit(t *testing.T) {
101	ctx := testContext()
102	historyRepo := repomocks.NewMockHistoryRepository(t)
103	historyRepo.EXPECT().Search(mock.Anything, "dumber", 100, repository.HistoryScope{}).Return([]entity.HistoryMatch{}, nil).Once()
104
105	uc := usecase.NewSearchHistoryUseCase(historyRepo)
106	result, err := uc.Search(ctx, usecase.SearchInput{Query: "dumber", Limit: 10_000})
107
108	require.NoError(t, err)
109	require.NotNil(t, result)
110	assert.Empty(t, result.Matches)
111}
112
113func TestSearchHistoryUseCase_Search_AppliesOptionalAgeScope(t *testing.T) {
114	ctx := testContext()
115	historyRepo := repomocks.NewMockHistoryRepository(t)
116	var gotScope repository.HistoryScope
117	historyRepo.EXPECT().
118		Search(mock.Anything, "dumber", 20, mock.Anything).
119		Run(func(_ context.Context, _ string, _ int, scope repository.HistoryScope) { gotScope = scope }).
120		Return([]entity.HistoryMatch{}, nil).
121		Once()
122
123	uc := usecase.NewSearchHistoryUseCase(historyRepo)
124	_, err := uc.Search(ctx, usecase.SearchInput{Query: "dumber", Limit: 20, MaxAgeDays: 30})
125
126	require.NoError(t, err)
127	assert.WithinDuration(t, time.Now().AddDate(0, 0, -30), gotScope.Cutoff, time.Minute)
128}
129
130func TestSearchHistoryUseCase_Search_ZeroMaxAgeMeansAllHistory(t *testing.T) {
131	ctx := testContext()
132	historyRepo := repomocks.NewMockHistoryRepository(t)
133	historyRepo.EXPECT().
134		Search(mock.Anything, "dumber", 20, repository.HistoryScope{}).
135		Return([]entity.HistoryMatch{}, nil).
136		Once()
137
138	uc := usecase.NewSearchHistoryUseCase(historyRepo)
139	_, err := uc.Search(ctx, usecase.SearchInput{Query: "dumber", Limit: 20, MaxAgeDays: 0})
140
141	require.NoError(t, err)
142}
143
144func TestSearchHistoryUseCase_GetRecentWithin_AppliesScopeAndLimit(t *testing.T) {
145	ctx := testContext()
146	historyRepo := repomocks.NewMockHistoryRepository(t)
147	var gotScope repository.HistoryScope
148	historyRepo.EXPECT().
149		GetRecent(mock.Anything, 8, 0, mock.Anything).
150		Run(func(_ context.Context, _ int, _ int, scope repository.HistoryScope) { gotScope = scope }).
151		Return([]*entity.HistoryEntry{}, nil).
152		Once()
153
154	uc := usecase.NewSearchHistoryUseCase(historyRepo)
155	_, err := uc.GetRecentWithin(ctx, 8, 14)
156
157	require.NoError(t, err)
158	assert.WithinDuration(t, time.Now().AddDate(0, 0, -14), gotScope.Cutoff, time.Minute)
159}
160
161func TestSearchHistoryUseCase_GetRecentWithin_ZeroMeansAllHistory(t *testing.T) {
162	ctx := testContext()
163	historyRepo := repomocks.NewMockHistoryRepository(t)
164	historyRepo.EXPECT().
165		GetRecent(mock.Anything, 8, 0, repository.HistoryScope{}).
166		Return([]*entity.HistoryEntry{}, nil).
167		Once()
168
169	uc := usecase.NewSearchHistoryUseCase(historyRepo)
170	_, err := uc.GetRecentWithin(ctx, 8, 0)
171
172	require.NoError(t, err)
173}
174
175func TestSearchHistoryUseCase_GetMostVisitedWithin_AppliesScopeAndLimit(t *testing.T) {
176	ctx := testContext()
177	historyRepo := repomocks.NewMockHistoryRepository(t)
178	var gotScope repository.HistoryScope
179	historyRepo.EXPECT().
180		GetMostVisitedWithin(mock.Anything, 8, mock.Anything).
181		Run(func(_ context.Context, _ int, scope repository.HistoryScope) { gotScope = scope }).
182		Return([]*entity.HistoryEntry{}, nil).
183		Once()
184
185	uc := usecase.NewSearchHistoryUseCase(historyRepo)
186	_, err := uc.GetMostVisitedWithin(ctx, 8, 14)
187
188	require.NoError(t, err)
189	assert.WithinDuration(t, time.Now().AddDate(0, 0, -14), gotScope.Cutoff, time.Minute)
190}
191
192func TestSearchHistoryUseCase_GetMostVisitedWithin_ZeroMeansAllHistory(t *testing.T) {
193	ctx := testContext()
194	historyRepo := repomocks.NewMockHistoryRepository(t)
195	historyRepo.EXPECT().
196		GetMostVisitedWithin(mock.Anything, 8, repository.HistoryScope{}).
197		Return([]*entity.HistoryEntry{}, nil).
198		Once()
199
200	uc := usecase.NewSearchHistoryUseCase(historyRepo)
201	_, err := uc.GetMostVisitedWithin(ctx, 8, 0)
202
203	require.NoError(t, err)
204}
205
206func TestSearchHistoryUseCase_GetRecentWindow_InvalidDomainReturnsValidationError(t *testing.T) {
207	ctx := testContext()
208	historyRepo := repomocks.NewMockHistoryRepository(t)
209	uc := usecase.NewSearchHistoryUseCase(historyRepo)
210
211	result, err := uc.GetRecentWindow(ctx, time.Time{}, 0, "///")
212
213	require.Error(t, err)
214	assert.Nil(t, result)
215	assert.Contains(t, err.Error(), "domain is required")
216}
217
218func TestSearchHistoryUseCase_GetRecentWindow_RejectsInvalidCursorPairs(t *testing.T) {
219	ctx := testContext()
220	historyRepo := repomocks.NewMockHistoryRepository(t)
221	uc := usecase.NewSearchHistoryUseCase(historyRepo)
222
223	result, err := uc.GetRecentWindow(ctx, time.Time{}, 42, "")
224	require.Error(t, err)
225	assert.Nil(t, result)
226	assert.Contains(t, err.Error(), "before and beforeID")
227
228	result, err = uc.GetRecentWindow(ctx, time.Now(), 0, "")
229	require.Error(t, err)
230	assert.Nil(t, result)
231	assert.Contains(t, err.Error(), "before and beforeID")
232
233	result, err = uc.GetRecentWindow(ctx, time.Now(), -1, "")
234	require.Error(t, err)
235	assert.Nil(t, result)
236	assert.Contains(t, err.Error(), "beforeID must be non-negative")
237}
238
239func TestSearchHistoryUseCase_GetRecentWindow_UsesLimitPlusOneForHasMoreAndCursor(t *testing.T) {
240	ctx := testContext()
241	before := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
242	entries := make([]*entity.HistoryEntry, 101)
243	for i := range entries {
244		entries[i] = &entity.HistoryEntry{ID: int64(200 - i), URL: "https://example.com", LastVisited: before.Add(-time.Duration(i) * time.Minute)}
245	}
246	historyRepo := repomocks.NewMockHistoryRepository(t)
247	historyRepo.EXPECT().GetRecentWindow(mock.Anything, before, int64(999), 101).Return(entries, nil).Once()
248	uc := usecase.NewSearchHistoryUseCase(historyRepo)
249
250	result, err := uc.GetRecentWindow(ctx, before, 999, "")
251
252	require.NoError(t, err)
253	require.NotNil(t, result)
254	assert.Len(t, result.Entries, 100)
255	assert.True(t, result.HasMore)
256	assert.Equal(t, entries[99].LastVisited.UTC(), result.CursorLastVisited)
257	assert.Equal(t, entries[99].ID, result.CursorID)
258	assert.Equal(t, before.Add(-24*time.Hour), result.After)
259}
260
261func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) {
262	ctx := testContext()
263
264	historyRepo := repomocks.NewMockHistoryRepository(t)
265
266	entries := []*entity.HistoryEntry{
267		{ID: 1, URL: "https://example.com", Title: "Example", LastVisited: time.Now()},
268		{ID: 2, URL: "https://github.com", Title: "GitHub", LastVisited: time.Now().Add(-24 * time.Hour)},
269	}
270
271	historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil)
272
273	uc := usecase.NewSearchHistoryUseCase(historyRepo)
274
275	result, err := uc.GetRecentSince(ctx, 30)
276	require.NoError(t, err)
277	require.Len(t, result, 2)
278	assert.Equal(t, "https://example.com", result[0].URL)
279	assert.Equal(t, "https://github.com", result[1].URL)
280}
281
282func TestSearchHistoryUseCase_GetRecentSince_ZeroMeansAll(t *testing.T) {
283	ctx := testContext()
284
285	historyRepo := repomocks.NewMockHistoryRepository(t)
286
287	entries := []*entity.HistoryEntry{
288		{ID: 1, URL: "https://example.com", Title: "Example"},
289	}
290
291	// When days == 0, should call GetAllRecentHistory
292	historyRepo.EXPECT().GetAllRecentHistory(mock.Anything).Return(entries, nil)
293
294	uc := usecase.NewSearchHistoryUseCase(historyRepo)
295
296	result, err := uc.GetRecentSince(ctx, 0)
297	require.NoError(t, err)
298	require.Len(t, result, 1)
299}
300
301func TestSearchHistoryUseCase_GetRecentSince_NegativeDaysDefaultsTo30(t *testing.T) {
302	ctx := testContext()
303
304	historyRepo := repomocks.NewMockHistoryRepository(t)
305
306	entries := []*entity.HistoryEntry{}
307
308	historyRepo.EXPECT().GetRecentSince(mock.Anything, 30).Return(entries, nil)
309
310	uc := usecase.NewSearchHistoryUseCase(historyRepo)
311
312	result, err := uc.GetRecentSince(ctx, -5)
313	require.NoError(t, err)
314	require.Empty(t, result)
315}
316
317func TestSearchHistoryUseCase_GetRecentSince_ReturnsErrorOnRepoFailure(t *testing.T) {
318	ctx := testContext()
319
320	historyRepo := repomocks.NewMockHistoryRepository(t)
321
322	historyRepo.EXPECT().GetRecentSince(mock.Anything, 7).Return(nil, errors.New("db connection failed"))
323
324	uc := usecase.NewSearchHistoryUseCase(historyRepo)
325
326	result, err := uc.GetRecentSince(ctx, 7)
327	require.Error(t, err)
328	assert.Nil(t, result)
329	assert.Contains(t, err.Error(), "failed to get recent history")
330}
331
332func TestSearchHistoryUseCase_GetMostVisited_ReturnsEntries(t *testing.T) {
333	ctx := testContext()
334
335	historyRepo := repomocks.NewMockHistoryRepository(t)
336
337	entries := []*entity.HistoryEntry{
338		{ID: 1, URL: "https://github.com", Title: "GitHub", VisitCount: 100},
339		{ID: 2, URL: "https://example.com", Title: "Example", VisitCount: 50},
340	}
341
342	historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return(entries, nil)
343
344	uc := usecase.NewSearchHistoryUseCase(historyRepo)
345
346	result, err := uc.GetMostVisited(ctx, 30)
347	require.NoError(t, err)
348	require.Len(t, result, 2)
349	assert.Equal(t, "https://github.com", result[0].URL)
350	assert.Equal(t, int64(100), result[0].VisitCount)
351}
352
353func TestSearchHistoryUseCase_GetMostVisited_ZeroMeansAll(t *testing.T) {
354	ctx := testContext()
355
356	historyRepo := repomocks.NewMockHistoryRepository(t)
357
358	entries := []*entity.HistoryEntry{}
359
360	// When days == 0, should call GetAllMostVisited
361	historyRepo.EXPECT().GetAllMostVisited(mock.Anything).Return(entries, nil)
362
363	uc := usecase.NewSearchHistoryUseCase(historyRepo)
364
365	result, err := uc.GetMostVisited(ctx, 0)
366	require.NoError(t, err)
367	require.Empty(t, result)
368}
369
370func TestSearchHistoryUseCase_GetMostVisited_ReturnsErrorOnRepoFailure(t *testing.T) {
371	ctx := testContext()
372
373	historyRepo := repomocks.NewMockHistoryRepository(t)
374
375	historyRepo.EXPECT().GetMostVisited(mock.Anything, 14).Return(nil, errors.New("query failed"))
376
377	uc := usecase.NewSearchHistoryUseCase(historyRepo)
378
379	result, err := uc.GetMostVisited(ctx, 14)
380	require.Error(t, err)
381	assert.Nil(t, result)
382	assert.Contains(t, err.Error(), "failed to get most visited history")
383}
384
385func TestSearchHistoryUseCase_GetMostVisited_ReturnsEmptyWhenNoHistory(t *testing.T) {
386	ctx := testContext()
387
388	historyRepo := repomocks.NewMockHistoryRepository(t)
389
390	historyRepo.EXPECT().GetMostVisited(mock.Anything, 30).Return([]*entity.HistoryEntry{}, nil)
391
392	uc := usecase.NewSearchHistoryUseCase(historyRepo)
393
394	result, err := uc.GetMostVisited(ctx, 30)
395	require.NoError(t, err)
396	require.NotNil(t, result)
397	assert.Empty(t, result)
398}
399
400func TestSearchHistoryUseCase_GetDomainStats_ClampsOversizedLimit(t *testing.T) {
401	ctx := testContext()
402	historyRepo := repomocks.NewMockHistoryRepository(t)
403	historyRepo.EXPECT().GetDomainStats(mock.Anything, 100).Return([]*entity.DomainStat{}, nil).Once()
404
405	uc := usecase.NewSearchHistoryUseCase(historyRepo)
406	result, err := uc.GetDomainStats(ctx, 10_000)
407
408	require.NoError(t, err)
409	assert.Empty(t, result)
410}
411
412func TestSearchHistoryUseCase_Delete_PublishesDeleteChangeAfterSuccess(t *testing.T) {
413	ctx := testContext()
414	historyRepo := repomocks.NewMockHistoryRepository(t)
415	historyRepo.EXPECT().Delete(mock.Anything, int64(42)).Return(nil).Once()
416	sink := &recordingHistoryChangeSink{}
417	uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
418
419	err := uc.Delete(ctx, 42)
420
421	require.NoError(t, err)
422	change := requirePublishedHistoryChange(t, sink, dto.HistoryChangeReasonDelete)
423	assert.Equal(t, 1, change.DeleteCount)
424	assert.True(t, change.DeleteCountKnown)
425}
426
427func TestSearchHistoryUseCase_Delete_DoesNotPublishOnRepositoryError(t *testing.T) {
428	ctx := testContext()
429	historyRepo := repomocks.NewMockHistoryRepository(t)
430	historyRepo.EXPECT().Delete(mock.Anything, int64(42)).Return(errors.New("delete failed")).Once()
431	sink := &recordingHistoryChangeSink{}
432	uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
433
434	err := uc.Delete(ctx, 42)
435
436	require.Error(t, err)
437	assert.Empty(t, sink.changes)
438}
439
440func TestSearchHistoryUseCase_DeleteByDomain_PublishesDeleteChangeAfterSuccess(t *testing.T) {
441	ctx := testContext()
442	historyRepo := repomocks.NewMockHistoryRepository(t)
443	historyRepo.EXPECT().DeleteByDomain(mock.Anything, "example.com").Return(nil).Once()
444	sink := &recordingHistoryChangeSink{}
445	uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
446
447	err := uc.DeleteByDomain(ctx, "https://example.com/path")
448
449	require.NoError(t, err)
450	change := requirePublishedHistoryChange(t, sink, dto.HistoryChangeReasonDelete)
451	assert.Zero(t, change.DeleteCount, "domain delete count is unknown for aggregate deletes")
452	assert.False(t, change.DeleteCountKnown)
453}
454
455func TestSearchHistoryUseCase_DeleteByDomain_DoesNotPublishOnRepositoryError(t *testing.T) {
456	ctx := testContext()
457	historyRepo := repomocks.NewMockHistoryRepository(t)
458	historyRepo.EXPECT().DeleteByDomain(mock.Anything, "example.com").Return(errors.New("delete domain failed")).Once()
459	sink := &recordingHistoryChangeSink{}
460	uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
461
462	err := uc.DeleteByDomain(ctx, "example.com")
463
464	require.Error(t, err)
465	assert.Empty(t, sink.changes)
466}
467
468func TestSearchHistoryUseCase_SetHistoryMutationCoordinator_TypedNilClearsCoordinator(t *testing.T) {
469	ctx := testContext()
470	historyRepo := repomocks.NewMockHistoryRepository(t)
471	historyRepo.EXPECT().DeleteAll(mock.Anything).Return(nil).Once()
472	previous := &recordingHistoryMutationCoordinator{}
473	var coordinator *nilPanicHistoryMutationCoordinator
474	sink := &recordingHistoryChangeSink{}
475	uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
476	uc.SetHistoryMutationCoordinator(previous)
477	uc.SetHistoryMutationCoordinator(coordinator)
478
479	require.NotPanics(t, func() {
480		require.NoError(t, uc.ClearAll(ctx))
481	})
482	require.Zero(t, previous.beginCount)
483	requirePublishedHistoryChange(t, sink, dto.HistoryChangeReasonClear)
484}
485
486func TestSearchHistoryUseCase_ClearAll_PublishesClearChangeAfterSuccess(t *testing.T) {
487	ctx := testContext()
488	historyRepo := repomocks.NewMockHistoryRepository(t)
489	historyRepo.EXPECT().DeleteAll(mock.Anything).Return(nil).Once()
490	sink := &recordingHistoryChangeSink{}
491	uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
492
493	err := uc.ClearAll(ctx)
494
495	require.NoError(t, err)
496	change := requirePublishedHistoryChange(t, sink, dto.HistoryChangeReasonClear)
497	assert.Zero(t, change.DeleteCount)
498	assert.False(t, change.DeleteCountKnown)
499}
500
501func TestSearchHistoryUseCase_ClearAll_CoordinatesPendingRecorderWritesBeforeDelete(t *testing.T) {
502	ctx := testContext()
503	historyRepo := repomocks.NewMockHistoryRepository(t)
504	coordinator := &recordingHistoryMutationCoordinator{}
505	historyRepo.EXPECT().DeleteAll(mock.Anything).RunAndReturn(func(context.Context) error {
506		require.Equal(t, 1, coordinator.beginCount)
507		require.Zero(t, coordinator.releaseCount)
508		return nil
509	}).Once()
510	sink := &recordingHistoryChangeSink{onChange: func(dto.HistoryChange) {
511		require.Equal(t, 1, coordinator.releaseCount, "history change should publish after mutation coordinator release")
512	}}
513	uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
514	uc.SetHistoryMutationCoordinator(coordinator)
515
516	err := uc.ClearAll(ctx)
517
518	require.NoError(t, err)
519	require.Equal(t, 1, coordinator.beginCount)
520	require.Equal(t, 1, coordinator.releaseCount)
521	requirePublishedHistoryChange(t, sink, dto.HistoryChangeReasonClear)
522}
523
524func TestSearchHistoryUseCase_OtherDestructiveMutationsCoordinateBeforeDeleteAndPublish(t *testing.T) {
525	ctx := testContext()
526	before := time.Now().Add(-7 * 24 * time.Hour)
527
528	tests := []struct {
529		name             string
530		expectRepo       func(*repomocks.MockHistoryRepository, *recordingHistoryMutationCoordinator)
531		run              func(*usecase.SearchHistoryUseCase) error
532		reason           dto.HistoryChangeReason
533		deleteCount      int
534		deleteCountKnown bool
535	}{
536		{
537			name: "delete",
538			expectRepo: func(repo *repomocks.MockHistoryRepository, coordinator *recordingHistoryMutationCoordinator) {
539				repo.EXPECT().Delete(mock.Anything, int64(42)).RunAndReturn(func(context.Context, int64) error {
540					require.Equal(t, 1, coordinator.beginCount)
541					require.Zero(t, coordinator.releaseCount)
542					return nil
543				}).Once()
544			},
545			run: func(uc *usecase.SearchHistoryUseCase) error {
546				return uc.Delete(ctx, 42)
547			},
548			reason:           dto.HistoryChangeReasonDelete,
549			deleteCount:      1,
550			deleteCountKnown: true,
551		},
552		{
553			name: "delete by domain",
554			expectRepo: func(repo *repomocks.MockHistoryRepository, coordinator *recordingHistoryMutationCoordinator) {
555				repo.EXPECT().DeleteByDomain(mock.Anything, "example.com").RunAndReturn(func(context.Context, string) error {
556					require.Equal(t, 1, coordinator.beginCount)
557					require.Zero(t, coordinator.releaseCount)
558					return nil
559				}).Once()
560			},
561			run: func(uc *usecase.SearchHistoryUseCase) error {
562				return uc.DeleteByDomain(ctx, "https://example.com/path")
563			},
564			reason: dto.HistoryChangeReasonDelete,
565		},
566		{
567			name: "clear older than",
568			expectRepo: func(repo *repomocks.MockHistoryRepository, coordinator *recordingHistoryMutationCoordinator) {
569				repo.EXPECT().DeleteOlderThan(mock.Anything, before).RunAndReturn(func(context.Context, time.Time) error {
570					require.Equal(t, 1, coordinator.beginCount)
571					require.Zero(t, coordinator.releaseCount)
572					return nil
573				}).Once()
574			},
575			run: func(uc *usecase.SearchHistoryUseCase) error {
576				return uc.ClearOlderThan(ctx, before)
577			},
578			reason: dto.HistoryChangeReasonClear,
579		},
580		{
581			name: "clear range",
582			expectRepo: func(repo *repomocks.MockHistoryRepository, coordinator *recordingHistoryMutationCoordinator) {
583				repo.EXPECT().DeleteSince(mock.Anything, mock.AnythingOfType("time.Time")).RunAndReturn(func(context.Context, time.Time) error {
584					require.Equal(t, 1, coordinator.beginCount)
585					require.Zero(t, coordinator.releaseCount)
586					return nil
587				}).Once()
588			},
589			run: func(uc *usecase.SearchHistoryUseCase) error {
590				return uc.ClearRange(ctx, "hour")
591			},
592			reason: dto.HistoryChangeReasonClear,
593		},
594	}
595
596	for _, tt := range tests {
597		t.Run(tt.name, func(t *testing.T) {
598			historyRepo := repomocks.NewMockHistoryRepository(t)
599			coordinator := &recordingHistoryMutationCoordinator{}
600			tt.expectRepo(historyRepo, coordinator)
601			sink := &recordingHistoryChangeSink{onChange: func(dto.HistoryChange) {
602				require.Equal(t, 1, coordinator.releaseCount, "history change should publish after mutation coordinator release")
603			}}
604			uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
605			uc.SetHistoryMutationCoordinator(coordinator)
606
607			err := tt.run(uc)
608
609			require.NoError(t, err)
610			require.Equal(t, 1, coordinator.beginCount)
611			require.Equal(t, 1, coordinator.releaseCount)
612			change := requirePublishedHistoryChange(t, sink, tt.reason)
613			require.Equal(t, tt.deleteCount, change.DeleteCount)
614			require.Equal(t, tt.deleteCountKnown, change.DeleteCountKnown)
615		})
616	}
617}
618
619func TestSearchHistoryUseCase_DestructiveMutationsReleaseCoordinatorOnRepositoryError(t *testing.T) {
620	ctx := testContext()
621	before := time.Now().Add(-7 * 24 * time.Hour)
622	repoErr := errors.New("repository failed")
623
624	tests := []struct {
625		name       string
626		expectRepo func(*repomocks.MockHistoryRepository, *recordingHistoryMutationCoordinator)
627		run        func(*usecase.SearchHistoryUseCase) error
628	}{
629		{
630			name: "delete",
631			expectRepo: func(repo *repomocks.MockHistoryRepository, coordinator *recordingHistoryMutationCoordinator) {
632				repo.EXPECT().Delete(mock.Anything, int64(42)).RunAndReturn(func(context.Context, int64) error {
633					require.Equal(t, 1, coordinator.beginCount)
634					require.Zero(t, coordinator.releaseCount)
635					return repoErr
636				}).Once()
637			},
638			run: func(uc *usecase.SearchHistoryUseCase) error { return uc.Delete(ctx, 42) },
639		},
640		{
641			name: "delete by domain",
642			expectRepo: func(repo *repomocks.MockHistoryRepository, coordinator *recordingHistoryMutationCoordinator) {
643				repo.EXPECT().DeleteByDomain(mock.Anything, "example.com").RunAndReturn(func(context.Context, string) error {
644					require.Equal(t, 1, coordinator.beginCount)
645					require.Zero(t, coordinator.releaseCount)
646					return repoErr
647				}).Once()
648			},
649			run: func(uc *usecase.SearchHistoryUseCase) error { return uc.DeleteByDomain(ctx, "example.com") },
650		},
651		{
652			name: "clear older than",
653			expectRepo: func(repo *repomocks.MockHistoryRepository, coordinator *recordingHistoryMutationCoordinator) {
654				repo.EXPECT().DeleteOlderThan(mock.Anything, before).RunAndReturn(func(context.Context, time.Time) error {
655					require.Equal(t, 1, coordinator.beginCount)
656					require.Zero(t, coordinator.releaseCount)
657					return repoErr
658				}).Once()
659			},
660			run: func(uc *usecase.SearchHistoryUseCase) error { return uc.ClearOlderThan(ctx, before) },
661		},
662		{
663			name: "clear range",
664			expectRepo: func(repo *repomocks.MockHistoryRepository, coordinator *recordingHistoryMutationCoordinator) {
665				repo.EXPECT().DeleteSince(mock.Anything, mock.AnythingOfType("time.Time")).RunAndReturn(func(context.Context, time.Time) error {
666					require.Equal(t, 1, coordinator.beginCount)
667					require.Zero(t, coordinator.releaseCount)
668					return repoErr
669				}).Once()
670			},
671			run: func(uc *usecase.SearchHistoryUseCase) error { return uc.ClearRange(ctx, "hour") },
672		},
673		{
674			name: "clear all",
675			expectRepo: func(repo *repomocks.MockHistoryRepository, coordinator *recordingHistoryMutationCoordinator) {
676				repo.EXPECT().DeleteAll(mock.Anything).RunAndReturn(func(context.Context) error {
677					require.Equal(t, 1, coordinator.beginCount)
678					require.Zero(t, coordinator.releaseCount)
679					return repoErr
680				}).Once()
681			},
682			run: func(uc *usecase.SearchHistoryUseCase) error { return uc.ClearAll(ctx) },
683		},
684	}
685
686	for _, tt := range tests {
687		t.Run(tt.name, func(t *testing.T) {
688			historyRepo := repomocks.NewMockHistoryRepository(t)
689			coordinator := &recordingHistoryMutationCoordinator{}
690			tt.expectRepo(historyRepo, coordinator)
691			sink := &recordingHistoryChangeSink{}
692			uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
693			uc.SetHistoryMutationCoordinator(coordinator)
694
695			err := tt.run(uc)
696
697			require.Error(t, err)
698			require.Equal(t, 1, coordinator.beginCount)
699			require.Equal(t, 1, coordinator.releaseCount)
700			assert.Empty(t, sink.changes)
701		})
702	}
703}
704
705func TestSearchHistoryUseCase_OtherDestructiveMutationsReturnCoordinatorErrorBeforeDeleting(t *testing.T) {
706	ctx := testContext()
707	before := time.Now().Add(-7 * 24 * time.Hour)
708	tests := []struct {
709		name string
710		run  func(*usecase.SearchHistoryUseCase) error
711	}{
712		{name: "delete", run: func(uc *usecase.SearchHistoryUseCase) error { return uc.Delete(ctx, 42) }},
713		{name: "delete by domain", run: func(uc *usecase.SearchHistoryUseCase) error { return uc.DeleteByDomain(ctx, "example.com") }},
714		{name: "clear older than", run: func(uc *usecase.SearchHistoryUseCase) error { return uc.ClearOlderThan(ctx, before) }},
715		{name: "clear range", run: func(uc *usecase.SearchHistoryUseCase) error { return uc.ClearRange(ctx, "hour") }},
716	}
717
718	for _, tt := range tests {
719		t.Run(tt.name, func(t *testing.T) {
720			historyRepo := repomocks.NewMockHistoryRepository(t)
721			coordinator := &recordingHistoryMutationCoordinator{err: errors.New("flush failed")}
722			sink := &recordingHistoryChangeSink{}
723			uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
724			uc.SetHistoryMutationCoordinator(coordinator)
725
726			err := tt.run(uc)
727
728			require.Error(t, err)
729			require.Equal(t, 1, coordinator.beginCount)
730			require.Zero(t, coordinator.releaseCount)
731			assert.Empty(t, sink.changes)
732		})
733	}
734}
735
736func TestSearchHistoryUseCase_ClearAll_ReturnsCoordinatorErrorBeforeDeleting(t *testing.T) {
737	ctx := testContext()
738	historyRepo := repomocks.NewMockHistoryRepository(t)
739	coordinator := &recordingHistoryMutationCoordinator{err: errors.New("flush failed")}
740	sink := &recordingHistoryChangeSink{}
741	uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
742	uc.SetHistoryMutationCoordinator(coordinator)
743
744	err := uc.ClearAll(ctx)
745
746	require.Error(t, err)
747	require.Equal(t, 1, coordinator.beginCount)
748	require.Zero(t, coordinator.releaseCount)
749	assert.Empty(t, sink.changes)
750}
751
752func TestSearchHistoryUseCase_ClearAll_DoesNotPublishOnRepositoryError(t *testing.T) {
753	ctx := testContext()
754	historyRepo := repomocks.NewMockHistoryRepository(t)
755	historyRepo.EXPECT().DeleteAll(mock.Anything).Return(errors.New("clear all failed")).Once()
756	sink := &recordingHistoryChangeSink{}
757	uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
758
759	err := uc.ClearAll(ctx)
760
761	require.Error(t, err)
762	assert.Empty(t, sink.changes)
763}
764
765func TestSearchHistoryUseCase_ClearOlderThan_PublishesClearChangeAfterSuccess(t *testing.T) {
766	ctx := testContext()
767	historyRepo := repomocks.NewMockHistoryRepository(t)
768	before := time.Now().Add(-7 * 24 * time.Hour)
769	historyRepo.EXPECT().DeleteOlderThan(mock.Anything, before).Return(nil).Once()
770	sink := &recordingHistoryChangeSink{}
771	uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
772
773	err := uc.ClearOlderThan(ctx, before)
774
775	require.NoError(t, err)
776	requirePublishedHistoryChange(t, sink, dto.HistoryChangeReasonClear)
777}
778
779func TestSearchHistoryUseCase_ClearOlderThan_DoesNotPublishOnRepositoryError(t *testing.T) {
780	ctx := testContext()
781	historyRepo := repomocks.NewMockHistoryRepository(t)
782	before := time.Now().Add(-7 * 24 * time.Hour)
783	historyRepo.EXPECT().DeleteOlderThan(mock.Anything, before).Return(errors.New("clear old failed")).Once()
784	sink := &recordingHistoryChangeSink{}
785	uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
786
787	err := uc.ClearOlderThan(ctx, before)
788
789	require.Error(t, err)
790	assert.Empty(t, sink.changes)
791}
792
793func TestSearchHistoryUseCase_ClearRange_RecentRangeDeletesSinceCutoffAndPublishesClearChange(t *testing.T) {
794	ctx := testContext()
795	historyRepo := repomocks.NewMockHistoryRepository(t)
796	now := time.Now()
797	expectedCutoff := now.Add(-time.Hour)
798	historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool {
799		return since.After(expectedCutoff.Add(-time.Minute)) && since.Before(expectedCutoff.Add(time.Minute))
800	})).Return(nil).Once()
801	sink := &recordingHistoryChangeSink{}
802	uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
803
804	err := uc.ClearRange(ctx, "hour")
805
806	require.NoError(t, err)
807	requirePublishedHistoryChange(t, sink, dto.HistoryChangeReasonClear)
808}
809
810func TestSearchHistoryUseCase_ClearRange_RecentRangeDoesNotPublishOnRepositoryError(t *testing.T) {
811	ctx := testContext()
812	historyRepo := repomocks.NewMockHistoryRepository(t)
813	historyRepo.EXPECT().DeleteSince(mock.Anything, mock.AnythingOfType("time.Time")).Return(errors.New("delete since failed")).Once()
814	sink := &recordingHistoryChangeSink{}
815	uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
816
817	err := uc.ClearRange(ctx, "hour")
818
819	require.Error(t, err)
820	assert.Empty(t, sink.changes)
821}
822
823func TestSearchHistoryUseCase_ClearRange_TodayDeletesSinceLocalMidnight(t *testing.T) {
824	ctx := testContext()
825	historyRepo := repomocks.NewMockHistoryRepository(t)
826	now := time.Now()
827	expectedCutoff := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
828	historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool {
829		return since.Equal(expectedCutoff)
830	})).Return(nil).Once()
831
832	uc := usecase.NewSearchHistoryUseCase(historyRepo)
833
834	err := uc.ClearRange(ctx, "day")
835	require.NoError(t, err)
836}
837
838func TestSearchHistoryUseCase_ClearRange_AllUsesClearAllAndPublishesOnce(t *testing.T) {
839	ctx := testContext()
840
841	historyRepo := repomocks.NewMockHistoryRepository(t)
842
843	historyRepo.EXPECT().DeleteAll(mock.Anything).Return(nil).Once()
844	sink := &recordingHistoryChangeSink{}
845	uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
846
847	err := uc.ClearRange(ctx, "all")
848	require.NoError(t, err)
849	requirePublishedHistoryChange(t, sink, dto.HistoryChangeReasonClear)
850}
851
852func TestSearchHistoryUseCase_ClearRange_AllDoesNotPublishOnRepositoryError(t *testing.T) {
853	ctx := testContext()
854
855	historyRepo := repomocks.NewMockHistoryRepository(t)
856
857	historyRepo.EXPECT().DeleteAll(mock.Anything).Return(errors.New("clear all failed")).Once()
858	sink := &recordingHistoryChangeSink{}
859	uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
860
861	err := uc.ClearRange(ctx, "all")
862	require.Error(t, err)
863	assert.Empty(t, sink.changes)
864}
865
866func TestSearchHistoryUseCase_ClearRange_UnknownRangeDoesNotDeleteOrPublish(t *testing.T) {
867	ctx := testContext()
868
869	historyRepo := repomocks.NewMockHistoryRepository(t)
870	sink := &recordingHistoryChangeSink{}
871	uc := usecase.NewSearchHistoryUseCase(historyRepo, sink)
872
873	err := uc.ClearRange(ctx, "bogus")
874	require.Error(t, err)
875	assert.Contains(t, err.Error(), "unknown history delete range")
876	assert.Empty(t, sink.changes)
877}
878
879func TestSearchHistoryUseCase_GetRecentByDomainAllowsUnderscoreDomains(t *testing.T) {
880	ctx := testContext()
881	historyRepo := repomocks.NewMockHistoryRepository(t)
882	want := []*entity.HistoryEntry{{URL: "https://example_.com"}}
883	historyRepo.EXPECT().GetRecentByDomain(mock.Anything, "example_.com", 20, 0).Return(want, nil).Once()
884	uc := usecase.NewSearchHistoryUseCase(historyRepo)
885
886	result, err := uc.GetRecentByDomain(ctx, "example_.com", 20, 0)
887
888	require.NoError(t, err)
889	assert.Equal(t, want, result)
890}
891
892func TestSearchHistoryUseCase_GetRecentByDomain_ClampsOversizedLimit(t *testing.T) {
893	ctx := testContext()
894	historyRepo := repomocks.NewMockHistoryRepository(t)
895	historyRepo.EXPECT().GetRecentByDomain(mock.Anything, "example.com", 500, 25).Return([]*entity.HistoryEntry{}, nil).Once()
896	uc := usecase.NewSearchHistoryUseCase(historyRepo)
897
898	result, err := uc.GetRecentByDomain(ctx, "example.com", 10_000, 25)
899
900	require.NoError(t, err)
901	assert.Empty(t, result)
902}
903
904func TestSearchHistoryUseCase_DeleteByDomainAllowsUnderscoreDomains(t *testing.T) {
905	ctx := testContext()
906	historyRepo := repomocks.NewMockHistoryRepository(t)
907	historyRepo.EXPECT().DeleteByDomain(mock.Anything, "example_.com").Return(nil).Once()
908	uc := usecase.NewSearchHistoryUseCase(historyRepo)
909
910	err := uc.DeleteByDomain(ctx, "example_.com")
911
912	require.NoError(t, err)
913}
914
915func TestSearchHistoryUseCase_Search_NegativeMaxAgeMeansAllHistory(t *testing.T) {
916	ctx := testContext()
917	historyRepo := repomocks.NewMockHistoryRepository(t)
918	historyRepo.EXPECT().
919		Search(mock.Anything, "dumber", 20, repository.HistoryScope{}).
920		Return([]entity.HistoryMatch{}, nil).
921		Once()
922
923	uc := usecase.NewSearchHistoryUseCase(historyRepo)
924	_, err := uc.Search(ctx, usecase.SearchInput{Query: "dumber", Limit: 20, MaxAgeDays: -5})
925
926	require.NoError(t, err)
927}
928
929func TestSearchHistoryUseCase_GetRecentWithin_NegativeMaxAgeMeansAllHistory(t *testing.T) {
930	ctx := testContext()
931	historyRepo := repomocks.NewMockHistoryRepository(t)
932	historyRepo.EXPECT().
933		GetRecent(mock.Anything, 8, 0, repository.HistoryScope{}).
934		Return([]*entity.HistoryEntry{}, nil).
935		Once()
936
937	uc := usecase.NewSearchHistoryUseCase(historyRepo)
938	_, err := uc.GetRecentWithin(ctx, 8, -5)
939
940	require.NoError(t, err)
941}
942
943func TestSearchHistoryUseCase_GetRecentWithin_NonPositiveLimitReturnsEmpty(t *testing.T) {
944	ctx := testContext()
945	historyRepo := repomocks.NewMockHistoryRepository(t)
946	uc := usecase.NewSearchHistoryUseCase(historyRepo)
947
948	results, err := uc.GetRecentWithin(ctx, 0, 30)
949
950	require.NoError(t, err)
951	assert.Empty(t, results)
952}
953
954func TestSearchHistoryUseCase_GetMostVisitedWithin_NonPositiveLimitReturnsEmpty(t *testing.T) {
955	ctx := testContext()
956	historyRepo := repomocks.NewMockHistoryRepository(t)
957	uc := usecase.NewSearchHistoryUseCase(historyRepo)
958
959	results, err := uc.GetMostVisitedWithin(ctx, -3, 30)
960
961	require.NoError(t, err)
962	assert.Empty(t, results)
963}
964
965func TestSearchHistoryUseCase_GetRecentWithin_ClampsOversizedLimit(t *testing.T) {
966	ctx := testContext()
967	historyRepo := repomocks.NewMockHistoryRepository(t)
968	historyRepo.EXPECT().
969		GetRecent(mock.Anything, 500, 0, mock.Anything).
970		Return([]*entity.HistoryEntry{}, nil).
971		Once()
972
973	uc := usecase.NewSearchHistoryUseCase(historyRepo)
974	_, err := uc.GetRecentWithin(ctx, 10_000, 30)
975
976	require.NoError(t, err)
977}
978
979func TestSearchHistoryUseCase_GetMostVisitedWithin_ClampsOversizedLimit(t *testing.T) {
980	ctx := testContext()
981	historyRepo := repomocks.NewMockHistoryRepository(t)
982	historyRepo.EXPECT().
983		GetMostVisitedWithin(mock.Anything, 500, mock.Anything).
984		Return([]*entity.HistoryEntry{}, nil).
985		Once()
986
987	uc := usecase.NewSearchHistoryUseCase(historyRepo)
988	_, err := uc.GetMostVisitedWithin(ctx, 10_000, 30)
989
990	require.NoError(t, err)
991}

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