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}