1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "strings"
10 "testing"
11
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"
15
16 "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"
18 "bnema.dev/ermite/internal/ports/mocks"
19 "bnema.dev/ermite/internal/usecase"
20)
21
22func ptr[T any](v T) *T { return &v }
23
24func TestUpdate(t *testing.T) {
25 t.Parallel()
26 ctx := context.Background()
27 n := name(t, "tools")
28 priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Description: "old"}
29 admin := tokenPrincipal(domain.ScopeRepoAdmin, "tools")
30
31 t.Run("auto-merge setting is stored and kicks evaluator", func(t *testing.T) {
32 f := newFixture(t)
33 trigger := mocks.NewAutoMergeTrigger(t)
34 f.repos.WithAutoMerge(trigger)
35 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
36 want := priv
37 want.Settings = domain.DefaultRepoSettings()
38 want.Settings.AllowAutoMerge = true
39 expectRepoMutation(t, f.meta, priv, want)
40 trigger.EXPECT().OnRepoChanged(ctx, n).Return().Once()
41 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
42 got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{AllowAutoMerge: ptr(true)})
43 require.NoError(t, err)
44 assert.True(t, got.EffectiveSettings().AllowAutoMerge)
45 })
46
47 t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) {
48 t.Parallel()
49 f := newFixture(t)
50 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
51 want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new", Settings: domain.DefaultRepoSettings()}
52 expectRepoMutation(t, f.meta, priv, want)
53 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
54 Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate,
55 }).Return().Once()
56
57 got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{
58 Description: ptr("new"), Visibility: ptr(domain.VisibilityPublic),
59 })
60 require.NoError(t, err)
61 assert.Equal(t, want, got)
62 })
63
64 t.Run("topics are normalized and stored, and RepoUpdated is published", func(t *testing.T) {
65 t.Parallel()
66 f := newFixture(t)
67 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
68 want := priv
69 want.Topics = []string{"cli", "go"}
70 want.Settings = domain.DefaultRepoSettings()
71 expectRepoMutation(t, f.meta, priv, want)
72 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
73 Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPrivate,
74 }).Return().Once()
75
76 got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Topics: &[]string{"Go", "cli", "go"}})
77 require.NoError(t, err)
78 assert.Equal(t, want, got)
79 })
80
81 t.Run("an empty list clears the topics", func(t *testing.T) {
82 t.Parallel()
83 f := newFixture(t)
84 withTopics := priv
85 withTopics.Topics = []string{"go"}
86 f.meta.EXPECT().Get(ctx, n).Return(withTopics, nil).Once()
87 want := priv
88 want.Settings = domain.DefaultRepoSettings()
89 expectRepoMutation(t, f.meta, withTopics, want)
90 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
91
92 got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Topics: &[]string{}})
93 require.NoError(t, err)
94 assert.Nil(t, got.Topics)
95 })
96
97 t.Run("exhausted mutation conflicts are returned", func(t *testing.T) {
98 f := newFixture(t)
99 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
100 f.meta.EXPECT().Mutate(ctx, n, mock.Anything).Return(domain.Repo{}, domain.ErrConflict).Once()
101 _, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Description: ptr("x")})
102 require.ErrorIs(t, err, domain.ErrConflict)
103 })
104
105 t.Run("the default branch is checked, then HEAD moves", func(t *testing.T) {
106 t.Parallel()
107 f := newFixture(t)
108 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
109 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
110 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(nil)
111 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
112 got, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{DefaultBranch: ptr("dev")})
113 require.NoError(t, err)
114 assert.Equal(t, priv, got)
115 })
116
117 t.Run("metadata is written before HEAD moves, and the event follows both", func(t *testing.T) {
118 t.Parallel()
119 f := newFixture(t)
120 var order []string
121 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
122 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
123 f.meta.EXPECT().Mutate(ctx, n, mock.Anything).RunAndReturn(func(_ context.Context, _ domain.RepoName, fn func(*domain.Repo) (bool, error)) (domain.Repo, error) {
124 order = append(order, "meta")
125 repo := priv
126 _, err := fn(&repo)
127 return repo, err
128 })
129 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").RunAndReturn(func(context.Context, domain.RepoName, string) error {
130 order = append(order, "head")
131 return nil
132 })
133 f.bus.EXPECT().Publish(ctx, mock.Anything).Run(func(context.Context, domain.Event) { order = append(order, "event") }).Return().Once()
134 _, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Description: ptr("x"), DefaultBranch: ptr("dev")})
135 require.NoError(t, err)
136 assert.Equal(t, []string{"meta", "head", "event"}, order)
137 })
138
139 t.Run("a HEAD that cannot move after the metadata was stored still publishes, then fails", func(t *testing.T) {
140 t.Parallel()
141 f := newFixture(t)
142 boom := errors.New("boom")
143 pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "old"}
144 f.meta.EXPECT().Get(ctx, n).Return(pub, nil)
145 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
146 expectRepoMutation(t, f.meta, pub, mock.Anything)
147 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
148 f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
149 Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic,
150 }).Return().Once()
151 _, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Visibility: ptr(domain.VisibilityPrivate), DefaultBranch: ptr("dev")})
152 assert.ErrorIs(t, err, boom)
153 })
154
155 t.Run("a HEAD that cannot move with no metadata change publishes nothing", func(t *testing.T) {
156 t.Parallel()
157 f := newFixture(t)
158 boom := errors.New("boom")
159 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
160 f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
161 f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
162 _, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{DefaultBranch: ptr("dev")})
163 assert.ErrorIs(t, err, boom)
164 })
165
166 t.Run("a missing branch is a failed precondition and HEAD stays", func(t *testing.T) {
167 t.Parallel()
168 f := newFixture(t)
169 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
170 f.git.EXPECT().HasBranch(ctx, n, "nope").Return(false, nil)
171 _, err := f.repos.Update(ctx, admin, n, ports.UpdateRepoParams{Description: ptr("x"), DefaultBranch: ptr("nope")})
172 assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
173 })
174
175 t.Run("invalid input changes nothing", func(t *testing.T) {
176 tooMany := make([]string, 0, domain.MaxTopics+1)
177 for i := range domain.MaxTopics + 1 {
178 tooMany = append(tooMany, fmt.Sprintf("t%d", i))
179 }
180 for name, params := range map[string]ports.UpdateRepoParams{
181 "empty": {},
182 "empty branch": {DefaultBranch: ptr("")},
183 "multi-line": {Description: ptr("a\nb")},
184 "too long": {Description: ptr(strings.Repeat("x", usecase.MaxDescriptionLen+1))},
185 "bad utf-8": {Description: ptr("\xff")},
186 "bad topic": {Topics: &[]string{"Bad_Topic"}},
187 "empty topic": {Topics: &[]string{""}},
188 "too many topics": {Topics: &tooMany},
189 "unknown visiblity": {Visibility: ptr(domain.Visibility("secret"))},
190 } {
191 f := newFixture(t)
192 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
193 _, err := f.repos.Update(ctx, admin, n, params)
194 require.Error(t, err, name)
195 assert.True(t, errors.Is(err, domain.ErrInvalid) || errors.Is(err, domain.ErrInvalidVisibility), "%s: %v", name, err)
196 }
197 })
198
199 access := []struct {
200 name string
201 p domain.Principal
202 repo domain.Repo
203 want error
204 }{
205 {"anonymous on a private repository sees nothing", domain.Anonymous(), priv, domain.ErrNotFound},
206 {"anonymous on a public repository must log in", domain.Anonymous(), domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, domain.ErrUnauthenticated},
207 {"reader of a private repository is forbidden", tokenPrincipal(domain.ScopeRepoRead), priv, domain.ErrForbidden},
208 {"writer of a private repository is hidden", tokenPrincipal(domain.ScopeRepoWrite), priv, domain.ErrNotFound},
209 {"admin of another repository is hidden", tokenPrincipal(domain.ScopeRepoAdmin, "other"), priv, domain.ErrNotFound},
210 {"writer of a public repository is forbidden", tokenPrincipal(domain.ScopeRepoWrite), domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, domain.ErrForbidden},
211 }
212 for _, tt := range access {
213 t.Run(tt.name, func(t *testing.T) {
214 t.Parallel()
215 f := newFixture(t)
216 f.meta.EXPECT().Get(ctx, n).Return(tt.repo, nil)
217 _, err := f.repos.Update(ctx, tt.p, n, ports.UpdateRepoParams{Description: ptr("x")})
218 assert.ErrorIs(t, err, tt.want)
219 err = f.repos.Delete(ctx, tt.p, n)
220 assert.ErrorIs(t, err, tt.want)
221 })
222 }
223
224 t.Run("a token limited to branches may not change any setting", func(t *testing.T) {
225 t.Parallel()
226 modes := []domain.MergeMode{domain.MergeModeSquash}
227 for name, params := range map[string]ports.UpdateRepoParams{
228 "default branch": {DefaultBranch: ptr("dev")},
229 "visibility": {Visibility: ptr(domain.VisibilityPublic)},
230 "description": {Description: ptr("x")},
231 "merge modes": {AllowedMergeModes: &modes},
232 "issues": {Issues: ptr(domain.IssuesOwner)},
233 } {
234 f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish
235 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
236 _, err := f.repos.Update(ctx, branchLimited(domain.ScopeRepoAdmin), n, params)
237 assert.ErrorIs(t, err, domain.ErrForbidden, name)
238 }
239 })
240
241 t.Run("a token limited to branches may not delete a repository", func(t *testing.T) {
242 t.Parallel()
243 f := newFixture(t) // no Trash, no Delete
244 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
245 err := f.repos.Delete(ctx, branchLimited(domain.ScopeRepoAdmin), n)
246 assert.ErrorIs(t, err, domain.ErrForbidden)
247 })
248
249 t.Run("a missing repository is not found", func(t *testing.T) {
250 t.Parallel()
251 f := newFixture(t)
252 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
253 _, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Description: ptr("x")})
254 assert.ErrorIs(t, err, domain.ErrNotFound)
255 })
256}
257
258func TestUpdateIssuesMode(t *testing.T) {
259 t.Parallel()
260 ctx := context.Background()
261 n := name(t, "tools")
262 priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Settings: domain.DefaultRepoSettings()}
263
264 t.Run("public is stored and published", func(t *testing.T) {
265 t.Parallel()
266 f := newFixture(t)
267 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
268 expectRepoMutation(t, f.meta, priv, mock.MatchedBy(func(r domain.Repo) bool {
269 return r.Settings.Issues == domain.IssuesPublic
270 }))
271 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
272 repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)})
273 require.NoError(t, err)
274 assert.Equal(t, domain.IssuesPublic, repo.Settings.Issues)
275 })
276
277 t.Run("an unknown mode is invalid", func(t *testing.T) {
278 t.Parallel()
279 f := newFixture(t)
280 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
281 _, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesMode("all"))})
282 assert.ErrorIs(t, err, domain.ErrInvalid)
283 })
284
285 t.Run("owner is stored and published", func(t *testing.T) {
286 t.Parallel()
287 f := newFixture(t)
288 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
289 expectRepoMutation(t, f.meta, priv, mock.MatchedBy(func(r domain.Repo) bool {
290 return r.Settings.Issues == domain.IssuesOwner
291 }))
292 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
293 repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesOwner)})
294 require.NoError(t, err)
295 assert.Equal(t, domain.IssuesOwner, repo.Settings.Issues)
296 })
297}
298
299func TestDelete(t *testing.T) {
300 t.Parallel()
301 ctx := context.Background()
302 n := name(t, "tools")
303 priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}
304
305 t.Run("trash, forget the metadata, publish RepoDeleted with the visibility", func(t *testing.T) {
306 t.Parallel()
307 f := newFixture(t)
308 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
309 f.git.EXPECT().Trash(ctx, n).Return(nil)
310 f.meta.EXPECT().Delete(ctx, n).Return(nil)
311 f.bus.EXPECT().Publish(ctx, domain.RepoDeleted{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return().Once()
312 require.NoError(t, f.repos.Delete(ctx, tokenPrincipal(domain.ScopeRepoAdmin, "tools"), n))
313 })
314
315 t.Run("a directory already gone still loses its metadata", func(t *testing.T) {
316 t.Parallel()
317 f := newFixture(t)
318 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
319 f.git.EXPECT().Trash(ctx, n).Return(domain.ErrNotFound)
320 f.meta.EXPECT().Delete(ctx, n).Return(nil)
321 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
322 require.NoError(t, f.repos.Delete(ctx, domain.Root(), n))
323 })
324
325 t.Run("a failed move keeps the metadata", func(t *testing.T) {
326 t.Parallel()
327 f := newFixture(t)
328 boom := errors.New("boom")
329 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
330 f.git.EXPECT().Trash(ctx, n).Return(boom)
331 assert.ErrorIs(t, f.repos.Delete(ctx, domain.Root(), n), boom)
332 })
333}
334
335func TestView(t *testing.T) {
336 t.Parallel()
337 ctx := context.Background()
338 n := name(t, "tools")
339 repo := domain.Repo{Name: n}
340
341 f := newFixture(t)
342 f.git.EXPECT().HeadBranch(ctx, n).Return("main", nil).Once()
343 v, err := f.repos.View(ctx, repo)
344 require.NoError(t, err)
345 assert.Equal(t, "main", v.DefaultBranch)
346
347 f.git.EXPECT().HeadBranch(ctx, n).Return("", domain.ErrNotFound).Once()
348 v, err = f.repos.View(ctx, repo)
349 require.NoError(t, err, "a missing directory has no default branch")
350 assert.Empty(t, v.DefaultBranch)
351}
352
353func TestListPageSizes(t *testing.T) {
354 t.Parallel()
355 ctx := context.Background()
356 var all []domain.Repo
357 for i := range usecase.MaxRepoPageSize + 10 {
358 all = append(all, domain.Repo{Name: name(t, fmt.Sprintf("r%04d", i)), Visibility: domain.VisibilityPublic})
359 }
360 for _, tt := range []struct {
361 name string
362 size int
363 want int
364 more bool
365 }{
366 {"default", 0, usecase.DefaultPageSize, true},
367 {"within the maximum", 500, 500, true},
368 {"the maximum", usecase.MaxRepoPageSize, usecase.MaxRepoPageSize, true},
369 {"lowered to the maximum", usecase.MaxRepoPageSize * 10, usecase.MaxRepoPageSize, true},
370 } {
371 t.Run(tt.name, func(t *testing.T) {
372 t.Parallel()
373 f := newFixture(t)
374 f.meta.EXPECT().List(ctx).Return(all, nil)
375 page, err := f.repos.ListPage(ctx, domain.Anonymous(), tt.size, "")
376 require.NoError(t, err)
377 assert.Len(t, page.Repos, tt.want)
378 assert.Equal(t, tt.more, page.NextPageToken != "")
379 })
380 }
381}
382
383func TestListPage(t *testing.T) {
384 t.Parallel()
385 ctx := context.Background()
386 var all []domain.Repo
387 for _, s := range []string{"a", "b", "c", "d", "e"} {
388 all = append(all, domain.Repo{Name: name(t, s), Visibility: domain.VisibilityPublic})
389 }
390 priv := domain.Repo{Name: name(t, "bb"), Visibility: domain.VisibilityPrivate}
391 all = append(all[:2], append([]domain.Repo{priv}, all[2:]...)...)
392
393 f := newFixture(t)
394 f.meta.EXPECT().List(ctx).Return(all, nil)
395 var got []string
396 token := ""
397 for pages := 0; ; pages++ {
398 require.Less(t, pages, 10)
399 page, err := f.repos.ListPage(ctx, domain.Anonymous(), 2, token)
400 require.NoError(t, err)
401 for _, r := range page.Repos {
402 got = append(got, r.Name.String())
403 }
404 if token = page.NextPageToken; token == "" {
405 break
406 }
407 }
408 assert.Equal(t, []string{"a", "b", "c", "d", "e"}, got, "private repositories never show, pages do not overlap")
409
410 _, err := f.repos.ListPage(ctx, domain.Root(), 0, "garbage")
411 assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
412 _, err = f.repos.ListPage(ctx, domain.Root(), -1, "")
413 assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
414}