main 5041353

manage_favorites_test.go RawHistoryFolder

1package usecase_test
2
3import (
4	"context"
5	"errors"
6	"testing"
7
8	"github.com/bnema/dumber/internal/application/dto"
9	"github.com/bnema/dumber/internal/application/usecase"
10	"github.com/bnema/dumber/internal/domain/entity"
11	repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
12	"github.com/stretchr/testify/assert"
13	"github.com/stretchr/testify/mock"
14	"github.com/stretchr/testify/require"
15)
16
17func TestManageFavoritesUseCase_GetAllURLs_ReturnsURLSet(t *testing.T) {
18	ctx := testContext()
19
20	favoriteRepo := repomocks.NewMockFavoriteRepository(t)
21	tagRepo := repomocks.NewMockTagRepository(t)
22
23	favorites := []*entity.Favorite{
24		{ID: 1, URL: "https://example.com"},
25		{ID: 2, URL: "https://github.com"},
26		{ID: 3, URL: "https://google.com"},
27	}
28
29	favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
30
31	uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
32
33	urls, err := uc.GetAllURLs(ctx)
34	require.NoError(t, err)
35	require.NotNil(t, urls)
36
37	assert.Len(t, urls, 3)
38	_, hasExample := urls["https://example.com"]
39	_, hasGithub := urls["https://github.com"]
40	_, hasGoogle := urls["https://google.com"]
41	assert.True(t, hasExample)
42	assert.True(t, hasGithub)
43	assert.True(t, hasGoogle)
44}
45
46func TestManageFavoritesUseCase_GetAllURLs_ReturnsEmptySetWhenNoFavorites(t *testing.T) {
47	ctx := testContext()
48
49	favoriteRepo := repomocks.NewMockFavoriteRepository(t)
50	tagRepo := repomocks.NewMockTagRepository(t)
51
52	favoriteRepo.EXPECT().GetAll(mock.Anything).Return([]*entity.Favorite{}, nil)
53
54	uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
55
56	urls, err := uc.GetAllURLs(ctx)
57	require.NoError(t, err)
58	require.NotNil(t, urls)
59	assert.Empty(t, urls)
60}
61
62func TestManageFavoritesUseCase_GetAllURLs_ReturnsErrorOnRepoFailure(t *testing.T) {
63	ctx := testContext()
64
65	favoriteRepo := repomocks.NewMockFavoriteRepository(t)
66	tagRepo := repomocks.NewMockTagRepository(t)
67
68	favoriteRepo.EXPECT().GetAll(mock.Anything).Return(nil, errors.New("db connection failed"))
69
70	uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
71
72	urls, err := uc.GetAllURLs(ctx)
73	require.Error(t, err)
74	assert.Nil(t, urls)
75	assert.Contains(t, err.Error(), "failed to get favorites")
76}
77
78func TestManageFavoritesUseCase_Toggle_AddsWhenNotFavorite(t *testing.T) {
79	ctx := testContext()
80
81	favoriteRepo := repomocks.NewMockFavoriteRepository(t)
82	tagRepo := repomocks.NewMockTagRepository(t)
83
84	// URL is not a favorite
85	favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil)
86	favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(f *entity.Favorite) bool {
87		return f.URL == "https://example.com" && f.Title == "Example"
88	})).Return(nil)
89
90	uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
91
92	result, err := uc.Toggle(ctx, "https://example.com", "Example")
93	require.NoError(t, err)
94	require.NotNil(t, result)
95
96	assert.True(t, result.Added)
97	assert.Equal(t, "https://example.com", result.URL)
98	assert.Equal(t, "Example", result.Title)
99	assert.Equal(t, "Favorite added", result.Message)
100}
101
102func TestManageFavoritesUseCase_Toggle_RemovesWhenAlreadyFavorite(t *testing.T) {
103	ctx := testContext()
104
105	favoriteRepo := repomocks.NewMockFavoriteRepository(t)
106	tagRepo := repomocks.NewMockTagRepository(t)
107
108	existingFav := &entity.Favorite{
109		ID:    42,
110		URL:   "https://example.com",
111		Title: "Example Site",
112	}
113
114	// URL is already a favorite
115	favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil)
116	favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(nil)
117
118	uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
119
120	result, err := uc.Toggle(ctx, "https://example.com", "Example")
121	require.NoError(t, err)
122	require.NotNil(t, result)
123
124	assert.False(t, result.Added)
125	assert.Equal(t, "https://example.com", result.URL)
126	assert.Equal(t, "Example Site", result.Title) // Uses existing title
127	assert.Equal(t, "Favorite removed", result.Message)
128}
129
130func TestManageFavoritesUseCase_Toggle_EmptyURLReturnsError(t *testing.T) {
131	ctx := testContext()
132
133	favoriteRepo := repomocks.NewMockFavoriteRepository(t)
134	tagRepo := repomocks.NewMockTagRepository(t)
135
136	uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
137
138	result, err := uc.Toggle(ctx, "", "Title")
139	require.Error(t, err)
140	assert.Nil(t, result)
141	assert.Contains(t, err.Error(), "favorite URL is required")
142}
143
144func TestManageFavoritesUseCase_ToggleNormalizesURLBeforeLookup(t *testing.T) {
145	ctx := testContext()
146
147	favoriteRepo := repomocks.NewMockFavoriteRepository(t)
148	tagRepo := repomocks.NewMockTagRepository(t)
149
150	favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once()
151	favoriteRepo.EXPECT().FindByURL(mock.Anything, "example.com").Return(nil, nil).Once()
152	favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool {
153		return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example"
154	})).Return(nil).Once()
155
156	uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
157
158	result, err := uc.Toggle(ctx, " example.com ", " Example ")
159	require.NoError(t, err)
160	require.NotNil(t, result)
161	assert.True(t, result.Added)
162	assert.Equal(t, "https://example.com", result.URL)
163	assert.Equal(t, "Example", result.Title)
164}
165
166func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T) {
167	ctx := testContext()
168
169	favoriteRepo := repomocks.NewMockFavoriteRepository(t)
170	tagRepo := repomocks.NewMockTagRepository(t)
171
172	favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, errors.New("db error"))
173
174	uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
175
176	result, err := uc.Toggle(ctx, "https://example.com", "Example")
177	require.Error(t, err)
178	assert.Nil(t, result)
179	assert.Contains(t, err.Error(), "failed to check existing favorite")
180}
181
182func TestManageFavoritesUseCase_Toggle_SaveErrorReturnsError(t *testing.T) {
183	ctx := testContext()
184
185	favoriteRepo := repomocks.NewMockFavoriteRepository(t)
186	tagRepo := repomocks.NewMockTagRepository(t)
187
188	favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil)
189	favoriteRepo.EXPECT().Save(mock.Anything, mock.Anything).Return(errors.New("db error"))
190
191	uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
192
193	result, err := uc.Toggle(ctx, "https://example.com", "Example")
194	require.Error(t, err)
195	assert.Nil(t, result)
196	assert.Contains(t, err.Error(), "failed to add favorite")
197}
198
199func TestManageFavoritesUseCase_Toggle_DeleteErrorReturnsError(t *testing.T) {
200	ctx := testContext()
201
202	favoriteRepo := repomocks.NewMockFavoriteRepository(t)
203	tagRepo := repomocks.NewMockTagRepository(t)
204
205	existingFav := &entity.Favorite{ID: 42, URL: "https://example.com", Title: "Example"}
206	favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(existingFav, nil)
207	favoriteRepo.EXPECT().Delete(mock.Anything, entity.FavoriteID(42)).Return(errors.New("db error"))
208
209	uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
210
211	result, err := uc.Toggle(ctx, "https://example.com", "Example")
212	require.Error(t, err)
213	assert.Nil(t, result)
214	assert.Contains(t, err.Error(), "failed to remove favorite")
215}
216
217func TestManageFavoritesUseCase_UpdateFavoritePreservesShortcutWhenOmitted(t *testing.T) {
218	ctx := testContext()
219	shortcut := 3
220	existing := &entity.Favorite{
221		ID:          42,
222		URL:         "https://example.com",
223		Title:       "Example",
224		ShortcutKey: &shortcut,
225	}
226
227	favoriteRepo := repomocks.NewMockFavoriteRepository(t)
228	tagRepo := repomocks.NewMockTagRepository(t)
229	favoriteRepo.EXPECT().FindByID(mock.Anything, entity.FavoriteID(42)).Return(existing, nil)
230	favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool {
231		return fav.ID == 42 && fav.Title == "Updated" && fav.ShortcutKey != nil && *fav.ShortcutKey == 3
232	})).Return(nil)
233
234	uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
235	updated, err := uc.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
236		ID:    42,
237		Title: "Updated",
238	})
239	require.NoError(t, err)
240	require.NotNil(t, updated.ShortcutKey)
241	assert.Equal(t, 3, *updated.ShortcutKey)
242}
243
244func TestManageFavoritesUseCase_UpdateFavoriteClearsShortcutWhenExplicit(t *testing.T) {
245	ctx := testContext()
246	shortcut := 3
247	existing := &entity.Favorite{
248		ID:          42,
249		URL:         "https://example.com",
250		Title:       "Example",
251		ShortcutKey: &shortcut,
252	}
253
254	favoriteRepo := repomocks.NewMockFavoriteRepository(t)
255	tagRepo := repomocks.NewMockTagRepository(t)
256	favoriteRepo.EXPECT().FindByID(mock.Anything, entity.FavoriteID(42)).Return(existing, nil)
257	favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool {
258		return fav.ID == 42 && fav.Title == "Updated" && fav.ShortcutKey == nil
259	})).Return(nil)
260
261	uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
262	updated, err := uc.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
263		ID:             42,
264		Title:          "Updated",
265		ShortcutKeySet: true,
266	})
267	require.NoError(t, err)
268	assert.Nil(t, updated.ShortcutKey)
269}
270
271func TestManageFavoritesUseCase_FilterForOmnibox_RanksPrefixBeforeContains(t *testing.T) {
272	ctx := testContext()
273
274	favoriteRepo := repomocks.NewMockFavoriteRepository(t)
275	tagRepo := repomocks.NewMockTagRepository(t)
276
277	favorites := []*entity.Favorite{
278		{ID: 1, URL: "https://example.com/landing", Title: "GitHub docs mirror", Position: 0},
279		{ID: 2, URL: "https://github.com/bnema/dumber", Title: "Project repo", Position: 1},
280		{ID: 3, URL: "https://gitlab.com/team/project", Title: "GitLab", Position: 2},
281	}
282	favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
283
284	uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
285
286	results, err := uc.FilterForOmnibox(ctx, "git")
287	require.NoError(t, err)
288	require.Len(t, results, 3)
289	assert.Equal(t, "https://github.com/bnema/dumber", results[0].URL)
290	assert.Equal(t, "https://gitlab.com/team/project", results[1].URL)
291	assert.Equal(t, "https://example.com/landing", results[2].URL)
292}
293
294func TestManageFavoritesUseCase_FilterForOmnibox_EmptyQueryKeepsRepositoryOrder(t *testing.T) {
295	ctx := testContext()
296
297	favoriteRepo := repomocks.NewMockFavoriteRepository(t)
298	tagRepo := repomocks.NewMockTagRepository(t)
299
300	favorites := []*entity.Favorite{
301		{ID: 1, URL: "https://first.example"},
302		{ID: 2, URL: "https://second.example"},
303	}
304	favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil)
305
306	uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
307
308	results, err := uc.FilterForOmnibox(ctx, "")
309	require.NoError(t, err)
310	require.Len(t, results, 2)
311	assert.Equal(t, "https://first.example", results[0].URL)
312	assert.Equal(t, "https://second.example", results[1].URL)
313}
314
315func TestManageFavoritesUseCase_GetAllURLs_UsesCache(t *testing.T) {
316	ctx := testContext()
317
318	favoriteRepo := repomocks.NewMockFavoriteRepository(t)
319	tagRepo := repomocks.NewMockTagRepository(t)
320
321	favorites := []*entity.Favorite{
322		{ID: 1, URL: "https://example.com"},
323	}
324	favoriteRepo.EXPECT().GetAll(mock.Anything).Return(favorites, nil).Once()
325
326	uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
327
328	urls, err := uc.GetAllURLs(ctx)
329	require.NoError(t, err)
330	require.Contains(t, urls, "https://example.com")
331
332	urls, err = uc.GetAllURLs(ctx)
333	require.NoError(t, err)
334	require.Contains(t, urls, "https://example.com")
335}
336
337func TestManageFavoritesUseCase_Toggle_InvalidatesCache(t *testing.T) {
338	ctx := testContext()
339
340	favoriteRepo := repomocks.NewMockFavoriteRepository(t)
341	tagRepo := repomocks.NewMockTagRepository(t)
342
343	initialFavorites := []*entity.Favorite{
344		{ID: 1, URL: "https://cached.example"},
345	}
346	favoriteRepo.EXPECT().GetAll(mock.Anything).Return(initialFavorites, nil).Once()
347	favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://new.example").Return(nil, nil)
348	favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(f *entity.Favorite) bool {
349		return f.URL == "https://new.example"
350	})).Return(nil)
351	updatedFavorites := []*entity.Favorite{
352		{ID: 1, URL: "https://cached.example"},
353		{ID: 2, URL: "https://new.example"},
354	}
355	favoriteRepo.EXPECT().GetAll(mock.Anything).Return(updatedFavorites, nil).Once()
356
357	uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
358
359	urls, err := uc.GetAllURLs(ctx)
360	require.NoError(t, err)
361	require.Contains(t, urls, "https://cached.example")
362
363	_, err = uc.Toggle(ctx, "https://new.example", "New")
364	require.NoError(t, err)
365
366	urls, err = uc.GetAllURLs(ctx)
367	require.NoError(t, err)
368	require.Contains(t, urls, "https://new.example")
369}
370
371func TestManageFavoritesUseCase_AddFavoriteNormalizesURL(t *testing.T) {
372	ctx := testContext()
373
374	favoriteRepo := repomocks.NewMockFavoriteRepository(t)
375	tagRepo := repomocks.NewMockTagRepository(t)
376
377	favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once()
378	favoriteRepo.EXPECT().FindByURL(mock.Anything, "example.com").Return(nil, nil).Once()
379	favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool {
380		return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example"
381	})).Return(nil).Once()
382
383	uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
384
385	fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "example.com", Title: " Example "})
386	require.NoError(t, err)
387	require.NotNil(t, fav)
388	assert.Equal(t, "https://example.com", fav.URL)
389	assert.Equal(t, "Example", fav.Title)
390}
391
392func TestManageFavoritesUseCase_AddFavoriteRejectsInvalidTagIDs(t *testing.T) {
393	ctx := testContext()
394
395	favoriteRepo := repomocks.NewMockFavoriteRepository(t)
396	tagRepo := repomocks.NewMockTagRepository(t)
397	uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
398
399	_, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "https://example.com", Tags: []entity.TagID{1, 0}})
400	require.Error(t, err)
401	assert.Contains(t, err.Error(), "favorite tag id must be positive")
402}
403
404func TestManageFavoritesUseCase_AddFavoriteAssignsTagsWithoutFolder(t *testing.T) {
405	ctx := testContext()
406
407	favoriteRepo := repomocks.NewMockFavoriteRepository(t)
408	tagRepo := repomocks.NewMockTagRepository(t)
409
410	favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once()
411	favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool {
412		return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example" && fav.FaviconURL == "https://example.com/favicon.ico"
413	})).Run(func(_ context.Context, fav *entity.Favorite) {
414		fav.ID = entity.FavoriteID(42)
415	}).Return(nil).Once()
416	tagRepo.EXPECT().AssignToFavorite(mock.Anything, entity.TagID(7), entity.FavoriteID(42)).Return(nil).Once()
417	tagRepo.EXPECT().AssignToFavorite(mock.Anything, entity.TagID(8), entity.FavoriteID(42)).Return(nil).Once()
418
419	uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
420
421	fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{
422		URL:        "https://example.com",
423		Title:      "Example",
424		FaviconURL: " https://example.com/favicon.ico ",
425		Tags:       []entity.TagID{7, 8},
426	})
427	require.NoError(t, err)
428	require.NotNil(t, fav)
429	assert.Equal(t, entity.FavoriteID(42), fav.ID)
430}
431
432func TestManageFavoritesUseCase_AddFavoriteReturnsAssignTagError(t *testing.T) {
433	ctx := testContext()
434
435	favoriteRepo := repomocks.NewMockFavoriteRepository(t)
436	tagRepo := repomocks.NewMockTagRepository(t)
437
438	favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once()
439	favoriteRepo.EXPECT().Save(mock.Anything, mock.Anything).Run(func(_ context.Context, fav *entity.Favorite) {
440		fav.ID = entity.FavoriteID(42)
441	}).Return(nil).Once()
442	tagRepo.EXPECT().AssignToFavorite(mock.Anything, entity.TagID(7), entity.FavoriteID(42)).Return(errors.New("tag missing")).Once()
443
444	uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
445
446	fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{
447		URL:   "https://example.com",
448		Title: "Example",
449		Tags:  []entity.TagID{7},
450	})
451	require.Error(t, err)
452	assert.Nil(t, fav)
453	assert.Contains(t, err.Error(), "failed to assign tag 7 to favorite 42")
454}
455
456func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testing.T) {
457	ctx := testContext()
458
459	tests := []string{
460		"",
461		"not a url",
462		"javascript:alert(1)",
463		"data:text/html,<script>alert(1)</script>",
464		"https:///missing-host",
465		"dumb:///missing-host",
466	}
467	for _, raw := range tests {
468		t.Run(raw, func(t *testing.T) {
469			favoriteRepo := repomocks.NewMockFavoriteRepository(t)
470			tagRepo := repomocks.NewMockTagRepository(t)
471			uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
472
473			_, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: raw})
474			require.Error(t, err)
475		})
476	}
477}
478
479func TestManageFavoritesUseCase_AddFavoriteNormalizesOpaqueDumbRoute(t *testing.T) {
480	ctx := testContext()
481
482	favoriteRepo := repomocks.NewMockFavoriteRepository(t)
483	tagRepo := repomocks.NewMockTagRepository(t)
484
485	favoriteRepo.EXPECT().FindByURL(mock.Anything, "dumb://history").Return(nil, nil).Once()
486	favoriteRepo.EXPECT().FindByURL(mock.Anything, "dumb:history").Return(nil, nil).Once()
487	favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool {
488		return fav != nil && fav.URL == "dumb://history" && fav.Title == "History"
489	})).Return(nil).Once()
490
491	uc := usecase.NewManageFavoritesUseCase(favoriteRepo, tagRepo)
492
493	fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "dumb:history", Title: "History"})
494	require.NoError(t, err)
495	require.NotNil(t, fav)
496	assert.Equal(t, "dumb://history", fav.URL)
497}

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