v0.31.0 d5e0505

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/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}

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