1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6 "strings"
7 "testing"
8 "time"
9
10 pb "bnema.dev/ermite-api/gen/ermite"
11 "bnema.dev/ermite/internal/domain"
12 "bnema.dev/ermite/internal/ports"
13 "bnema.dev/ermite/internal/ports/mocks"
14 "connectrpc.com/connect"
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/mock"
17 "github.com/stretchr/testify/require"
18)
19
20func TestNoteRPCFields(t *testing.T) {
21 now := time.Now().UTC()
22 note := domain.Comment{ID: 4, ReplyTo: 2, StartLine: 10, Line: 24, Verdict: domain.VerdictApprove, EditedAt: now, Deleted: true}
23 got := commentToPB(note)
24 assert.Equal(t, "2", got.GetReplyTo())
25 assert.EqualValues(t, 10, got.GetStartLine())
26 assert.Equal(t, pb.Verdict_VERDICT_APPROVE, got.GetVerdict())
27 assert.Equal(t, now, got.GetEditedAt().AsTime())
28 assert.True(t, got.GetDeleted())
29 assert.Empty(t, commentToPB(domain.Comment{}).GetReplyTo())
30 assert.Nil(t, commentToPB(domain.Comment{}).GetEditedAt())
31 for _, v := range []domain.Verdict{domain.VerdictNone, domain.VerdictApprove, domain.VerdictChangesRequested} {
32 back, err := verdictFromPB(verdictToPB(v))
33 require.NoError(t, err)
34 assert.Equal(t, v, back)
35 }
36 m := mocks.NewMergeRequestService(t)
37 m.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.AddCommentParams{Text: "note", StartLine: 10, Line: 24, Verdict: domain.VerdictApprove}).Return(note, nil).Once()
38 res, err := (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, Text: "note", StartLine: 10, Line: 24, Verdict: pb.Verdict_VERDICT_APPROVE}))
39 require.NoError(t, err)
40 assert.Equal(t, got, res.Msg.GetComment())
41 _, err = (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Verdict: pb.Verdict(99)}))
42 requireCode(t, connect.CodeInvalidArgument, err)
43}
44
45func TestNoteMutationRPCCodes(t *testing.T) {
46 for _, edit := range []bool{false, true} {
47 for _, failure := range []error{nil, domain.ErrForbidden, domain.ErrPreconditionFailed, domain.ErrNotFound} {
48 m := mocks.NewMergeRequestService(t)
49 note := domain.Comment{ID: 2, Deleted: !edit}
50 var err error
51 if edit {
52 m.EXPECT().EditComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(2), "new").Return(note, failure).Once()
53 _, err = (&mergeRequestService{m}).EditComment(ownerCtx(), connect.NewRequest(&pb.EditCommentRequest{Repo: "demo", Number: 1, CommentId: "2", Text: "new"}))
54 } else {
55 m.EXPECT().DeleteComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(2)).Return(note, failure).Once()
56 _, err = (&mergeRequestService{m}).DeleteComment(ownerCtx(), connect.NewRequest(&pb.DeleteCommentRequest{Repo: "demo", Number: 1, CommentId: "2"}))
57 }
58 switch failure {
59 case nil:
60 require.NoError(t, err)
61 case domain.ErrForbidden:
62 requireCode(t, connect.CodePermissionDenied, err)
63 case domain.ErrPreconditionFailed:
64 requireCode(t, connect.CodeFailedPrecondition, err)
65 case domain.ErrNotFound:
66 requireCode(t, connect.CodeNotFound, err)
67 }
68 }
69 }
70}
71
72func TestNoteEventsRPC(t *testing.T) {
73 repo := mustRepoName(t, "demo")
74 note := domain.Comment{ID: 3, Verdict: domain.VerdictApprove, Deleted: true}
75 at := time.Now().UTC()
76 events := []domain.Event{
77 domain.CommentEdited{Repo: repo, Number: 1, Comment: note, At: at},
78 domain.CommentDeleted{Repo: repo, Number: 1, Comment: note, At: at},
79 domain.CommentResolved{Repo: repo, Number: 1, Comment: note, At: at},
80 }
81 for i, ev := range events {
82 got := watchEventToPB(ports.WatchEvent{ID: "e", Event: ev})
83 require.NotNil(t, got)
84 assert.Equal(t, at, got.GetTime().AsTime())
85 var c *pb.Comment
86 switch i {
87 case 0:
88 c = got.GetCommentEdited().GetComment()
89 case 1:
90 c = got.GetCommentDeleted().GetComment()
91 case 2:
92 c = got.GetCommentResolved().GetComment()
93 }
94 assert.Equal(t, "3", c.GetId())
95 assert.True(t, c.GetDeleted())
96 assert.Equal(t, pb.Verdict_VERDICT_APPROVE, c.GetVerdict())
97 }
98}
99
100func TestNoteReplyIDValidation(t *testing.T) {
101 for _, id := range []string{"abc", "0", "-1", "01", "9223372036854775808"} {
102 _, err := (&mergeRequestService{mocks.NewMergeRequestService(t)}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, ReplyTo: id, Text: "reply"}))
103 requireCode(t, connect.CodeInvalidArgument, err)
104 assert.Contains(t, err.Error(), "reply_to")
105 assert.Contains(t, err.Error(), "is not a note id")
106 }
107 m := mocks.NewMergeRequestService(t)
108 m.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.AddCommentParams{ReplyTo: 2, Text: "reply"}).Return(domain.Comment{ID: 3, ReplyTo: 2}, nil).Once()
109 res, err := (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, ReplyTo: "2", Text: "reply"}))
110 require.NoError(t, err)
111 assert.Equal(t, "2", res.Msg.GetComment().GetReplyTo())
112}
113
114func TestNoteVerdictsDetailAndScope(t *testing.T) {
115 m := mocks.NewMergeRequestService(t)
116 m.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.GetMergeRequestOptions{}).Return(ports.MergeRequestDetail{Verdicts: []domain.AuthorVerdict{{Author: "owner", Verdict: domain.VerdictApprove, CommentID: 4, Stale: true}}}, nil).Once()
117 res, err := (&mergeRequestService{m}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 1}))
118 require.NoError(t, err)
119 require.Len(t, res.Msg.GetVerdicts(), 1)
120 assert.Equal(t, "4", res.Msg.GetVerdicts()[0].GetCommentId())
121 assert.True(t, res.Msg.GetVerdicts()[0].GetStale())
122 m.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.AddCommentParams{Verdict: domain.VerdictApprove}).Return(domain.Comment{}, domain.ErrForbidden).Once()
123 _, err = (&mergeRequestService{m}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{Repo: "demo", Number: 1, Verdict: pb.Verdict_VERDICT_APPROVE}))
124 requireCode(t, connect.CodePermissionDenied, err)
125}
126
127func TestAutoMergeRPCCodesAndFields(t *testing.T) {
128 now := time.Date(2026, 10, 20, 9, 0, 0, 0, time.UTC)
129 head, err := domain.ParseObjectID(strings.Repeat("a", 40))
130 require.NoError(t, err)
131 armed := domain.MergeRequest{
132 Repo: mustRepoName(t, "demo"), Number: 1, State: domain.MRStateOpen, Source: "feat", Target: "main", HeadID: head,
133 AutoMerge: &domain.AutoMerge{Mode: domain.MergeModeSquash, HeadID: head, EnablerLabel: "owner", EnabledAt: now},
134 }
135 idle := armed
136 idle.AutoMerge = nil
137
138 pbArmed := mergeRequestToPB(armed)
139 require.NotNil(t, pbArmed.GetAutoMerge())
140 assert.Equal(t, pb.MergeMode_MERGE_MODE_SQUASH, pbArmed.GetAutoMerge().GetMode())
141 assert.Equal(t, head.String(), pbArmed.GetAutoMerge().GetHeadId())
142 assert.Equal(t, "owner", pbArmed.GetAutoMerge().GetEnabledBy())
143 assert.Equal(t, now, pbArmed.GetAutoMerge().GetEnabledAt().AsTime())
144 assert.False(t, pbArmed.GetAutoMerged())
145 assert.Nil(t, mergeRequestToPB(idle).GetAutoMerge())
146 done := idle
147 done.State, done.AutoMerged = domain.MRStateMerged, true
148 assert.True(t, mergeRequestToPB(done).GetAutoMerged())
149
150 // Success: the mode and the request reach the service, the fields come back.
151 m := mocks.NewMergeRequestService(t)
152 m.EXPECT().EnableAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), ports.EnableAutoMergeParams{Mode: domain.MergeModeSquash, HeadID: mustObjectID(t, strings.Repeat("a", domain.ObjectIDLen))}).Return(armed, nil).Once()
153 res, err := (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, Mode: pb.MergeMode_MERGE_MODE_SQUASH, HeadId: strings.Repeat("a", domain.ObjectIDLen)}))
154 require.NoError(t, err)
155 assert.Equal(t, pbArmed, res.Msg.GetMergeRequest())
156 m.EXPECT().CancelAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1)).Return(idle, nil).Once()
157 cres, err := (&mergeRequestService{m}).CancelAutoMerge(ownerCtx(), connect.NewRequest(&pb.CancelAutoMergeRequest{Repo: "demo", Number: 1}))
158 require.NoError(t, err)
159 assert.Nil(t, cres.Msg.GetMergeRequest().GetAutoMerge())
160
161 // Bad input never reaches the service.
162 _, err = (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, Mode: pb.MergeMode(99), HeadId: strings.Repeat("a", domain.ObjectIDLen)}))
163 requireCode(t, connect.CodeInvalidArgument, err)
164 _, err = (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, HeadId: "abc"}))
165 requireCode(t, connect.CodeInvalidArgument, err)
166 _, err = (&mergeRequestService{m}).CancelAutoMerge(ownerCtx(), connect.NewRequest(&pb.CancelAutoMergeRequest{Repo: "", Number: 1}))
167 requireCode(t, connect.CodeInvalidArgument, err)
168
169 // Domain errors map like the other merge request calls.
170 for _, tc := range []struct {
171 err error
172 code connect.Code
173 }{
174 {domain.ErrPreconditionFailed, connect.CodeFailedPrecondition},
175 {domain.ErrForbidden, connect.CodePermissionDenied},
176 {domain.ErrNotFound, connect.CodeNotFound},
177 } {
178 m := mocks.NewMergeRequestService(t)
179 m.EXPECT().EnableAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), mock.Anything).Return(domain.MergeRequest{}, tc.err).Once()
180 _, err := (&mergeRequestService{m}).EnableAutoMerge(ownerCtx(), connect.NewRequest(&pb.EnableAutoMergeRequest{Repo: "demo", Number: 1, HeadId: strings.Repeat("a", domain.ObjectIDLen)}))
181 requireCode(t, tc.code, err)
182 m.EXPECT().CancelAutoMerge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1)).Return(domain.MergeRequest{}, tc.err).Once()
183 _, err = (&mergeRequestService{m}).CancelAutoMerge(ownerCtx(), connect.NewRequest(&pb.CancelAutoMergeRequest{Repo: "demo", Number: 1}))
184 requireCode(t, tc.code, err)
185 }
186}
187
188func TestAutoMergeWatchEventsAndSettingsToPB(t *testing.T) {
189 armed := domain.MergeRequest{Repo: mustRepoName(t, "demo"), Number: 1, State: domain.MRStateOpen,
190 AutoMerge: &domain.AutoMerge{Mode: domain.MergeModeMerge, EnablerLabel: "owner"}}
191 got := watchEventToPB(ports.WatchEvent{ID: "1", Event: domain.MergeRequestAutoMergeEnabled{MergeRequest: armed}})
192 require.NotNil(t, got.GetMergeRequestAutoMergeEnabled())
193 assert.Equal(t, pb.MergeMode_MERGE_MODE_MERGE_COMMIT, got.GetMergeRequestAutoMergeEnabled().GetMergeRequest().GetAutoMerge().GetMode())
194 got = watchEventToPB(ports.WatchEvent{ID: "2", Event: domain.MergeRequestAutoMergeCancelled{MergeRequest: armed, Reason: "the branch moved"}})
195 require.NotNil(t, got.GetMergeRequestAutoMergeCancelled())
196 assert.Equal(t, "the branch moved", got.GetMergeRequestAutoMergeCancelled().GetReason())
197 got = watchEventToPB(ports.WatchEvent{ID: "3", Event: domain.MergeRequestMerged{MergeRequest: armed, Auto: true}})
198 assert.True(t, got.GetMergeRequestMerged().GetAuto())
199 got = watchEventToPB(ports.WatchEvent{ID: "4", Event: domain.MergeRequestMerged{MergeRequest: armed}})
200 assert.False(t, got.GetMergeRequestMerged().GetAuto())
201
202 assert.True(t, settingsToPB(domain.RepoSettings{AllowAutoMerge: true}).GetAllowAutoMerge())
203 assert.False(t, settingsToPB(domain.RepoSettings{}).GetAllowAutoMerge())
204}