v0.28.0 fca9155

repos_write_test.go RawHistoryFolder

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}

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 2

Tags 45

↑↓ moveenter openesc close