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}