v0.3.0 9fdf386

repos_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app_test
4
5import (
6	"context"
7	"errors"
8	"testing"
9	"time"
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	"bnema.dev/ermite/internal/ports"
18	"bnema.dev/ermite/internal/ports/mocks"
19)
20
21var now = time.Date(2026, 10, 10, 8, 0, 0, 0, time.UTC)
22
23type fixture struct {
24	meta  *mocks.RepoMetaStore
25	git   *mocks.GitRepos
26	clock *mocks.Clock
27	bus   *mocks.EventBus
28	repos *app.Repos
29}
30
31func newFixture(t *testing.T) *fixture {
32	t.Helper()
33	f := &fixture{
34		meta:  mocks.NewRepoMetaStore(t),
35		git:   mocks.NewGitRepos(t),
36		clock: mocks.NewClock(t),
37		bus:   mocks.NewEventBus(t),
38	}
39	f.clock.EXPECT().Now().Return(now).Maybe()
40	f.repos = app.NewRepos(f.meta, f.git, f.bus, f.clock)
41	return f
42}
43
44func name(t *testing.T, s string) domain.RepoName {
45	t.Helper()
46	n, err := domain.ParseRepoName(s)
47	require.NoError(t, err)
48	return n
49}
50
51func TestCreate(t *testing.T) {
52	ctx := context.Background()
53	errBoom := errors.New("boom")
54
55	t.Run("success is private by default", func(t *testing.T) {
56		f := newFixture(t)
57		n := name(t, "tools")
58		want := domain.Repo{Name: n, Description: "d", Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}
59		f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
60		f.meta.EXPECT().Create(ctx, want).Return(nil)
61		f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
62		f.bus.EXPECT().Publish(ctx, domain.RepoCreated{Repo: n, Visibility: domain.VisibilityPrivate, At: now}).Return().Once()
63
64		got, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: n, Description: "d"})
65		require.NoError(t, err)
66		assert.Equal(t, want, got)
67	})
68
69	t.Run("anonymous is refused before anything runs", func(t *testing.T) {
70		f := newFixture(t)
71		_, err := f.repos.Create(ctx, domain.Anonymous(), app.CreateParams{Name: name(t, "tools")})
72		assert.ErrorIs(t, err, domain.ErrUnauthenticated)
73	})
74
75	t.Run("token without repo:write or outside its list is forbidden", func(t *testing.T) {
76		for _, p := range []domain.Principal{
77			tokenPrincipal(domain.ScopeRepoRead),
78			tokenPrincipal(domain.ScopeRepoWrite, "other"),
79		} {
80			f := newFixture(t)
81			_, err := f.repos.Create(ctx, p, app.CreateParams{Name: name(t, "tools")})
82			assert.ErrorIs(t, err, domain.ErrForbidden)
83		}
84	})
85
86	t.Run("token with repo:write creates", func(t *testing.T) {
87		f := newFixture(t)
88		n := name(t, "tools")
89		f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
90		f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil)
91		f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(nil)
92		f.bus.EXPECT().Publish(ctx, mock.Anything).Return().Once()
93		_, err := f.repos.Create(ctx, tokenPrincipal(domain.ScopeRepoWrite, "tools"), app.CreateParams{Name: n})
94		require.NoError(t, err)
95	})
96
97	t.Run("zero name is invalid", func(t *testing.T) {
98		f := newFixture(t)
99		_, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{})
100		assert.ErrorIs(t, err, domain.ErrInvalidName)
101	})
102
103	t.Run("unknown visibility is invalid", func(t *testing.T) {
104		f := newFixture(t)
105		_, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: name(t, "tools"), Visibility: "secret"})
106		assert.ErrorIs(t, err, domain.ErrInvalidVisibility)
107	})
108
109	t.Run("stage failure stores nothing", func(t *testing.T) {
110		f := newFixture(t)
111		f.git.EXPECT().Stage(ctx).Return("", errBoom)
112		_, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: name(t, "tools")})
113		assert.ErrorIs(t, err, errBoom)
114	})
115
116	t.Run("existing name discards the staged repository", func(t *testing.T) {
117		f := newFixture(t)
118		f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
119		f.meta.EXPECT().Create(ctx, mock.Anything).Return(domain.ErrAlreadyExists)
120		f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil)
121		_, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: name(t, "tools")})
122		assert.ErrorIs(t, err, domain.ErrAlreadyExists)
123	})
124
125	t.Run("publish conflict removes metadata and staging", func(t *testing.T) {
126		f := newFixture(t)
127		n := name(t, "tools")
128		f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
129		f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil)
130		f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(domain.ErrAlreadyExists)
131		f.meta.EXPECT().Delete(mock.Anything, n).Return(nil)
132		f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil)
133		_, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: n})
134		assert.ErrorIs(t, err, domain.ErrAlreadyExists)
135	})
136
137	t.Run("failed publish removes metadata and staging", func(t *testing.T) {
138		f := newFixture(t)
139		n := name(t, "tools")
140		f.git.EXPECT().Stage(ctx).Return(ports.StagedID("s1"), nil)
141		f.meta.EXPECT().Create(ctx, mock.Anything).Return(nil)
142		f.git.EXPECT().Publish(ctx, ports.StagedID("s1"), n).Return(errBoom)
143		f.meta.EXPECT().Delete(mock.Anything, n).Return(nil)
144		f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).Return(nil)
145		_, err := f.repos.Create(ctx, domain.Root(), app.CreateParams{Name: n})
146		assert.ErrorIs(t, err, errBoom)
147	})
148
149	t.Run("cleanup survives a canceled context", func(t *testing.T) {
150		f := newFixture(t)
151		n := name(t, "tools")
152		cctx, cancel := context.WithCancel(ctx)
153		f.git.EXPECT().Stage(cctx).Return(ports.StagedID("s1"), nil)
154		f.meta.EXPECT().Create(cctx, mock.Anything).Return(nil)
155		f.git.EXPECT().Publish(cctx, ports.StagedID("s1"), n).
156			RunAndReturn(func(context.Context, ports.StagedID, domain.RepoName) error {
157				cancel()
158				return errBoom
159			})
160		f.meta.EXPECT().Delete(mock.Anything, n).RunAndReturn(func(c context.Context, _ domain.RepoName) error {
161			return c.Err()
162		})
163		f.git.EXPECT().Discard(mock.Anything, ports.StagedID("s1")).RunAndReturn(func(c context.Context, _ ports.StagedID) error {
164			return c.Err()
165		})
166		_, err := f.repos.Create(cctx, domain.Root(), app.CreateParams{Name: n})
167		assert.ErrorIs(t, err, errBoom)
168	})
169}
170
171func tokenPrincipal(s domain.Scope, repos ...string) domain.Principal {
172	tok := domain.Token{ID: "t", Scopes: []domain.Scope{s}}
173	for _, r := range repos {
174		n, err := domain.ParseRepoName(r)
175		if err != nil {
176			panic(err)
177		}
178		tok.Repos = append(tok.Repos, n)
179	}
180	return domain.TokenPrincipal(tok)
181}
182
183func TestGetAndListAccess(t *testing.T) {
184	ctx := context.Background()
185	pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
186	priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate}
187
188	tests := []struct {
189		name      string
190		principal domain.Principal
191		get       domain.Repo
192		wantErr   error
193	}{
194		{"anonymous reads public", domain.Anonymous(), pub, nil},
195		{"anonymous cannot see private", domain.Anonymous(), priv, domain.ErrNotFound},
196		{"root reads public", domain.Root(), pub, nil},
197		{"root reads private", domain.Root(), priv, nil},
198		{"token with repo:read reads private", tokenPrincipal(domain.ScopeRepoRead), priv, nil},
199		{"token limited to the repo reads private", tokenPrincipal(domain.ScopeRepoRead, "priv"), priv, nil},
200		{"token limited to another repo cannot see private", tokenPrincipal(domain.ScopeRepoRead, "other"), priv, domain.ErrNotFound},
201		{"token without repo:read cannot see private", tokenPrincipal(domain.ScopeRepoWrite), priv, domain.ErrNotFound},
202		{"token without repo:read reads public", tokenPrincipal(domain.ScopeRepoWrite), pub, nil},
203		{"token limited to another repo reads public", tokenPrincipal(domain.ScopeRepoRead, "other"), pub, nil},
204	}
205	for _, tt := range tests {
206		t.Run("get/"+tt.name, func(t *testing.T) {
207			f := newFixture(t)
208			f.meta.EXPECT().Get(ctx, tt.get.Name).Return(tt.get, nil)
209			got, err := f.repos.Get(ctx, tt.principal, tt.get.Name)
210			if tt.wantErr != nil {
211				assert.ErrorIs(t, err, tt.wantErr)
212				assert.Zero(t, got)
213				return
214			}
215			require.NoError(t, err)
216			assert.Equal(t, tt.get, got)
217		})
218	}
219
220	t.Run("get missing is not found", func(t *testing.T) {
221		f := newFixture(t)
222		f.meta.EXPECT().Get(ctx, pub.Name).Return(domain.Repo{}, domain.ErrNotFound)
223		_, err := f.repos.Get(ctx, domain.Root(), pub.Name)
224		assert.ErrorIs(t, err, domain.ErrNotFound)
225	})
226
227	t.Run("private missing and hidden look the same", func(t *testing.T) {
228		f := newFixture(t)
229		f.meta.EXPECT().Get(ctx, priv.Name).Return(priv, nil)
230		_, errHidden := f.repos.Get(ctx, domain.Anonymous(), priv.Name)
231		f.meta.EXPECT().Get(ctx, name(t, "gone")).Return(domain.Repo{}, domain.ErrNotFound)
232		_, errMissing := f.repos.Get(ctx, domain.Anonymous(), name(t, "gone"))
233		assert.Equal(t, errMissing, errHidden)
234	})
235
236	listTests := []struct {
237		name      string
238		principal domain.Principal
239		want      []domain.Repo
240	}{
241		{"anonymous lists public only", domain.Anonymous(), []domain.Repo{pub}},
242		{"root lists all", domain.Root(), []domain.Repo{priv, pub}},
243	}
244	for _, tt := range listTests {
245		t.Run("list/"+tt.name, func(t *testing.T) {
246			f := newFixture(t)
247			f.meta.EXPECT().List(ctx).Return([]domain.Repo{priv, pub}, nil)
248			got, err := f.repos.List(ctx, tt.principal)
249			require.NoError(t, err)
250			assert.Equal(t, tt.want, got)
251		})
252	}
253
254	t.Run("list error", func(t *testing.T) {
255		f := newFixture(t)
256		boom := errors.New("boom")
257		f.meta.EXPECT().List(ctx).Return(nil, boom)
258		_, err := f.repos.List(ctx, domain.Root())
259		assert.ErrorIs(t, err, boom)
260	})
261}
262
263func TestReconcile(t *testing.T) {
264	ctx := context.Background()
265
266	t.Run("registers orphan dir as private, reports missing dir, skips invalid", func(t *testing.T) {
267		f := newFixture(t)
268		orphan, kept, ghost := name(t, "orphan"), name(t, "kept"), name(t, "ghost")
269		f.git.EXPECT().PurgeStaging(ctx).Return(nil)
270		f.git.EXPECT().List(ctx).Return(ports.RepoDirs{
271			Names:   []domain.RepoName{orphan, kept},
272			Invalid: []string{"Bad Name.git"},
273		}, nil)
274		f.meta.EXPECT().List(ctx).Return([]domain.Repo{
275			{Name: ghost, Visibility: domain.VisibilityPublic},
276			{Name: kept, Visibility: domain.VisibilityPublic},
277		}, nil)
278		f.meta.EXPECT().Create(ctx, domain.Repo{Name: orphan, Visibility: domain.VisibilityPrivate, CreatedAt: now, Settings: domain.DefaultRepoSettings()}).Return(nil)
279
280		rep, err := f.repos.Reconcile(ctx)
281		require.NoError(t, err)
282		assert.Equal(t, []domain.RepoName{orphan}, rep.Registered)
283		assert.Equal(t, []domain.RepoName{ghost}, rep.MissingDirs)
284		assert.Equal(t, []string{"Bad Name.git"}, rep.InvalidDirs)
285	})
286
287	t.Run("purge failure stops", func(t *testing.T) {
288		f := newFixture(t)
289		boom := errors.New("boom")
290		f.git.EXPECT().PurgeStaging(ctx).Return(boom)
291		_, err := f.repos.Reconcile(ctx)
292		assert.ErrorIs(t, err, boom)
293	})
294
295	t.Run("register failure stops", func(t *testing.T) {
296		f := newFixture(t)
297		boom := errors.New("boom")
298		orphan := name(t, "orphan")
299		f.git.EXPECT().PurgeStaging(ctx).Return(nil)
300		f.git.EXPECT().List(ctx).Return(ports.RepoDirs{Names: []domain.RepoName{orphan}}, nil)
301		f.meta.EXPECT().List(ctx).Return(nil, nil)
302		f.meta.EXPECT().Create(ctx, mock.Anything).Return(boom)
303		_, err := f.repos.Reconcile(ctx)
304		assert.ErrorIs(t, err, boom)
305	})
306}

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