1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6 "testing"
7
8 "connectrpc.com/connect"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"
12
13 pb "bnema.dev/ermite-api/gen/ermite"
14 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports"
16 "bnema.dev/ermite/internal/ports/mocks"
17)
18
19func TestMergeRequestServiceCreate(t *testing.T) {
20 t.Parallel()
21 tests := []struct {
22 name string
23 repo string
24 usecase error
25 wantCode connect.Code
26 wantCall bool
27 }{
28 {name: "ok", repo: "demo", wantCall: true},
29 {name: "invalid repo", repo: "../x", wantCode: connect.CodeInvalidArgument},
30 {name: "not found", repo: "demo", usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true},
31 {name: "forbidden", repo: "demo", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied, wantCall: true},
32 {name: "already open", repo: "demo", usecase: domain.ErrAlreadyExists, wantCode: connect.CodeAlreadyExists, wantCall: true},
33 {name: "invalid fields", repo: "demo", usecase: domain.ErrInvalid, wantCode: connect.CodeInvalidArgument, wantCall: true},
34 }
35 for _, tt := range tests {
36 t.Run(tt.name, func(t *testing.T) {
37 t.Parallel()
38 mrs := mocks.NewMergeRequestService(t)
39 if tt.wantCall {
40 mrs.EXPECT().Create(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), ports.CreateMergeRequestParams{
41 Title: "t", Description: "d", Source: "feat", Target: "main",
42 }).Return(domain.MergeRequest{Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}, tt.usecase).Once()
43 }
44 resp, err := (&mergeRequestService{mrs: mrs}).CreateMergeRequest(ownerCtx(), connect.NewRequest(&pb.CreateMergeRequestRequest{
45 Repo: tt.repo, Title: "t", Description: "d", SourceBranch: "feat", TargetBranch: "main",
46 }))
47 if tt.wantCode != 0 {
48 requireCode(t, tt.wantCode, err)
49 return
50 }
51 require.NoError(t, err)
52 assert.EqualValues(t, 7, resp.Msg.GetMergeRequest().GetNumber())
53 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, resp.Msg.GetMergeRequest().GetState())
54 assert.Equal(t, pb.MergeabilityState_MERGEABILITY_STATE_CHECKING, resp.Msg.GetMergeRequest().GetMergeability().GetState())
55 })
56 }
57}
58
59func TestMergeRequestServiceListRejectsUnknownState(t *testing.T) {
60 t.Parallel()
61 _, err := (&mergeRequestService{mrs: mocks.NewMergeRequestService(t)}).ListMergeRequests(ownerCtx(),
62 connect.NewRequest(&pb.ListMergeRequestsRequest{Repo: "demo", State: pb.MergeRequestState(99)}))
63 requireCode(t, connect.CodeInvalidArgument, err)
64}
65
66func TestMergeRequestServiceListFiltersByState(t *testing.T) {
67 t.Parallel()
68 mrs := mocks.NewMergeRequestService(t)
69 mrs.EXPECT().List(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), ports.ListMergeRequestsParams{
70 State: domain.MRStateMerged, PageSize: 3, PageToken: "tok",
71 }).Return(ports.MergeRequestPage{MergeRequests: []domain.MergeRequest{{Number: 1}}, NextPageToken: "n"}, nil).Once()
72 resp, err := (&mergeRequestService{mrs: mrs}).ListMergeRequests(ownerCtx(), connect.NewRequest(&pb.ListMergeRequestsRequest{
73 Repo: "demo", State: pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, PageSize: 3, PageToken: "tok",
74 }))
75 require.NoError(t, err)
76 assert.Len(t, resp.Msg.GetMergeRequests(), 1)
77 assert.Equal(t, "n", resp.Msg.GetNextPageToken())
78}
79
80func TestMergeRequestServiceAddComment(t *testing.T) {
81 t.Parallel()
82 tests := []struct {
83 name string
84 side pb.DiffSide
85 usecase error
86 wantCode connect.Code
87 wantCall bool
88 }{
89 {name: "ok", side: pb.DiffSide_DIFF_SIDE_NEW, wantCall: true},
90 {name: "unknown side", side: pb.DiffSide(99), wantCode: connect.CodeInvalidArgument},
91 {name: "merge request not open", side: pb.DiffSide_DIFF_SIDE_NEW, usecase: domain.ErrPreconditionFailed, wantCode: connect.CodeFailedPrecondition, wantCall: true},
92 {name: "concurrent update", side: pb.DiffSide_DIFF_SIDE_NEW, usecase: domain.ErrConflict, wantCode: connect.CodeAborted, wantCall: true},
93 {name: "not found", side: pb.DiffSide_DIFF_SIDE_NEW, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true},
94 }
95 for _, tt := range tests {
96 t.Run(tt.name, func(t *testing.T) {
97 t.Parallel()
98 mrs := mocks.NewMergeRequestService(t)
99 if tt.wantCall {
100 mrs.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(2), mock.Anything).
101 Return(domain.Comment{ID: 5}, tt.usecase).Once()
102 }
103 resp, err := (&mergeRequestService{mrs: mrs}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{
104 Repo: "demo", Number: 2, Path: "f.go", Line: 3, Side: tt.side, Text: "hi",
105 }))
106 if tt.wantCode != 0 {
107 requireCode(t, tt.wantCode, err)
108 return
109 }
110 require.NoError(t, err)
111 assert.Equal(t, "5", resp.Msg.GetComment().GetId())
112 })
113 }
114}
115
116func TestMergeRequestServiceResolveCommentMapsUnissuedIDsToZero(t *testing.T) {
117 t.Parallel()
118 for _, id := range []string{"", "abc", "-1", "0", "007", "99999999999999999999"} {
119 t.Run(id, func(t *testing.T) {
120 t.Parallel()
121 mrs := mocks.NewMergeRequestService(t)
122 mrs.EXPECT().ResolveComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(0), true).
123 Return(domain.Comment{}, domain.ErrNotFound).Once()
124 _, err := (&mergeRequestService{mrs: mrs}).ResolveComment(ownerCtx(), connect.NewRequest(&pb.ResolveCommentRequest{
125 Repo: "demo", Number: 1, CommentId: id, Resolved: true,
126 }))
127 requireCode(t, connect.CodeNotFound, err)
128 })
129 }
130}
131
132func TestMergeRequestServiceMerge(t *testing.T) {
133 t.Parallel()
134 const head = "0123456789abcdef0123456789abcdef01234567"
135 tests := []struct {
136 name string
137 req *pb.MergeMergeRequestRequest
138 usecase error
139 wantCode connect.Code
140 wantCall bool
141 }{
142 {name: "ok", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: head, DeleteSourceBranch: true}, wantCall: true},
143 {name: "ignore checks", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: head, IgnoreChecks: true}, wantCall: true},
144 {name: "head is not a full id", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: "abc"}, wantCode: connect.CodeInvalidArgument},
145 {name: "invalid repo", req: &pb.MergeMergeRequestRequest{Repo: "../x", Number: 1}, wantCode: connect.CodeInvalidArgument},
146 {name: "head moved", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: head}, usecase: domain.ErrConflict, wantCode: connect.CodeAborted, wantCall: true},
147 {name: "checks failing", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1}, usecase: domain.ErrPreconditionFailed, wantCode: connect.CodeFailedPrecondition, wantCall: true},
148 {name: "forbidden", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1}, usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied, wantCall: true},
149 }
150 for _, tt := range tests {
151 t.Run(tt.name, func(t *testing.T) {
152 t.Parallel()
153 mrs := mocks.NewMergeRequestService(t)
154 if tt.wantCall {
155 mrs.EXPECT().Merge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), mock.MatchedBy(func(p ports.MergeParams) bool {
156 return p.IgnoreChecks == tt.req.GetIgnoreChecks() && p.DeleteSource == tt.req.GetDeleteSourceBranch() && (tt.req.GetHeadId() == "") == p.HeadID.IsZero()
157 })).Return(ports.MergeResult{MergeRequest: domain.MergeRequest{Number: 1}, SourceDeleted: true}, tt.usecase).Once()
158 }
159 resp, err := (&mergeRequestService{mrs: mrs}).MergeMergeRequest(ownerCtx(), connect.NewRequest(tt.req))
160 if tt.wantCode != 0 {
161 requireCode(t, tt.wantCode, err)
162 return
163 }
164 require.NoError(t, err)
165 assert.True(t, resp.Msg.GetSourceBranchDeleted())
166 })
167 }
168}
169
170func TestMergeRequestServiceCloseAndMarkRead(t *testing.T) {
171 t.Parallel()
172 t.Run("close not found", func(t *testing.T) {
173 t.Parallel()
174 mrs := mocks.NewMergeRequestService(t)
175 mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{}, domain.ErrNotFound).Once()
176 _, err := (&mergeRequestService{mrs: mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))
177 requireCode(t, connect.CodeNotFound, err)
178 })
179 t.Run("close ok", func(t *testing.T) {
180 t.Parallel()
181 mrs := mocks.NewMergeRequestService(t)
182 mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{Number: 9, State: domain.MRStateClosed}, nil).Once()
183 resp, err := (&mergeRequestService{mrs: mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))
184 require.NoError(t, err)
185 assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, resp.Msg.GetMergeRequest().GetState())
186 })
187 t.Run("mark read rejects a short head", func(t *testing.T) {
188 t.Parallel()
189 _, err := (&mergeRequestService{mrs: mocks.NewMergeRequestService(t)}).MarkRead(ownerCtx(),
190 connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1, HeadId: "abc"}))
191 requireCode(t, connect.CodeInvalidArgument, err)
192 })
193 t.Run("mark read unauthenticated", func(t *testing.T) {
194 t.Parallel()
195 mrs := mocks.NewMergeRequestService(t)
196 mrs.EXPECT().MarkRead(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), domain.ObjectID{}).Return(domain.ObjectID{}, domain.ErrUnauthenticated).Once()
197 _, err := (&mergeRequestService{mrs: mrs}).MarkRead(ownerCtx(), connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1}))
198 requireCode(t, connect.CodeUnauthenticated, err)
199 })
200}
201
202func TestMergeRequestServiceGet(t *testing.T) {
203 t.Parallel()
204 tests := []struct {
205 name string
206 usecase error
207 wantCode connect.Code
208 }{
209 {name: "ok"},
210 {name: "not found", usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound},
211 {name: "unauthenticated", usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated},
212 }
213 for _, tt := range tests {
214 t.Run(tt.name, func(t *testing.T) {
215 t.Parallel()
216 mrs := mocks.NewMergeRequestService(t)
217 mrs.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(4), ports.GetMergeRequestOptions{SinceLastRead: true}).
218 Return(ports.MergeRequestDetail{MergeRequest: domain.MergeRequest{Number: 4}, CommitsTruncated: true}, tt.usecase).Once()
219 resp, err := (&mergeRequestService{mrs: mrs}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 4, SinceLastRead: true}))
220 if tt.wantCode != 0 {
221 requireCode(t, tt.wantCode, err)
222 return
223 }
224 require.NoError(t, err)
225 assert.EqualValues(t, 4, resp.Msg.GetMergeRequest().GetNumber())
226 assert.True(t, resp.Msg.GetCommitsTruncated())
227 })
228 }
229}
230
231func TestMergeRequestServiceMarkReady(t *testing.T) {
232 t.Parallel()
233 for _, tt := range []struct {
234 name string
235 err error
236 code connect.Code
237 }{
238 {name: "success"},
239 {name: "not a draft", err: domain.ErrPreconditionFailed, code: connect.CodeFailedPrecondition},
240 {name: "closed", err: domain.ErrPreconditionFailed, code: connect.CodeFailedPrecondition},
241 {name: "without mr:write", err: domain.ErrForbidden, code: connect.CodePermissionDenied},
242 } {
243 t.Run(tt.name, func(t *testing.T) {
244 mrs := mocks.NewMergeRequestService(t)
245 title, err := domain.ParseMRTitle("Ready")
246 require.NoError(t, err)
247 mrs.EXPECT().MarkReady(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1)).Return(domain.MergeRequest{Number: 1, Title: title, State: domain.MRStateOpen}, tt.err).Once()
248 res, err := (&mergeRequestService{mrs: mrs}).MarkReady(ownerCtx(), connect.NewRequest(&pb.MarkReadyRequest{Repo: "demo", Number: 1}))
249 if tt.code != 0 {
250 requireCode(t, tt.code, err)
251 return
252 }
253 require.NoError(t, err)
254 assert.Equal(t, "Ready", res.Msg.GetMergeRequest().GetTitle())
255 })
256 }
257}