v0.32.0 0617278

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

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