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}