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/app"
19 "bnema.dev/ermite/internal/domain"
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.Run("visibility", func(t *testing.T) {
27 for _, v := range []domain.Visibility{domain.VisibilityPublic, domain.VisibilityPrivate} {
28 got := visibilityToPB(v)
29 assert.NotEqual(t, pb.Visibility_VISIBILITY_UNSPECIFIED, got, v)
30 back, err := visibilityFromPB(got)
31 require.NoError(t, err, v)
32 assert.Equal(t, v, back)
33 }
34 assert.Equal(t, pb.Visibility_VISIBILITY_UNSPECIFIED, visibilityToPB(""))
35 _, err := visibilityFromPB(pb.Visibility(99))
36 assert.ErrorIs(t, err, domain.ErrInvalidVisibility)
37 })
38
39 t.Run("scope", func(t *testing.T) {
40 for _, s := range domain.AllScopes() {
41 got, ok := scopeToPB[s]
42 require.True(t, ok, "scope %s has no counterpart in the contract", s)
43 assert.NotEqual(t, pb.Scope_SCOPE_UNSPECIFIED, got, s)
44 back, err := scopeFromPB(got)
45 require.NoError(t, err, s)
46 assert.Equal(t, s, back)
47 }
48 _, err := scopeFromPB(pb.Scope_SCOPE_UNSPECIFIED)
49 assert.ErrorIs(t, err, domain.ErrInvalidScope)
50 // Every scope of the contract is a scope of the domain.
51 for v := range pb.Scope_name {
52 if pb.Scope(v) == pb.Scope_SCOPE_UNSPECIFIED {
53 continue
54 }
55 _, err := scopeFromPB(pb.Scope(v))
56 assert.NoError(t, err, pb.Scope(v))
57 }
58 })
59
60 t.Run("ref kind", func(t *testing.T) {
61 for _, k := range []domain.RefKind{domain.RefKindHead, domain.RefKindBranch, domain.RefKindTag} {
62 got := refKindToPB(k)
63 assert.NotEqual(t, pb.RefKind_REF_KIND_UNSPECIFIED, got, k)
64 back, err := refKindFromPB(got)
65 require.NoError(t, err, k)
66 assert.Equal(t, k, back)
67 }
68 _, err := refKindFromPB(pb.RefKind_REF_KIND_UNSPECIFIED)
69 assert.ErrorIs(t, err, domain.ErrInvalidRefKind)
70 })
71
72 t.Run("entry kind", func(t *testing.T) {
73 seen := map[pb.EntryKind]domain.EntryKind{}
74 for _, k := range []domain.EntryKind{
75 domain.EntryFile, domain.EntryExecutable, domain.EntryDirectory, domain.EntrySymlink, domain.EntrySubmodule,
76 } {
77 got := entryKindToPB(k)
78 assert.NotEqual(t, pb.EntryKind_ENTRY_KIND_UNSPECIFIED, got, k)
79 assert.NotContains(t, seen, got, "%s and %s share a value", k, seen[got])
80 seen[got] = k
81 }
82 })
83
84 t.Run("file status", func(t *testing.T) {
85 seen := map[pb.FileStatus]domain.FileStatus{}
86 for _, s := range []domain.FileStatus{
87 domain.FileAdded, domain.FileDeleted, domain.FileModified, domain.FileRenamed, domain.FileCopied, domain.FileTypeChanged,
88 } {
89 got := fileStatusToPB(s)
90 assert.NotEqual(t, pb.FileStatus_FILE_STATUS_UNSPECIFIED, got, s)
91 assert.NotContains(t, seen, got, "%s and %s share a value", s, seen[got])
92 seen[got] = s
93 }
94 })
95
96 t.Run("line kind", func(t *testing.T) {
97 seen := map[pb.LineKind]domain.LineKind{}
98 for _, k := range []domain.LineKind{domain.LineContext, domain.LineAddition, domain.LineDeletion} {
99 got := lineKindToPB(k)
100 assert.NotEqual(t, pb.LineKind_LINE_KIND_UNSPECIFIED, got, k)
101 assert.NotContains(t, seen, got, "%s and %s share a value", k, seen[got])
102 seen[got] = k
103 }
104 })
105
106 // Every value of the contract except UNSPECIFIED is produced by some value
107 // of the domain: nothing in the contract is unreachable.
108 t.Run("the contract has no value the domain cannot produce", func(t *testing.T) {
109 produced := func(names map[int32]string, from func(v int32) bool) {
110 for v, n := range names {
111 if v == 0 {
112 continue
113 }
114 assert.True(t, from(v), "%s is never produced", n)
115 }
116 }
117 produced(pb.EntryKind_name, func(v int32) bool {
118 for _, k := range []domain.EntryKind{domain.EntryFile, domain.EntryExecutable, domain.EntryDirectory, domain.EntrySymlink, domain.EntrySubmodule} {
119 if entryKindToPB(k) == pb.EntryKind(v) {
120 return true
121 }
122 }
123 return false
124 })
125 produced(pb.FileStatus_name, func(v int32) bool {
126 for _, k := range []domain.FileStatus{domain.FileAdded, domain.FileDeleted, domain.FileModified, domain.FileRenamed, domain.FileCopied, domain.FileTypeChanged} {
127 if fileStatusToPB(k) == pb.FileStatus(v) {
128 return true
129 }
130 }
131 return false
132 })
133 produced(pb.LineKind_name, func(v int32) bool {
134 for _, k := range []domain.LineKind{domain.LineContext, domain.LineAddition, domain.LineDeletion} {
135 if lineKindToPB(k) == pb.LineKind(v) {
136 return true
137 }
138 }
139 return false
140 })
141 })
142}
143
144func TestMergeModeMappers(t *testing.T) {
145 for _, m := range domain.MergeModes() {
146 got := mergeModeToPB(m)
147 assert.NotEqual(t, pb.MergeMode_MERGE_MODE_UNSPECIFIED, got, m)
148 back, err := mergeModeFromPB(got)
149 require.NoError(t, err, m)
150 assert.Equal(t, m, back)
151 }
152 // Unspecified in a merge request is the default mode, left to the use case;
153 // in the settings it is refused.
154 m, err := mergeModeFromPB(pb.MergeMode_MERGE_MODE_UNSPECIFIED)
155 require.NoError(t, err)
156 assert.Empty(t, m)
157 _, err = mergeModesFromPB([]pb.MergeMode{pb.MergeMode_MERGE_MODE_UNSPECIFIED})
158 assert.ErrorIs(t, err, domain.ErrInvalid)
159 _, err = mergeModeFromPB(pb.MergeMode(99))
160 assert.ErrorIs(t, err, domain.ErrInvalid)
161 // Every mode of the contract is known.
162 for v := range pb.MergeMode_name {
163 if pb.MergeMode(v) != pb.MergeMode_MERGE_MODE_UNSPECIFIED {
164 _, err := mergeModeFromPB(pb.MergeMode(v))
165 assert.NoError(t, err, pb.MergeMode(v))
166 }
167 }
168}
169
170func TestMergeRequestEnumMappers(t *testing.T) {
171 for _, s := range []domain.MRState{domain.MRStateOpen, domain.MRStateMerged, domain.MRStateClosed} {
172 got := mergeRequestStateToPB(s)
173 assert.NotEqual(t, pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED, got, s)
174 back, err := mergeRequestStateFromPB(got)
175 require.NoError(t, err, s)
176 assert.Equal(t, s, back)
177 }
178 all, err := mergeRequestStateFromPB(pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED)
179 require.NoError(t, err)
180 assert.Empty(t, all, "unspecified means every state")
181 _, err = mergeRequestStateFromPB(99)
182 assert.ErrorIs(t, err, domain.ErrInvalid)
183
184 for _, s := range []domain.DiffSide{domain.SideOld, domain.SideNew, domain.SideNone} {
185 back, err := diffSideFromPB(diffSideToPB(s))
186 require.NoError(t, err, s)
187 assert.Equal(t, s, back)
188 }
189 assert.Equal(t, pb.DiffSide_DIFF_SIDE_UNSPECIFIED, diffSideToPB(domain.SideNone), "a general comment")
190 _, err = diffSideFromPB(99)
191 assert.ErrorIs(t, err, domain.ErrInvalid)
192}
193
194func TestCommentID(t *testing.T) {
195 assert.EqualValues(t, 7, commentID("7"))
196 for _, bad := range []string{"", "0", "-1", "07", "+7", "x", "7 ", "9223372036854775808"} {
197 assert.Zero(t, commentID(bad), "%q names no comment", bad)
198 }
199}
200
201func TestRepoToPBAlwaysHasSettings(t *testing.T) {
202 // A repository value without settings (never stored with them) reads as the defaults.
203 r := repoToPB(app.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}})
204 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes())
205 assert.True(t, r.GetSettings().GetFailingChecksBlock())
206
207 custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}}
208 r = repoToPB(app.RepoView{Repo: domain.Repo{Name: mustName(t, "x"), Settings: custom}})
209 assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes())
210 assert.False(t, r.GetSettings().GetFailingChecksBlock())
211}
212
213func mustName(t *testing.T, s string) domain.RepoName {
214 t.Helper()
215 n, err := domain.ParseRepoName(s)
216 require.NoError(t, err)
217 return n
218}
219
220func TestUpdateParamsSettingsPaths(t *testing.T) {
221 msg := func(paths ...string) *pb.UpdateRepoRequest {
222 return &pb.UpdateRepoRequest{
223 UpdateMask: &fieldmaskpb.FieldMask{Paths: paths},
224 Settings: &pb.RepoSettings{
225 AllowedMergeModes: []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH},
226 FailingChecksBlock: true,
227 },
228 }
229 }
230 p, err := updateParams(msg("settings"))
231 require.NoError(t, err)
232 require.NotNil(t, p.AllowedMergeModes)
233 assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, *p.AllowedMergeModes)
234 require.NotNil(t, p.FailingChecksBlock)
235 assert.True(t, *p.FailingChecksBlock)
236
237 p, err = updateParams(msg("settings.allowed_merge_modes"))
238 require.NoError(t, err)
239 assert.NotNil(t, p.AllowedMergeModes)
240 assert.Nil(t, p.FailingChecksBlock, "not named: left alone")
241
242 p, err = updateParams(msg("settings.failing_checks_block"))
243 require.NoError(t, err)
244 assert.Nil(t, p.AllowedMergeModes)
245 assert.NotNil(t, p.FailingChecksBlock)
246
247 // Modes named with nothing to take them from are an empty list, which the use case refuses.
248 p, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.allowed_merge_modes"}}})
249 require.NoError(t, err)
250 require.NotNil(t, p.AllowedMergeModes)
251 assert.Empty(t, *p.AllowedMergeModes)
252
253 _, err = updateParams(msg("settings.other"))
254 assert.ErrorIs(t, err, domain.ErrInvalid)
255}
256
257func TestConnectErrorCarriesTheFieldsOfARejection(t *testing.T) {
258 var v domain.Violations
259 v.Add("title", "must be one line")
260 v.Add("source_branch", "does not exist")
261 err := connectError(context.Background(), fmt.Errorf("create: %w", v.Err()))
262 var ce *connect.Error
263 require.ErrorAs(t, err, &ce)
264 assert.Equal(t, connect.CodeInvalidArgument, ce.Code())
265 require.Len(t, ce.Details(), 1)
266 val, derr := ce.Details()[0].Value()
267 require.NoError(t, derr)
268 br, ok := val.(*errdetails.BadRequest)
269 require.True(t, ok)
270 require.Len(t, br.GetFieldViolations(), 2)
271 assert.Equal(t, "title", br.GetFieldViolations()[0].GetField())
272 assert.Equal(t, "must be one line", br.GetFieldViolations()[0].GetDescription())
273
274 // Other errors carry no detail, and a text error alone is invalid_argument.
275 err = connectError(context.Background(), domain.ErrNotFound)
276 require.ErrorAs(t, err, &ce)
277 assert.Empty(t, ce.Details())
278 _, perr := domain.ParsePlainText("\x07")
279 err = connectError(context.Background(), perr)
280 require.ErrorAs(t, err, &ce)
281 assert.Equal(t, connect.CodeInvalidArgument, ce.Code())
282}
283
284func TestWatchEventToPBMergeRequestEvents(t *testing.T) {
285 repo := mustName(t, "proj")
286 title, err := domain.ParseMRTitle("Add x")
287 require.NoError(t, err)
288 mr := domain.MergeRequest{Repo: repo, Number: 4, Title: title, Source: "feat", Target: "main", State: domain.MRStateMerged, MergeMode: domain.MergeModeSquash}
289 at := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)
290
291 for name, tc := range map[string]struct {
292 ev domain.Event
293 pick func(*pb.Event) *pb.MergeRequest
294 }{
295 "opened": {domain.MergeRequestOpened{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestOpened().GetMergeRequest() }},
296 "merged": {domain.MergeRequestMerged{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestMerged().GetMergeRequest() }},
297 "closed": {domain.MergeRequestClosed{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestClosed().GetMergeRequest() }},
298 } {
299 t.Run(name, func(t *testing.T) {
300 got := watchEventToPB(app.WatchEvent{ID: "e1", Event: tc.ev})
301 require.NotNil(t, got)
302 assert.Equal(t, "e1", got.GetId())
303 assert.Equal(t, at, got.GetTime().AsTime())
304 m := tc.pick(got)
305 require.NotNil(t, m)
306 assert.Equal(t, "proj", m.GetRepo())
307 assert.EqualValues(t, 4, m.GetNumber())
308 assert.Equal(t, "Add x", m.GetTitle())
309 assert.Equal(t, pb.MergeMode_MERGE_MODE_SQUASH, m.GetMergeMode())
310 })
311 }
312
313 c := domain.Comment{ID: 9, Author: "root", Path: "a.txt", Line: 3, Side: domain.SideOld, CreatedAt: at}
314 got := watchEventToPB(app.WatchEvent{ID: "e2", Event: domain.CommentAdded{Repo: repo, Number: 4, Comment: c, At: at}})
315 added := got.GetCommentAdded()
316 require.NotNil(t, added)
317 assert.Equal(t, "proj", added.GetRepo())
318 assert.EqualValues(t, 4, added.GetNumber())
319 assert.Equal(t, "9", added.GetComment().GetId())
320 assert.Equal(t, pb.DiffSide_DIFF_SIDE_OLD, added.GetComment().GetSide())
321 assert.EqualValues(t, 3, added.GetComment().GetLine())
322}