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}
283
284func TestUpdateParamsAllowAutoMerge(t *testing.T) {
285 t.Parallel()
286 mask := func(paths ...string) *fieldmaskpb.FieldMask { return &fieldmaskpb.FieldMask{Paths: paths} }
287 settings := &pb.RepoSettings{AllowAutoMerge: true, FailingChecksBlock: true, AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}}
288
289 params, err := updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings.allow_auto_merge"), Settings: settings})
290 require.NoError(t, err)
291 require.NotNil(t, params.AllowAutoMerge)
292 assert.True(t, *params.AllowAutoMerge)
293 assert.Nil(t, params.FailingChecksBlock, "only the masked setting is set")
294 assert.Nil(t, params.AllowedMergeModes)
295
296 params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings"), Settings: settings})
297 require.NoError(t, err)
298 require.NotNil(t, params.AllowAutoMerge, "settings covers allow_auto_merge")
299 assert.True(t, *params.AllowAutoMerge)
300 require.NotNil(t, params.FailingChecksBlock)
301 require.NotNil(t, params.AllowedMergeModes)
302
303 params, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: mask("settings.failing_checks_block"), Settings: settings})
304 require.NoError(t, err)
305 assert.Nil(t, params.AllowAutoMerge)
306 require.NotNil(t, params.FailingChecksBlock)
307}