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