1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6 "context"
7 "fmt"
8 "testing"
9 "time"
10
11 "connectrpc.com/connect"
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"
14 "google.golang.org/genproto/googleapis/rpc/errdetails"
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)
21
22// Every value of the domain maps to a real value of the contract, never to
23// UNSPECIFIED, and the mappers that have an inverse round-trip. A value added
24// to the domain and forgotten here would reach clients as "not set".
25func TestEnumMappersCoverEveryDomainValue(t *testing.T) {
26 t.Parallel()
27 t.Run("visibility", func(t *testing.T) {
28 t.Parallel()
29 for _, v := range []domain.Visibility{domain.VisibilityPublic, domain.VisibilityPrivate} {
30 got := visibilityToPB(v)
31 assert.NotEqual(t, pb.Visibility_VISIBILITY_UNSPECIFIED, got, v)
32 back, err := visibilityFromPB(got)
33 require.NoError(t, err, v)
34 assert.Equal(t, v, back)
35 }
36 assert.Equal(t, pb.Visibility_VISIBILITY_UNSPECIFIED, visibilityToPB(""))
37 _, err := visibilityFromPB(pb.Visibility(99))
38 assert.ErrorIs(t, err, domain.ErrInvalidVisibility)
39 })
40
41 t.Run("scope", func(t *testing.T) {
42 t.Parallel()
43 for _, s := range domain.AllScopes() {
44 got, ok := scopeToPB[s]
45 require.True(t, ok, "scope %s has no counterpart in the contract", s)
46 assert.NotEqual(t, pb.Scope_SCOPE_UNSPECIFIED, got, s)
47 back, err := scopeFromPB(got)
48 require.NoError(t, err, s)
49 assert.Equal(t, s, back)
50 }
51 _, err := scopeFromPB(pb.Scope_SCOPE_UNSPECIFIED)
52 assert.ErrorIs(t, err, domain.ErrInvalidScope)
53 // Every scope of the contract is a scope of the domain.
54 for v := range pb.Scope_name {
55 if pb.Scope(v) == pb.Scope_SCOPE_UNSPECIFIED {
56 continue
57 }
58 _, err := scopeFromPB(pb.Scope(v))
59 assert.NoError(t, err, pb.Scope(v))
60 }
61 })
62
63 t.Run("ref kind", func(t *testing.T) {
64 t.Parallel()
65 for _, k := range []domain.RefKind{domain.RefKindHead, domain.RefKindBranch, domain.RefKindTag} {
66 got := refKindToPB(k)
67 assert.NotEqual(t, pb.RefKind_REF_KIND_UNSPECIFIED, got, k)
68 back, err := refKindFromPB(got)
69 require.NoError(t, err, k)
70 assert.Equal(t, k, back)
71 }
72 _, err := refKindFromPB(pb.RefKind_REF_KIND_UNSPECIFIED)
73 assert.ErrorIs(t, err, domain.ErrInvalidRefKind)
74 })
75
76 t.Run("entry kind", func(t *testing.T) {
77 t.Parallel()
78 seen := map[pb.EntryKind]domain.EntryKind{}
79 for _, k := range []domain.EntryKind{
80 domain.EntryFile, domain.EntryExecutable, domain.EntryDirectory, domain.EntrySymlink, domain.EntrySubmodule,
81 } {
82 got := entryKindToPB(k)
83 assert.NotEqual(t, pb.EntryKind_ENTRY_KIND_UNSPECIFIED, got, k)
84 assert.NotContains(t, seen, got, "%s and %s share a value", k, seen[got])
85 seen[got] = k
86 }
87 })
88
89 t.Run("file status", func(t *testing.T) {
90 t.Parallel()
91 seen := map[pb.FileStatus]domain.FileStatus{}
92 for _, s := range []domain.FileStatus{
93 domain.FileAdded, domain.FileDeleted, domain.FileModified, domain.FileRenamed, domain.FileCopied, domain.FileTypeChanged,
94 } {
95 got := fileStatusToPB(s)
96 assert.NotEqual(t, pb.FileStatus_FILE_STATUS_UNSPECIFIED, got, s)
97 assert.NotContains(t, seen, got, "%s and %s share a value", s, seen[got])
98 seen[got] = s
99 }
100 })
101
102 t.Run("line kind", func(t *testing.T) {
103 t.Parallel()
104 seen := map[pb.LineKind]domain.LineKind{}
105 for _, k := range []domain.LineKind{domain.LineContext, domain.LineAddition, domain.LineDeletion} {
106 got := lineKindToPB(k)
107 assert.NotEqual(t, pb.LineKind_LINE_KIND_UNSPECIFIED, got, k)
108 assert.NotContains(t, seen, got, "%s and %s share a value", k, seen[got])
109 seen[got] = k
110 }
111 })
112
113 // Every value of the contract except UNSPECIFIED is produced by some value
114 // of the domain: nothing in the contract is unreachable.
115 t.Run("the contract has no value the domain cannot produce", func(t *testing.T) {
116 t.Parallel()
117 produced := func(names map[int32]string, from func(v int32) bool) {
118 for v, n := range names {
119 if v == 0 {
120 continue
121 }
122 assert.True(t, from(v), "%s is never produced", n)
123 }
124 }
125 produced(pb.EntryKind_name, func(v int32) bool {
126 for _, k := range []domain.EntryKind{domain.EntryFile, domain.EntryExecutable, domain.EntryDirectory, domain.EntrySymlink, domain.EntrySubmodule} {
127 if entryKindToPB(k) == pb.EntryKind(v) {
128 return true
129 }
130 }
131 return false
132 })
133 produced(pb.FileStatus_name, func(v int32) bool {
134 for _, k := range []domain.FileStatus{domain.FileAdded, domain.FileDeleted, domain.FileModified, domain.FileRenamed, domain.FileCopied, domain.FileTypeChanged} {
135 if fileStatusToPB(k) == pb.FileStatus(v) {
136 return true
137 }
138 }
139 return false
140 })
141 produced(pb.LineKind_name, func(v int32) bool {
142 for _, k := range []domain.LineKind{domain.LineContext, domain.LineAddition, domain.LineDeletion} {
143 if lineKindToPB(k) == pb.LineKind(v) {
144 return true
145 }
146 }
147 return false
148 })
149 })
150}
151
152func TestMergeModeMappers(t *testing.T) {
153 t.Parallel()
154 for _, m := range domain.MergeModes() {
155 got := mergeModeToPB(m)
156 assert.NotEqual(t, pb.MergeMode_MERGE_MODE_UNSPECIFIED, got, m)
157 back, err := mergeModeFromPB(got)
158 require.NoError(t, err, m)
159 assert.Equal(t, m, back)
160 }
161 // Unspecified in a merge request is the default mode, left to the use case;
162 // in the settings it is refused.
163 m, err := mergeModeFromPB(pb.MergeMode_MERGE_MODE_UNSPECIFIED)
164 require.NoError(t, err)
165 assert.Empty(t, m)
166 _, err = mergeModesFromPB([]pb.MergeMode{pb.MergeMode_MERGE_MODE_UNSPECIFIED})
167 assert.ErrorIs(t, err, domain.ErrInvalid)
168 _, err = mergeModeFromPB(pb.MergeMode(99))
169 assert.ErrorIs(t, err, domain.ErrInvalid)
170 // Every mode of the contract is known.
171 for v := range pb.MergeMode_name {
172 if pb.MergeMode(v) != pb.MergeMode_MERGE_MODE_UNSPECIFIED {
173 _, err := mergeModeFromPB(pb.MergeMode(v))
174 assert.NoError(t, err, pb.MergeMode(v))
175 }
176 }
177}
178
179func TestMergeRequestEnumMappers(t *testing.T) {
180 t.Parallel()
181 for _, s := range []domain.MRState{domain.MRStateOpen, domain.MRStateMerged, domain.MRStateClosed} {
182 got := mergeRequestStateToPB(s)
183 assert.NotEqual(t, pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED, got, s)
184 back, err := mergeRequestStateFromPB(got)
185 require.NoError(t, err, s)
186 assert.Equal(t, s, back)
187 }
188 all, err := mergeRequestStateFromPB(pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED)
189 require.NoError(t, err)
190 assert.Empty(t, all, "unspecified means every state")
191 _, err = mergeRequestStateFromPB(99)
192 assert.ErrorIs(t, err, domain.ErrInvalid)
193
194 for _, s := range []domain.DiffSide{domain.SideOld, domain.SideNew, domain.SideNone} {
195 back, err := diffSideFromPB(diffSideToPB(s))
196 require.NoError(t, err, s)
197 assert.Equal(t, s, back)
198 }
199 assert.Equal(t, pb.DiffSide_DIFF_SIDE_UNSPECIFIED, diffSideToPB(domain.SideNone), "a general comment")
200 _, err = diffSideFromPB(99)
201 assert.ErrorIs(t, err, domain.ErrInvalid)
202}
203
204func TestCommentID(t *testing.T) {
205 t.Parallel()
206 assert.EqualValues(t, 7, commentID("7"))
207 for _, bad := range []string{"", "0", "-1", "07", "+7", "x", "7 ", "9223372036854775808"} {
208 assert.Zero(t, commentID(bad), "%q names no comment", bad)
209 }
210}
211
212func TestRepoToPBAlwaysHasSettings(t *testing.T) {
213 t.Parallel()
214 // A repository value without settings (never stored with them) reads as the defaults.
215 r := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}})
216 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH, pb.MergeMode_MERGE_MODE_MERGE_COMMIT}, r.GetSettings().GetAllowedMergeModes())
217 assert.True(t, r.GetSettings().GetFailingChecksBlock())
218
219 custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}}
220 r = repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x"), Settings: custom}})
221 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes())
222 assert.False(t, r.GetSettings().GetFailingChecksBlock())
223}
224
225func mustName(t *testing.T, s string) domain.RepoName {
226 t.Helper()
227 n, err := domain.ParseRepoName(s)
228 require.NoError(t, err)
229 return n
230}
231
232func TestUpdateParamsSettingsPaths(t *testing.T) {
233 t.Parallel()
234 msg := func(paths ...string) *pb.UpdateRepoRequest {
235 return &pb.UpdateRepoRequest{
236 UpdateMask: &fieldmaskpb.FieldMask{Paths: paths},
237 Settings: &pb.RepoSettings{
238 AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH},
239 FailingChecksBlock: true,
240 },
241 }
242 }
243 p, err := updateParams(msg("settings"))
244 require.NoError(t, err)
245 require.NotNil(t, p.AllowedMergeModes)
246 assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, *p.AllowedMergeModes)
247 require.NotNil(t, p.FailingChecksBlock)
248 assert.True(t, *p.FailingChecksBlock)
249
250 p, err = updateParams(msg("settings.allowed_merge_modes"))
251 require.NoError(t, err)
252 assert.NotNil(t, p.AllowedMergeModes)
253 assert.Nil(t, p.FailingChecksBlock, "not named: left alone")
254
255 p, err = updateParams(msg("settings.failing_checks_block"))
256 require.NoError(t, err)
257 assert.Nil(t, p.AllowedMergeModes)
258 assert.NotNil(t, p.FailingChecksBlock)
259
260 // Modes named with nothing to take them from are an empty list, which the use case refuses.
261 p, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.allowed_merge_modes"}}})
262 require.NoError(t, err)
263 require.NotNil(t, p.AllowedMergeModes)
264 assert.Empty(t, *p.AllowedMergeModes)
265
266 _, err = updateParams(msg("settings.other"))
267 assert.ErrorIs(t, err, domain.ErrInvalid)
268}
269
270func TestConnectErrorCarriesTheFieldsOfARejection(t *testing.T) {
271 t.Parallel()
272 var v domain.Violations
273 v.Add("title", "must be one line")
274 v.Add("source_branch", "does not exist")
275 err := connectError(context.Background(), fmt.Errorf("create: %w", v.Err()))
276 var ce *connect.Error
277 require.ErrorAs(t, err, &ce)
278 assert.Equal(t, connect.CodeInvalidArgument, ce.Code())
279 require.Len(t, ce.Details(), 1)
280 val, derr := ce.Details()[0].Value()
281 require.NoError(t, derr)
282 br, ok := val.(*errdetails.BadRequest)
283 require.True(t, ok)
284 require.Len(t, br.GetFieldViolations(), 2)
285 assert.Equal(t, "title", br.GetFieldViolations()[0].GetField())
286 assert.Equal(t, "must be one line", br.GetFieldViolations()[0].GetDescription())
287
288 // Other errors carry no detail, and a text error alone is invalid_argument.
289 err = connectError(context.Background(), domain.ErrNotFound)
290 require.ErrorAs(t, err, &ce)
291 assert.Empty(t, ce.Details())
292 _, perr := domain.ParsePlainText("\x07")
293 err = connectError(context.Background(), perr)
294 require.ErrorAs(t, err, &ce)
295 assert.Equal(t, connect.CodeInvalidArgument, ce.Code())
296}
297
298func TestWatchEventToPBMergeRequestEvents(t *testing.T) {
299 t.Parallel()
300 repo := mustName(t, "proj")
301 title, err := domain.ParseMRTitle("Add x")
302 require.NoError(t, err)
303 mr := domain.MergeRequest{Repo: repo, Number: 4, Title: title, Source: "feat", Target: "main", State: domain.MRStateMerged, MergeMode: domain.MergeModeSquash}
304 at := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)
305
306 for name, tc := range map[string]struct {
307 ev domain.Event
308 pick func(*pb.Event) *pb.MergeRequest
309 }{
310 "opened": {domain.MergeRequestOpened{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestOpened().GetMergeRequest() }},
311 "merged": {domain.MergeRequestMerged{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestMerged().GetMergeRequest() }},
312 "closed": {domain.MergeRequestClosed{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestClosed().GetMergeRequest() }},
313 } {
314 t.Run(name, func(t *testing.T) {
315 t.Parallel()
316 got := watchEventToPB(ports.WatchEvent{ID: "e1", Event: tc.ev})
317 require.NotNil(t, got)
318 assert.Equal(t, "e1", got.GetId())
319 assert.Equal(t, at, got.GetTime().AsTime())
320 m := tc.pick(got)
321 require.NotNil(t, m)
322 assert.Equal(t, "proj", m.GetRepo())
323 assert.EqualValues(t, 4, m.GetNumber())
324 assert.Equal(t, "Add x", m.GetTitle())
325 assert.Equal(t, pb.MergeMode_MERGE_MODE_SQUASH, m.GetMergeMode())
326 })
327 }
328
329 c := domain.Comment{ID: 9, Author: "root", Path: "a.txt", Line: 3, Side: domain.SideOld, CreatedAt: at}
330 got := watchEventToPB(ports.WatchEvent{ID: "e2", Event: domain.CommentAdded{Repo: repo, Number: 4, Comment: c, At: at}})
331 added := got.GetCommentAdded()
332 require.NotNil(t, added)
333 assert.Equal(t, "proj", added.GetRepo())
334 assert.EqualValues(t, 4, added.GetNumber())
335 assert.Equal(t, "9", added.GetComment().GetId())
336 assert.Equal(t, pb.DiffSide_DIFF_SIDE_OLD, added.GetComment().GetSide())
337 assert.EqualValues(t, 3, added.GetComment().GetLine())
338}
339
340func TestIssueMappers(t *testing.T) {
341 t.Parallel()
342 for _, m := range domain.IssuesModes() {
343 got, err := issuesModeFromPB(issuesModeToPB(m))
344 require.NoError(t, err)
345 assert.Equal(t, m, got)
346 }
347 assert.Equal(t, pb.IssuesMode_ISSUES_MODE_OFF, issuesModeToPB(""), "the empty mode is off")
348 _, err := issuesModeFromPB(pb.IssuesMode_ISSUES_MODE_UNSPECIFIED)
349 assert.ErrorIs(t, err, domain.ErrInvalid)
350
351 s, err := issueStateFromPB(pb.IssueState_ISSUE_STATE_UNSPECIFIED)
352 require.NoError(t, err)
353 assert.Empty(t, s)
354 _, err = issueStateFromPB(pb.IssueState_ISSUE_STATE_PENDING)
355 assert.ErrorIs(t, err, domain.ErrInvalid)
356
357 n, err := domain.ParseRepoName("proj")
358 require.NoError(t, err)
359 ev := watchEventToPB(ports.WatchEvent{ID: "1", Event: domain.IssueClosed{Issue: domain.Issue{Repo: n, Number: 4, State: domain.IssueStateClosed}}})
360 require.NotNil(t, ev.GetIssueClosed())
361 assert.EqualValues(t, 4, ev.GetIssueClosed().GetIssue().GetNumber())
362 assert.Equal(t, pb.IssueState_ISSUE_STATE_CLOSED, ev.GetIssueClosed().GetIssue().GetState())
363 ev = watchEventToPB(ports.WatchEvent{ID: "2", Event: domain.IssueCommented{Repo: n, Number: 4, Comment: domain.IssueComment{ID: 3}}})
364 assert.Equal(t, "3", ev.GetIssueCommented().GetComment().GetId())
365 assert.NotNil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssueOpened{}}).GetIssueOpened())
366 assert.NotNil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssueReopened{}}).GetIssueReopened())
367}
368
369func TestWatchEventToPBSkipsPendingRemoved(t *testing.T) {
370 t.Parallel()
371 assert.Nil(t, watchEventToPB(ports.WatchEvent{Event: domain.IssuePendingRemoved{}}))
372}
373
374func TestIssueStateToPBNeverPending(t *testing.T) {
375 t.Parallel()
376 assert.Equal(t, pb.IssueState_ISSUE_STATE_UNSPECIFIED, issueStateToPB(domain.IssueState("pending")))
377 assert.Equal(t, pb.IssueState_ISSUE_STATE_OPEN, issueStateToPB(domain.IssueStateOpen))
378}
379
380func TestMergeRequestToPBMergeabilityOnlyWhileOpen(t *testing.T) {
381 t.Parallel()
382 repo := mustName(t, "proj")
383 for state, want := range map[domain.MRState]bool{domain.MRStateOpen: true, domain.MRStateMerged: false, domain.MRStateClosed: false} {
384 mr := domain.MergeRequest{Repo: repo, Number: 4, State: state}
385 got := mergeRequestToPB(mr).GetMergeability()
386 if !want {
387 assert.Nil(t, got, state)
388 continue
389 }
390 require.NotNil(t, got)
391 assert.Equal(t, pb.MergeabilityState_MERGEABILITY_STATE_CHECKING, got.GetState())
392 ev := watchEventToPB(ports.WatchEvent{ID: "e", Event: domain.MergeRequestOpened{MergeRequest: mr}})
393 assert.Equal(t, pb.MergeabilityState_MERGEABILITY_STATE_CHECKING, ev.GetMergeRequestOpened().GetMergeRequest().GetMergeability().GetState())
394 }
395 for _, ev := range []domain.Event{domain.MergeRequestMerged{MergeRequest: domain.MergeRequest{Repo: repo, State: domain.MRStateMerged}}, domain.MergeRequestClosed{MergeRequest: domain.MergeRequest{Repo: repo, State: domain.MRStateClosed}}} {
396 got := watchEventToPB(ports.WatchEvent{ID: "e", Event: ev})
397 assert.Nil(t, got.GetMergeRequestMerged().GetMergeRequest().GetMergeability())
398 assert.Nil(t, got.GetMergeRequestClosed().GetMergeRequest().GetMergeability())
399 }
400}