detached 0e6522c

repo_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6	"context"
7	"errors"
8	"slices"
9	"testing"
10
11	"connectrpc.com/connect"
12	"github.com/stretchr/testify/assert"
13	"github.com/stretchr/testify/mock"
14	"github.com/stretchr/testify/require"
15	"google.golang.org/protobuf/types/known/fieldmaskpb"
16
17	pb "bnema.dev/ermite-api/gen/ermite"
18	"bnema.dev/ermite/internal/domain"
19	"bnema.dev/ermite/internal/ports"
20	"bnema.dev/ermite/internal/ports/mocks"
21)
22
23// ownerCtx is the context of a call made by the owner, as the interceptor
24// leaves it.
25func ownerCtx() context.Context {
26	return domain.ContextWithPrincipal(context.Background(), domain.Owner())
27}
28
29func mustRepoName(t *testing.T, s string) domain.RepoName {
30	t.Helper()
31	n, err := domain.ParseRepoName(s)
32	require.NoError(t, err)
33	return n
34}
35
36func requireCode(t *testing.T, want connect.Code, err error) {
37	t.Helper()
38	require.Error(t, err)
39	assert.Equal(t, want, connect.CodeOf(err), err.Error())
40}
41
42func TestRepoServiceGetRepo(t *testing.T) {
43	t.Parallel()
44	boom := errors.New("disk on fire")
45	tests := []struct {
46		name     string
47		repo     string
48		usecase  error
49		viewErr  error
50		wantCode connect.Code
51		wantCall bool
52	}{
53		{name: "ok", repo: "demo", wantCall: true},
54		{name: "invalid name never reaches the use case", repo: "../etc", wantCode: connect.CodeInvalidArgument},
55		{name: "not found", repo: "demo", usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true},
56		{name: "forbidden", repo: "demo", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied, wantCall: true},
57		{name: "unauthenticated", repo: "demo", usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated, wantCall: true},
58		{name: "unknown error is internal and opaque", repo: "demo", usecase: boom, wantCode: connect.CodeInternal, wantCall: true},
59		{name: "view failure", repo: "demo", viewErr: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true},
60	}
61	for _, tt := range tests {
62		t.Run(tt.name, func(t *testing.T) {
63			t.Parallel()
64			repos := mocks.NewRepoService(t)
65			if tt.wantCall {
66				repo := domain.Repo{Name: mustRepoName(t, "demo"), Description: "d", Visibility: domain.VisibilityPublic}
67				repos.EXPECT().Get(mock.Anything, domain.Owner(), repo.Name).Return(repo, tt.usecase).Once()
68				if tt.usecase == nil {
69					repos.EXPECT().View(mock.Anything, repo).Return(ports.RepoView{Repo: repo, DefaultBranch: "main"}, tt.viewErr).Once()
70				}
71			}
72			svc := &repoService{repos}
73			resp, err := svc.GetRepo(ownerCtx(), connect.NewRequest(&pb.GetRepoRequest{Repo: tt.repo}))
74			if tt.wantCode != 0 {
75				requireCode(t, tt.wantCode, err)
76				if errors.Is(tt.usecase, boom) {
77					assert.NotContains(t, err.Error(), "disk on fire")
78				}
79				return
80			}
81			require.NoError(t, err)
82			assert.Equal(t, "demo", resp.Msg.GetRepo().GetName())
83			assert.Equal(t, "main", resp.Msg.GetRepo().GetDefaultBranch())
84		})
85	}
86}
87
88func TestRepoServiceCreateRepo(t *testing.T) {
89	t.Parallel()
90	tests := []struct {
91		name     string
92		req      *pb.CreateRepoRequest
93		usecase  error
94		wantCode connect.Code
95		wantCall bool
96	}{
97		{name: "ok", req: &pb.CreateRepoRequest{Name: "demo", Topics: []string{"go", "cli"}, Visibility: pb.Visibility_VISIBILITY_PRIVATE}, wantCall: true},
98		{name: "invalid name", req: &pb.CreateRepoRequest{Name: "Bad Name", Visibility: pb.Visibility_VISIBILITY_PRIVATE}, wantCode: connect.CodeInvalidArgument},
99		{name: "unknown visibility", req: &pb.CreateRepoRequest{Name: "demo", Visibility: pb.Visibility(99)}, wantCode: connect.CodeInvalidArgument},
100		{name: "already exists", req: &pb.CreateRepoRequest{Name: "demo"}, usecase: domain.ErrAlreadyExists, wantCode: connect.CodeAlreadyExists, wantCall: true},
101		{name: "forbidden", req: &pb.CreateRepoRequest{Name: "demo"}, usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied, wantCall: true},
102	}
103	for _, tt := range tests {
104		t.Run(tt.name, func(t *testing.T) {
105			t.Parallel()
106			repos := mocks.NewRepoService(t)
107			if tt.wantCall {
108				repo := domain.Repo{Name: mustRepoName(t, "demo"), Visibility: domain.VisibilityPrivate}
109				repos.EXPECT().Create(mock.Anything, domain.Owner(), mock.MatchedBy(func(p ports.CreateRepoParams) bool {
110					return p.Name.String() == "demo" && slices.Equal(p.Topics, tt.req.GetTopics())
111				})).Return(repo, tt.usecase).Once()
112				if tt.usecase == nil {
113					repos.EXPECT().View(mock.Anything, repo).Return(ports.RepoView{Repo: repo}, nil).Once()
114				}
115			}
116			resp, err := (&repoService{repos}).CreateRepo(ownerCtx(), connect.NewRequest(tt.req))
117			if tt.wantCode != 0 {
118				requireCode(t, tt.wantCode, err)
119				return
120			}
121			require.NoError(t, err)
122			assert.Equal(t, "demo", resp.Msg.GetRepo().GetName())
123			assert.Equal(t, pb.Visibility_VISIBILITY_PRIVATE, resp.Msg.GetRepo().GetVisibility())
124		})
125	}
126}
127
128func TestRepoServiceUpdateRepoValidatesMask(t *testing.T) {
129	t.Parallel()
130	tests := []struct {
131		name     string
132		req      *pb.UpdateRepoRequest
133		usecase  error
134		wantCode connect.Code
135		wantCall bool
136	}{
137		{name: "empty mask", req: &pb.UpdateRepoRequest{Repo: "demo"}, wantCode: connect.CodeInvalidArgument},
138		{
139			name:     "unknown path",
140			req:      &pb.UpdateRepoRequest{Repo: "demo", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"name"}}},
141			wantCode: connect.CodeInvalidArgument,
142		},
143		{
144			name:     "unspecified visibility",
145			req:      &pb.UpdateRepoRequest{Repo: "demo", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}}},
146			wantCode: connect.CodeInvalidArgument,
147		},
148		{
149			name:     "invalid repo name",
150			req:      &pb.UpdateRepoRequest{Repo: "", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}},
151			wantCode: connect.CodeInvalidArgument,
152		},
153		{
154			name:     "not found",
155			req:      &pb.UpdateRepoRequest{Repo: "demo", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}},
156			usecase:  domain.ErrNotFound,
157			wantCode: connect.CodeNotFound, wantCall: true,
158		},
159		{
160			name:     "precondition failed",
161			req:      &pb.UpdateRepoRequest{Repo: "demo", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"default_branch"}}, DefaultBranch: "nope"},
162			usecase:  domain.ErrPreconditionFailed,
163			wantCode: connect.CodeFailedPrecondition, wantCall: true,
164		},
165		{
166			name:     "ok",
167			req:      &pb.UpdateRepoRequest{Repo: "demo", UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"description"}}, Description: "new"},
168			wantCall: true,
169		},
170	}
171	for _, tt := range tests {
172		t.Run(tt.name, func(t *testing.T) {
173			t.Parallel()
174			repos := mocks.NewRepoService(t)
175			if tt.wantCall {
176				repo := domain.Repo{Name: mustRepoName(t, "demo"), Description: "new"}
177				repos.EXPECT().Update(mock.Anything, domain.Owner(), repo.Name, mock.Anything).Return(repo, tt.usecase).Once()
178				if tt.usecase == nil {
179					repos.EXPECT().View(mock.Anything, repo).Return(ports.RepoView{Repo: repo}, nil).Once()
180				}
181			}
182			resp, err := (&repoService{repos}).UpdateRepo(ownerCtx(), connect.NewRequest(tt.req))
183			if tt.wantCode != 0 {
184				requireCode(t, tt.wantCode, err)
185				return
186			}
187			require.NoError(t, err)
188			assert.Equal(t, "new", resp.Msg.GetRepo().GetDescription())
189		})
190	}
191}
192
193func TestRepoServiceDeleteRepo(t *testing.T) {
194	t.Parallel()
195	tests := []struct {
196		name     string
197		repo     string
198		usecase  error
199		wantCode connect.Code
200		wantCall bool
201	}{
202		{name: "ok", repo: "demo", wantCall: true},
203		{name: "invalid name", repo: "a/../b", wantCode: connect.CodeInvalidArgument},
204		{name: "not found", repo: "demo", usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true},
205		{name: "forbidden", repo: "demo", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied, wantCall: true},
206	}
207	for _, tt := range tests {
208		t.Run(tt.name, func(t *testing.T) {
209			t.Parallel()
210			repos := mocks.NewRepoService(t)
211			if tt.wantCall {
212				repos.EXPECT().Delete(mock.Anything, domain.Owner(), mustRepoName(t, tt.repo)).Return(tt.usecase).Once()
213			}
214			_, err := (&repoService{repos}).DeleteRepo(ownerCtx(), connect.NewRequest(&pb.DeleteRepoRequest{Repo: tt.repo}))
215			if tt.wantCode != 0 {
216				requireCode(t, tt.wantCode, err)
217				return
218			}
219			require.NoError(t, err)
220		})
221	}
222}
223
224func TestRepoServiceListRepos(t *testing.T) {
225	t.Parallel()
226	t.Run("pages and views every repo", func(t *testing.T) {
227		t.Parallel()
228		repos := mocks.NewRepoService(t)
229		a := domain.Repo{Name: mustRepoName(t, "a")}
230		b := domain.Repo{Name: mustRepoName(t, "b")}
231		repos.EXPECT().ListPage(mock.Anything, domain.Owner(), 2, "tok").Return(ports.RepoPage{Repos: []domain.Repo{a, b}, NextPageToken: "next"}, nil).Once()
232		repos.EXPECT().View(mock.Anything, a).Return(ports.RepoView{Repo: a}, nil).Once()
233		repos.EXPECT().View(mock.Anything, b).Return(ports.RepoView{Repo: b}, nil).Once()
234		resp, err := (&repoService{repos}).ListRepos(ownerCtx(), connect.NewRequest(&pb.ListReposRequest{PageSize: 2, PageToken: "tok"}))
235		require.NoError(t, err)
236		require.Len(t, resp.Msg.GetRepos(), 2)
237		assert.Equal(t, "next", resp.Msg.GetNextPageToken())
238	})
239	for name, tc := range map[string]struct {
240		err  error
241		code connect.Code
242	}{
243		"bad page token":  {domain.ErrInvalidPageToken, connect.CodeInvalidArgument},
244		"bad page size":   {domain.ErrInvalidPageSize, connect.CodeInvalidArgument},
245		"unauthenticated": {domain.ErrUnauthenticated, connect.CodeUnauthenticated},
246	} {
247		t.Run(name, func(t *testing.T) {
248			t.Parallel()
249			repos := mocks.NewRepoService(t)
250			repos.EXPECT().ListPage(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.RepoPage{}, tc.err).Once()
251			_, err := (&repoService{repos}).ListRepos(ownerCtx(), connect.NewRequest(&pb.ListReposRequest{}))
252			requireCode(t, tc.code, err)
253		})
254	}
255}
256
257func TestUpdateParamsTopics(t *testing.T) {
258	t.Parallel()
259	mask := func(paths ...string) *fieldmaskpb.FieldMask { return &fieldmaskpb.FieldMask{Paths: paths} }
260
261	params, err := updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("topics"), Topics: []string{"go", "cli"}})
262	require.NoError(t, err)
263	require.NotNil(t, params.Topics)
264	assert.Equal(t, []string{"go", "cli"}, *params.Topics)
265	assert.Nil(t, params.Description, "only the masked fields are set")
266
267	params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("topics")})
268	require.NoError(t, err)
269	require.NotNil(t, params.Topics, "a masked empty list is a clear, not an absent field")
270	assert.Empty(t, *params.Topics)
271
272	params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("description"), Topics: []string{"go"}})
273	require.NoError(t, err)
274	assert.Nil(t, params.Topics, "topics outside the mask are ignored")
275}
276
277func TestRepoToPBTopics(t *testing.T) {
278	t.Parallel()
279	got := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustRepoName(t, "demo"), Topics: []string{"cli", "go"}}})
280	assert.Equal(t, []string{"cli", "go"}, got.GetTopics())
281	assert.Empty(t, repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustRepoName(t, "demo")}}).GetTopics())
282}

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 4

Tags 45

↑↓ moveenter openesc close