v0.17.0 ab8e178

repo_test.go RawHistoryFolder

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

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