v0.3.0 9fdf386

repos_write_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app_test
4
5import (
6	"context"
7	"errors"
8	"strings"
9	"testing"
10
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/mock"
13	"github.com/stretchr/testify/require"
14
15	"bnema.dev/ermite/internal/app"
16	"bnema.dev/ermite/internal/domain"
17)
18
19func ptr[T any](v T) *T { return &v }
20
21func TestUpdate(t *testing.T) {
22	ctx := context.Background()
23	n := name(t, "tools")
24	priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, Description: "old"}
25	admin := tokenPrincipal(domain.ScopeRepoAdmin, "tools")
26
27	t.Run("description and visibility are stored and RepoUpdated is published", func(t *testing.T) {
28		f := newFixture(t)
29		f.meta.EXPECT().Get(ctx, n).Return(priv, nil).Twice()
30		want := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "new", Settings: domain.DefaultRepoSettings()}
31		f.meta.EXPECT().Update(ctx, want).Return(nil)
32		f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
33			Repo: n, At: now, Visibility: domain.VisibilityPublic, Previous: domain.VisibilityPrivate,
34		}).Return().Once()
35
36		got, err := f.repos.Update(ctx, admin, n, app.UpdateParams{
37			Description: ptr("new"), Visibility: ptr(domain.VisibilityPublic),
38		})
39		require.NoError(t, err)
40		assert.Equal(t, want, got)
41	})
42
43	t.Run("a lost race on the metadata is retried", func(t *testing.T) {
44		f := newFixture(t)
45		f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
46		f.meta.EXPECT().Update(ctx, mock.Anything).Return(domain.ErrConflict).Once()
47		f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil).Once()
48		f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
49		_, err := f.repos.Update(ctx, admin, n, app.UpdateParams{Description: ptr("x")})
50		require.NoError(t, err)
51	})
52
53	t.Run("the default branch is checked, then HEAD moves", func(t *testing.T) {
54		f := newFixture(t)
55		f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
56		f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
57		f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(nil)
58		f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
59		got, err := f.repos.Update(ctx, admin, n, app.UpdateParams{DefaultBranch: ptr("dev")})
60		require.NoError(t, err)
61		assert.Equal(t, priv, got)
62	})
63
64	t.Run("metadata is written before HEAD moves, and the event follows both", func(t *testing.T) {
65		f := newFixture(t)
66		var order []string
67		f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
68		f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
69		f.meta.EXPECT().Update(ctx, mock.Anything).RunAndReturn(func(context.Context, domain.Repo) error {
70			order = append(order, "meta")
71			return nil
72		})
73		f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").RunAndReturn(func(context.Context, domain.RepoName, string) error {
74			order = append(order, "head")
75			return nil
76		})
77		f.bus.EXPECT().Publish(ctx, mock.Anything).Run(func(context.Context, domain.Event) { order = append(order, "event") }).Return().Once()
78		_, err := f.repos.Update(ctx, admin, n, app.UpdateParams{Description: ptr("x"), DefaultBranch: ptr("dev")})
79		require.NoError(t, err)
80		assert.Equal(t, []string{"meta", "head", "event"}, order)
81	})
82
83	t.Run("a HEAD that cannot move after the metadata was stored still publishes, then fails", func(t *testing.T) {
84		f := newFixture(t)
85		boom := errors.New("boom")
86		pub := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, Description: "old"}
87		f.meta.EXPECT().Get(ctx, n).Return(pub, nil)
88		f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
89		f.meta.EXPECT().Update(ctx, mock.Anything).Return(nil)
90		f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
91		f.bus.EXPECT().Publish(ctx, domain.RepoUpdated{
92			Repo: n, At: now, Visibility: domain.VisibilityPrivate, Previous: domain.VisibilityPublic,
93		}).Return().Once()
94		_, err := f.repos.Update(ctx, admin, n, app.UpdateParams{Visibility: ptr(domain.VisibilityPrivate), DefaultBranch: ptr("dev")})
95		assert.ErrorIs(t, err, boom)
96	})
97
98	t.Run("a HEAD that cannot move with no metadata change publishes nothing", func(t *testing.T) {
99		f := newFixture(t)
100		boom := errors.New("boom")
101		f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
102		f.git.EXPECT().HasBranch(ctx, n, "dev").Return(true, nil)
103		f.git.EXPECT().SetDefaultBranch(ctx, n, "dev").Return(boom)
104		_, err := f.repos.Update(ctx, admin, n, app.UpdateParams{DefaultBranch: ptr("dev")})
105		assert.ErrorIs(t, err, boom)
106	})
107
108	t.Run("a missing branch is a failed precondition and HEAD stays", func(t *testing.T) {
109		f := newFixture(t)
110		f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
111		f.git.EXPECT().HasBranch(ctx, n, "nope").Return(false, nil)
112		_, err := f.repos.Update(ctx, admin, n, app.UpdateParams{Description: ptr("x"), DefaultBranch: ptr("nope")})
113		assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
114	})
115
116	t.Run("invalid input changes nothing", func(t *testing.T) {
117		for name, params := range map[string]app.UpdateParams{
118			"empty":             {},
119			"empty branch":      {DefaultBranch: ptr("")},
120			"multi-line":        {Description: ptr("a\nb")},
121			"too long":          {Description: ptr(strings.Repeat("x", app.MaxDescriptionLen+1))},
122			"bad utf-8":         {Description: ptr("\xff")},
123			"unknown visiblity": {Visibility: ptr(domain.Visibility("secret"))},
124		} {
125			f := newFixture(t)
126			f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
127			_, err := f.repos.Update(ctx, admin, n, params)
128			require.Error(t, err, name)
129			assert.True(t, errors.Is(err, domain.ErrInvalid) || errors.Is(err, domain.ErrInvalidVisibility), "%s: %v", name, err)
130		}
131	})
132
133	access := []struct {
134		name string
135		p    domain.Principal
136		repo domain.Repo
137		want error
138	}{
139		{"anonymous on a private repository sees nothing", domain.Anonymous(), priv, domain.ErrNotFound},
140		{"anonymous on a public repository must log in", domain.Anonymous(), domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, domain.ErrUnauthenticated},
141		{"reader of a private repository is forbidden", tokenPrincipal(domain.ScopeRepoRead), priv, domain.ErrForbidden},
142		{"writer of a private repository is hidden", tokenPrincipal(domain.ScopeRepoWrite), priv, domain.ErrNotFound},
143		{"admin of another repository is hidden", tokenPrincipal(domain.ScopeRepoAdmin, "other"), priv, domain.ErrNotFound},
144		{"writer of a public repository is forbidden", tokenPrincipal(domain.ScopeRepoWrite), domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, domain.ErrForbidden},
145	}
146	for _, tt := range access {
147		t.Run(tt.name, func(t *testing.T) {
148			f := newFixture(t)
149			f.meta.EXPECT().Get(ctx, n).Return(tt.repo, nil)
150			_, err := f.repos.Update(ctx, tt.p, n, app.UpdateParams{Description: ptr("x")})
151			assert.ErrorIs(t, err, tt.want)
152			err = f.repos.Delete(ctx, tt.p, n)
153			assert.ErrorIs(t, err, tt.want)
154		})
155	}
156
157	t.Run("a missing repository is not found", func(t *testing.T) {
158		f := newFixture(t)
159		f.meta.EXPECT().Get(ctx, n).Return(domain.Repo{}, domain.ErrNotFound)
160		_, err := f.repos.Update(ctx, domain.Root(), n, app.UpdateParams{Description: ptr("x")})
161		assert.ErrorIs(t, err, domain.ErrNotFound)
162	})
163}
164
165func TestDelete(t *testing.T) {
166	ctx := context.Background()
167	n := name(t, "tools")
168	priv := domain.Repo{Name: n, Visibility: domain.VisibilityPrivate}
169
170	t.Run("trash, forget the metadata, publish RepoDeleted with the visibility", func(t *testing.T) {
171		f := newFixture(t)
172		f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
173		f.git.EXPECT().Trash(ctx, n).Return(nil)
174		f.meta.EXPECT().Delete(ctx, n).Return(nil)
175		f.bus.EXPECT().Publish(ctx, domain.RepoDeleted{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return().Once()
176		require.NoError(t, f.repos.Delete(ctx, tokenPrincipal(domain.ScopeRepoAdmin, "tools"), n))
177	})
178
179	t.Run("a directory already gone still loses its metadata", func(t *testing.T) {
180		f := newFixture(t)
181		f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
182		f.git.EXPECT().Trash(ctx, n).Return(domain.ErrNotFound)
183		f.meta.EXPECT().Delete(ctx, n).Return(nil)
184		f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
185		require.NoError(t, f.repos.Delete(ctx, domain.Root(), n))
186	})
187
188	t.Run("a failed move keeps the metadata", func(t *testing.T) {
189		f := newFixture(t)
190		boom := errors.New("boom")
191		f.meta.EXPECT().Get(ctx, n).Return(priv, nil)
192		f.git.EXPECT().Trash(ctx, n).Return(boom)
193		assert.ErrorIs(t, f.repos.Delete(ctx, domain.Root(), n), boom)
194	})
195}
196
197func TestView(t *testing.T) {
198	ctx := context.Background()
199	n := name(t, "tools")
200	repo := domain.Repo{Name: n}
201
202	f := newFixture(t)
203	f.git.EXPECT().HeadBranch(ctx, n).Return("main", nil).Once()
204	v, err := f.repos.View(ctx, repo)
205	require.NoError(t, err)
206	assert.Equal(t, "main", v.DefaultBranch)
207
208	f.git.EXPECT().HeadBranch(ctx, n).Return("", domain.ErrNotFound).Once()
209	v, err = f.repos.View(ctx, repo)
210	require.NoError(t, err, "a missing directory has no default branch")
211	assert.Empty(t, v.DefaultBranch)
212}
213
214func TestListPage(t *testing.T) {
215	ctx := context.Background()
216	var all []domain.Repo
217	for _, s := range []string{"a", "b", "c", "d", "e"} {
218		all = append(all, domain.Repo{Name: name(t, s), Visibility: domain.VisibilityPublic})
219	}
220	priv := domain.Repo{Name: name(t, "bb"), Visibility: domain.VisibilityPrivate}
221	all = append(all[:2], append([]domain.Repo{priv}, all[2:]...)...)
222
223	f := newFixture(t)
224	f.meta.EXPECT().List(ctx).Return(all, nil)
225	var got []string
226	token := ""
227	for pages := 0; ; pages++ {
228		require.Less(t, pages, 10)
229		page, err := f.repos.ListPage(ctx, domain.Anonymous(), 2, token)
230		require.NoError(t, err)
231		for _, r := range page.Repos {
232			got = append(got, r.Name.String())
233		}
234		if token = page.NextPageToken; token == "" {
235			break
236		}
237	}
238	assert.Equal(t, []string{"a", "b", "c", "d", "e"}, got, "private repositories never show, pages do not overlap")
239
240	_, err := f.repos.ListPage(ctx, domain.Root(), 0, "garbage")
241	assert.ErrorIs(t, err, domain.ErrInvalidPageToken)
242	_, err = f.repos.ListPage(ctx, domain.Root(), -1, "")
243	assert.ErrorIs(t, err, domain.ErrInvalidPageSize)
244}

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