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 block := true
228 for name, params := range map[string]ports.UpdateRepoParams{
229 "default branch": {DefaultBranch: ptr("dev")},
230 "visibility": {Visibility: ptr(domain.VisibilityPublic)},
231 "description": {Description: ptr("x")},
232 "merge modes": {AllowedMergeModes: &modes},
233 "checks rule": {FailingChecksBlock: &block},
234 "issues": {Issues: ptr(domain.IssuesOwner)},
235 } {
236 f := newFixture(t) // no HasBranch, no Update, no SetDefaultBranch, no Publish
237 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
238 _, err := f.repos.Update(ctx, branchLimited(domain.ScopeRepoAdmin), n, params)
239 assert.ErrorIs(t, err, domain.ErrForbidden, name)
240 }
241 })
242
243 t.Run("a token limited to branches may not delete a repository", func(t *testing.T) {
244 t.Parallel()
245 f := newFixture(t) // no Trash, no Delete
246 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
247 err := f.repos.Delete(ctx, branchLimited(domain.ScopeRepoAdmin), n)
248 assert.ErrorIs(t, err, domain.ErrForbidden)
249 })
250
251 t.Run("a missing repository is not found", func(t *testing.T) {
252 t.Parallel()
253 f := newFixture(t)
254 f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
255 _, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Description: ptr("x")})
256 assert.ErrorIs(t, err, domain.ErrNotFound)
257 })
258}
259
260func TestUpdateIssuesMode(t *testing.T) {
261 t.Parallel()
262 ctx := context.Background()
263 n := name(t, "tools")
264 priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Settings: domain.DefaultRepoSettings()}
265
266 t.Run("public is stored and published", func(t *testing.T) {
267 t.Parallel()
268 f := newFixture(t)
269 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
270 expectRepoMutation(t, f.meta, priv, mock.MatchedBy(func(r domain.Repo) bool {
271 return r.Settings.Issues == domain.IssuesPublic
272 }))
273 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
274 repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesPublic)})
275 require.NoError(t, err)
276 assert.Equal(t, domain.IssuesPublic, repo.Settings.Issues)
277 })
278
279 t.Run("an unknown mode is invalid", func(t *testing.T) {
280 t.Parallel()
281 f := newFixture(t)
282 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
283 _, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesMode("all"))})
284 assert.ErrorIs(t, err, domain.ErrInvalid)
285 })
286
287 t.Run("owner is stored and published", func(t *testing.T) {
288 t.Parallel()
289 f := newFixture(t)
290 f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Once()
291 expectRepoMutation(t, f.meta, priv, mock.MatchedBy(func(r domain.Repo) bool {
292 return r.Settings.Issues == domain.IssuesOwner && r.Settings.FailingChecksBlock
293 }))
294 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
295 repo, err := f.repos.Update(ctx, domain.Root(), n, ports.UpdateRepoParams{Issues: ptr(domain.IssuesOwner)})
296 require.NoError(t, err)
297 assert.Equal(t, domain.IssuesOwner, repo.Settings.Issues)
298 })
299}
300
301func TestDelete(t *testing.T) {
302 t.Parallel()
303 ctx := context.Background()
304 n := name(t, "tools")
305 priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}
306
307 t.Run("trash, forget the metadata, publish RepoDeleted with the visibility", func(t *testing.T) {
308 t.Parallel()
309 f := newFixture(t)
310 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
311 f.git.EXPECT().Trash(ctx, n).Return(nil)
312 f.meta.EXPECT().Delete(ctx, n).Return(nil)
313 f.bus.EXPECT().Publish(ctx, domain.RepoDeleted{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return().Once()
314 require.NoError(t, f.repos.Delete(ctx, tokenPrincipal(domain.ScopeRepoAdmin, "tools"), n))
315 })
316
317 t.Run("a directory already gone still loses its metadata", func(t *testing.T) {
318 t.Parallel()
319 f := newFixture(t)
320 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
321 f.git.EXPECT().Trash(ctx, n).Return(domain.ErrNotFound)
322 f.meta.EXPECT().Delete(ctx, n).Return(nil)
323 f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
324 require.NoError(t, f.repos.Delete(ctx, domain.Root(), n))
325 })
326
327 t.Run("a failed move keeps the metadata", func(t *testing.T) {
328 t.Parallel()
329 f := newFixture(t)
330 boom := errors.New("boom")
331 f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
332 f.git.EXPECT().Trash(ctx, n).Return(boom)
333 assert.ErrorIs(t, f.repos.Delete(ctx, domain.Root(), n), boom)
334 })
335}
336
337func TestView(t *testing.T) {
338 t.Parallel()
339 ctx := context.Background()
340 n := name(t, "tools")
341 repo := domain.Repo{Name: n}
342
343 f := newFixture(t)
344 f.git.EXPECT().HeadBranch(ctx, n).Return("main", nil).Once()
345 v, err := f.repos.View(ctx, repo)
346 require.NoError(t, err)
347 assert.Equal(t, "main", v.DefaultBranch)
348
349 f.git.EXPECT().HeadBranch(ctx, n).Return("", domain.ErrNotFound).Once()
350 v, err = f.repos.View(ctx, repo)
351 require.NoError(t, err, "a missing directory has no default branch")
352 assert.Empty(t, v.DefaultBranch)
353}
354
355func TestListPageSizes(t *testing.T) {
356 t.Parallel()
357 ctx := context.Background()
358 var all []domain.Repo
359 for i := range usecase.MaxRepoPageSize + 10 {
360 all = append(all, domain.Repo{Name: name(t, fmt.Sprintf("r%04d", i)), Visibility: domain.VisibilityPublic})
361 }
362 for _, tt := range []struct {
363 name string
364 size int
365 want int
366 more bool
367 }{
368 {"default", 0, usecase.DefaultPageSize, true},
369 {"within the maximum", 500, 500, true},
370 {"the maximum", usecase.MaxRepoPageSize, usecase.MaxRepoPageSize, true},
371 {"lowered to the maximum", usecase.MaxRepoPageSize * 10, usecase.MaxRepoPageSize, true},
372 } {
373 t.Run(tt.name, func(t *testing.T) {
374 t.Parallel()
375 f := newFixture(t)
376 f.meta.EXPECT().List(ctx).Return(all, nil)
377 page, err := f.repos.ListPage(ctx, domain.Anonymous(), tt.size, "")
378 require.NoError(t, err)
379 assert.Len(t, page.Repos, tt.want)
380 assert.Equal(t, tt.more, page.NextPageToken != "")
381 })
382 }
383}
384
385func TestListPage(t *testing.T) {
386 t.Parallel()
387 ctx := context.Background()
388 var all []domain.Repo
389 for _, s := range []string{"a", "b", "c", "d", "e"} {
390 all = append(all, domain.Repo{Name: name(t, s), Visibility: domain.VisibilityPublic})
391 }
392 priv := domain.Repo{Name: name(t, "bb"), Visibility: domain.VisibilityPrivate}
393 all = append(all[:2], append([]domain.Repo{priv}, all[2:]...)...)
394
395 f := newFixture(t)
396 f.meta.EXPECT().List(ctx).Return(all, nil)
397 var got []string
398 token := ""
399 for pages := 0; ; pages++ {
400 require.Less(t, pages, 10)
401 page, err := f.repos.ListPage(ctx, domain.Anonymous(), 2, token)
402 require.NoError(t, err)
403 for _, r := range page.Repos {
404 got = append(got, r.Name.String())
405 }
406 if token = page.NextPageToken; token == "" {
407 break
408 }
409 }
410 assert.Equal(t, []string{"a", "b", "c", "d", "e"}, got, "private repositories never show, pages do not overlap")
411
412 _, err := f.repos.ListPage(ctx, domain.Root(), 0, "garbage")
413 assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
414 _, err = f.repos.ListPage(ctx, domain.Root(), -1, "")
415 assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
416}