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