1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "context"
7 "errors"
8 "testing"
9 "time"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"
14
15 "bnema.dev/ermite/internal/domain"
16 "bnema.dev/ermite/internal/ports"
17 "bnema.dev/ermite/internal/ports/mocks"
18 "bnema.dev/ermite/internal/usecase"
19)
20
21type mrUnit struct {
22 meta *mocks.RepoMetaStore
23 store *mocks.MergeRequestStore
24 repos *mocks.GitRepos
25 git *mocks.MergeGit
26 mrs *usecase.MergeRequests
27 repo domain.Repo
28}
29
30func newMRUnit(t *testing.T) *mrUnit {
31 t.Helper()
32 u := &mrUnit{
33 meta: mocks.NewRepoMetaStore(t),
34 store: mocks.NewMergeRequestStore(t),
35 repos: mocks.NewGitRepos(t),
36 git: mocks.NewMergeGit(t),
37 repo: domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPrivate, Settings: domain.DefaultRepoSettings()},
38 }
39 clock := mocks.NewClock(t)
40 clock.EXPECT().Now().Return(now).Maybe()
41 sched := mocks.NewMaintenanceScheduler(t)
42 sched.EXPECT().Schedule(mock.Anything, mock.Anything).Return().Maybe()
43 u.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
44 Meta: u.meta, Store: u.store, Repos: u.repos, Git: u.git,
45 Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: sched,
46 })
47 return u
48}
49
50// GO-004: a failure to read Git is an error for the log, not a field violation
51// that would show its text to the caller.
52func TestCreateReturnsGitFailuresAsErrors(t *testing.T) {
53 t.Parallel()
54 boom := errors.New("fatal: /srv/ermite/repos/proj.git: permission denied")
55 params := ports.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "main"}
56
57 t.Run("reading a branch", func(t *testing.T) {
58 t.Parallel()
59 u := newMRUnit(t)
60 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
61 u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "feat").Return(domain.ObjectID{}, boom)
62 _, err := u.mrs.Create(context.Background(), domain.Root(), u.repo.Name, params)
63 require.ErrorIs(t, err, boom)
64 var fe *domain.InvalidFieldsError
65 assert.False(t, errors.As(err, &fe), "not a field violation: %v", err)
66 assert.NotErrorIs(t, err, domain.ErrInvalid)
67 })
68
69 t.Run("reading the default branch", func(t *testing.T) {
70 t.Parallel()
71 u := newMRUnit(t)
72 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
73 u.repos.EXPECT().DefaultBranch(mock.Anything, u.repo.Name).Return("", false, boom)
74 _, err := u.mrs.Create(context.Background(), domain.Root(), u.repo.Name, ports.CreateMergeRequestParams{Title: "t", Source: "feat"})
75 require.ErrorIs(t, err, boom)
76 var fe *domain.InvalidFieldsError
77 assert.False(t, errors.As(err, &fe), "not a field violation: %v", err)
78 })
79
80 t.Run("a branch that does not exist stays a violation", func(t *testing.T) {
81 t.Parallel()
82 u := newMRUnit(t)
83 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
84 u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "feat").Return(domain.ObjectID{}, domain.ErrNotFound)
85 u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "main").Return(someID(t), nil)
86 _, err := u.mrs.Create(context.Background(), domain.Root(), u.repo.Name, params)
87 assert.Contains(t, fieldsOf(t, err), "source_branch")
88 })
89}
90
91func someID(t *testing.T) domain.ObjectID {
92 t.Helper()
93 return oidFrom(t, oidA)
94}
95
96// GO-003: the settings are read again under the merge lock; a failure of that
97// read is returned, and a repository deleted meanwhile is not found.
98func TestMergeReturnsTheFailureToReadTheSettings(t *testing.T) {
99 t.Parallel()
100 boom := errors.New("kv: closed")
101 for label, tc := range map[string]struct{ err, want error }{
102 "read failure": {boom, boom},
103 "deleted meanwhile": {domain.ErrNotFound, domain.ErrNotFound},
104 } {
105 t.Run(label, func(t *testing.T) {
106 t.Parallel()
107 u := newMRUnit(t)
108 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Once()
109 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(domain.Repo{}, tc.err).Once()
110 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{
111 Repo: u.repo.Name, Number: 1, State: domain.MRStateOpen, Source: "feat", Target: "main",
112 }, nil)
113 _, err := u.mrs.Merge(context.Background(), domain.Root(), u.repo.Name, 1, ports.MergeParams{HeadID: someID(t)})
114 require.ErrorIs(t, err, tc.want)
115 if tc.want == boom {
116 assert.NotErrorIs(t, err, domain.ErrNotFound)
117 }
118 })
119 }
120}
121
122func TestNoteMutationRetries(t *testing.T) {
123 for _, action := range []string{"edit", "delete", "resolve"} {
124 t.Run(action, func(t *testing.T) {
125 u := newMRUnit(t)
126 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
127 mr := domain.MergeRequest{Repo: u.repo.Name, Number: 1, State: domain.MRStateOpen}
128 text, err := domain.ParsePlainText("before")
129 require.NoError(t, err)
130 c := domain.Comment{ID: 1, Author: "root", Text: text}
131 freshMR := mr
132 freshMR.Version = 2
133 fresh := c
134 fresh.Version = 3
135 fresh.CreatedAt = now.Add(-time.Hour)
136 if action != "resolve" {
137 fresh.Resolved = true
138 }
139 want := fresh
140 switch action {
141 case "edit":
142 want.Text, err = domain.ParsePlainText("after")
143 require.NoError(t, err)
144 want.EditedAt = now.UTC()
145 case "delete":
146 want.Text, err = domain.ParsePlainText("")
147 require.NoError(t, err)
148 want.Deleted = true
149 want.EditedAt = now.UTC()
150 case "resolve":
151 want.Resolved = true
152 }
153 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(mr, nil).Once()
154 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(freshMR, nil).Once()
155 u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(c, nil).Once()
156 u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(fresh, nil).Once()
157 u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
158 u.store.EXPECT().SaveComment(mock.Anything, mock.MatchedBy(func(saved domain.MergeRequest) bool {
159 return saved.Version == freshMR.Version
160 }), mock.MatchedBy(func(saved domain.Comment) bool { return saved == want })).Return(nil).Once()
161 bus := mocks.NewEventBus(t)
162 bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(e domain.Event) bool {
163 switch event := e.(type) {
164 case domain.CommentEdited:
165 return action == "edit" && event.Comment == want
166 case domain.CommentDeleted:
167 return action == "delete" && event.Comment == want
168 case domain.CommentResolved:
169 return action == "resolve" && event.Comment == want
170 }
171 return false
172 })).Return().Once()
173 clock := mocks.NewClock(t)
174 clock.EXPECT().Now().Return(now)
175 svc := usecase.NewMergeRequests(usecase.MergeRequestsConfig{Meta: u.meta, Store: u.store, Bus: bus, Clock: clock})
176 var got domain.Comment
177 switch action {
178 case "edit":
179 got, err = svc.EditComment(context.Background(), domain.Root(), u.repo.Name, 1, 1, "after")
180 case "delete":
181 got, err = svc.DeleteComment(context.Background(), domain.Root(), u.repo.Name, 1, 1)
182 case "resolve":
183 got, err = svc.ResolveComment(context.Background(), domain.Root(), u.repo.Name, 1, 1, true)
184 }
185 require.NoError(t, err)
186 assert.Equal(t, want, got)
187 })
188 }
189}
190
191// A reply whose root is deleted while the save is retried is rejected.
192func TestAddNoteRechecksReplyRootOnConflict(t *testing.T) {
193 u := newMRUnit(t)
194 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
195 u.git.EXPECT().BranchHead(mock.Anything, mock.Anything, mock.Anything).Return(domain.ObjectID{}, domain.ErrNotFound)
196 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{State: domain.MRStateOpen}, nil).Twice()
197 u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(domain.Comment{ID: 1}, nil).Once()
198 u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(domain.Comment{ID: 1, Deleted: true}, nil).Once()
199 u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
200 _, err := u.mrs.AddComment(context.Background(), domain.Root(), u.repo.Name, 1, ports.AddCommentParams{ReplyTo: 1, Text: "x"})
201 require.ErrorIs(t, err, domain.ErrInvalid)
202}
203
204func TestAddNoteValidation(t *testing.T) {
205 for _, tc := range []struct {
206 name string
207 params ports.AddCommentParams
208 target domain.Comment
209 missing bool
210 }{
211 {name: "range without path", params: ports.AddCommentParams{StartLine: 1, Text: "x"}},
212 {name: "equal ends", params: ports.AddCommentParams{Path: "a", StartLine: 3, Line: 3, Side: domain.SideNew, Text: "x"}},
213 {name: "reversed ends", params: ports.AddCommentParams{Path: "a", StartLine: 4, Line: 3, Side: domain.SideNew, Text: "x"}},
214 {name: "reply anchor", params: ports.AddCommentParams{ReplyTo: 1, Path: "a", Line: 1, Side: domain.SideNew, Text: "x"}},
215 {name: "reply verdict", params: ports.AddCommentParams{ReplyTo: 1, Verdict: domain.VerdictApprove}},
216 {name: "reply to reply", params: ports.AddCommentParams{ReplyTo: 1, Text: "x"}, target: domain.Comment{ReplyTo: 2}},
217 {name: "deleted root", params: ports.AddCommentParams{ReplyTo: 1, Text: "x"}, target: domain.Comment{Deleted: true}},
218 {name: "missing root", params: ports.AddCommentParams{ReplyTo: 1, Text: "x"}, missing: true},
219 } {
220 t.Run(tc.name, func(t *testing.T) {
221 u := newMRUnit(t)
222 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
223 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{State: domain.MRStateOpen}, nil)
224 if tc.name == "reply to reply" || tc.name == "deleted root" || tc.missing {
225 var err error
226 if tc.missing {
227 err = domain.ErrNotFound
228 }
229 u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(tc.target, err)
230 }
231 _, err := u.mrs.AddComment(context.Background(), domain.Root(), u.repo.Name, 1, tc.params)
232 require.ErrorIs(t, err, domain.ErrInvalid)
233 })
234 }
235}
236
237func TestCompare(t *testing.T) {
238 for _, tc := range []struct {
239 name string
240 ancestor, more, zero bool
241 ancestorErr, rangeErr error
242 }{
243 {name: "linear", ancestor: true}, {name: "bounded", ancestor: true, more: true},
244 {name: "unrelated histories"}, {name: "zero target forwards ancestry error", zero: true, ancestorErr: domain.ErrInvalid},
245 {name: "ancestor error", ancestorErr: domain.ErrInvalid},
246 {name: "range error", ancestor: true, rangeErr: domain.ErrInvalid},
247 } {
248 t.Run(tc.name, func(t *testing.T) {
249 u := newMRUnit(t)
250 old, tip := someID(t), oidFrom(t, "2222222222222222222222222222222222222222")
251 if tc.zero {
252 old = domain.ObjectID{}
253 }
254 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
255 u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, old, tip).Return(tc.ancestor, tc.ancestorErr)
256 commits := []domain.Commit{{ID: tip}}
257 if tc.ancestorErr == nil {
258 u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, old, tip, ports.MaxCompareCommits).Return(commits, tc.more, tc.rangeErr)
259 }
260 got, err := u.mrs.Compare(context.Background(), domain.Root(), u.repo.Name, old, tip)
261 if tc.ancestorErr != nil {
262 require.ErrorIs(t, err, tc.ancestorErr)
263 return
264 }
265 if tc.rangeErr != nil {
266 require.ErrorIs(t, err, tc.rangeErr)
267 return
268 }
269 require.NoError(t, err)
270 assert.Equal(t, ports.CompareResult{Ahead: commits, Behind: !tc.ancestor, Exact: !tc.more}, got)
271 })
272 }
273}
274
275func TestPushSummary(t *testing.T) {
276 for _, tc := range []struct {
277 name string
278 ancestor, more bool
279 ancestorErr, rangeErr error
280 }{
281 {name: "linear", ancestor: true}, {name: "bounded", ancestor: true, more: true},
282 {name: "unrelated histories"}, {name: "ancestor error", ancestorErr: domain.ErrInvalid},
283 {name: "range error", ancestor: true, rangeErr: domain.ErrInvalid},
284 } {
285 t.Run(tc.name, func(t *testing.T) {
286 u := newMRUnit(t)
287 old, tip := someID(t), oidFrom(t, "2222222222222222222222222222222222222222")
288 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
289 u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, old, tip).Return(tc.ancestor, tc.ancestorErr)
290 if tc.ancestor && tc.ancestorErr == nil {
291 u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, old, tip, ports.MaxPushSummaryCommits).Return([]domain.Commit{{ID: tip}}, tc.more, tc.rangeErr)
292 }
293 got, err := u.mrs.PushSummary(context.Background(), domain.Root(), u.repo.Name, old, tip)
294 if tc.ancestorErr != nil {
295 require.ErrorIs(t, err, tc.ancestorErr)
296 return
297 }
298 if tc.rangeErr != nil {
299 require.ErrorIs(t, err, tc.rangeErr)
300 return
301 }
302 require.NoError(t, err)
303 want := ports.PushSummary{Rewritten: !tc.ancestor}
304 if tc.ancestor {
305 want.Count, want.More = 1, tc.more
306 }
307 assert.Equal(t, want, got)
308 })
309 }
310}
311
312func TestReadTrackingSharedPosition(t *testing.T) {
313 for _, tc := range []struct {
314 name string
315 principal domain.Principal
316 readIndex int
317 }{
318 {"owner unread", domain.Owner(), 1}, {"owner already read", domain.Owner(), 0},
319 {"owner rewritten", domain.Owner(), -1}, {"anonymous", domain.Anonymous(), 1}, {"root token", domain.Root(), 1},
320 } {
321 t.Run(tc.name, func(t *testing.T) {
322 u := newMRUnit(t)
323 u.repo.Visibility = domain.VisibilityPublic
324 head := someID(t)
325 old := oidFrom(t, "2222222222222222222222222222222222222222")
326 read := old
327 if tc.readIndex == 0 {
328 read = head
329 }
330 mr := domain.MergeRequest{Repo: u.repo.Name, Number: 1, State: domain.MRStateClosed, HeadID: head}
331 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
332 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(mr, nil)
333 u.store.EXPECT().ListComments(mock.Anything, u.repo.Name, int64(1)).Return(nil, nil)
334 u.store.EXPECT().GetRead(mock.Anything, u.repo.Name, int64(1)).Return(read, nil)
335 u.git.EXPECT().HasCommit(mock.Anything, u.repo.Name, head).Return(true, nil)
336 commits := []domain.Commit{{ID: head}}
337 if tc.readIndex >= 0 {
338 commits = append(commits, domain.Commit{ID: old})
339 }
340 u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, domain.ObjectID{}, head, usecase.MaxMergeRequestCommits).Return(commits, false, nil)
341 detail, err := u.mrs.Get(context.Background(), tc.principal, u.repo.Name, 1, ports.GetMergeRequestOptions{})
342 require.NoError(t, err)
343 expected := 0
344 assert.Equal(t, read, detail.LastReadID)
345 assert.Equal(t, read != head, detail.HeadUnread)
346 if tc.readIndex > 0 {
347 expected = tc.readIndex
348 }
349 assert.Equal(t, expected, detail.NewCommits)
350 })
351 }
352}
353
354func TestHistoryAuthorizationAndEqualTips(t *testing.T) {
355 for _, allowed := range []bool{false, true} {
356 t.Run(map[bool]string{false: "private", true: "equal"}[allowed], func(t *testing.T) {
357 u := newMRUnit(t)
358 if allowed {
359 u.repo.Visibility = domain.VisibilityPublic
360 }
361 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Twice()
362 id := someID(t)
363 comparison, err := u.mrs.Compare(context.Background(), domain.Anonymous(), u.repo.Name, id, id)
364 if allowed {
365 require.NoError(t, err)
366 assert.True(t, comparison.Exact)
367 } else {
368 require.ErrorIs(t, err, domain.ErrNotFound)
369 }
370 summary, err := u.mrs.PushSummary(context.Background(), domain.Anonymous(), u.repo.Name, id, id)
371 if allowed {
372 require.NoError(t, err)
373 assert.Zero(t, summary)
374 } else {
375 require.ErrorIs(t, err, domain.ErrNotFound)
376 }
377 })
378 }
379}
380
381func TestMergeRequestIssueDetails(t *testing.T) {
382 for _, mode := range []domain.IssuesMode{domain.IssuesOwner, domain.IssuesOff} {
383 t.Run(string(mode), func(t *testing.T) {
384 u := newMRUnit(t)
385 u.repo.Settings.Issues = mode
386 issues, links := mocks.NewIssueStore(t), mocks.NewIssueLinkStore(t)
387 mrs := usecase.NewMergeRequests(usecase.MergeRequestsConfig{Meta: u.meta, Store: u.store, Git: u.git, Issues: issues, IssueLinks: links})
388 title, err := domain.ParseMRTitle("Fix")
389 require.NoError(t, err)
390 mr := domain.MergeRequest{Repo: u.repo.Name, Number: 2, State: domain.MRStateClosed, Title: title}
391 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
392 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(2)).Return(mr, nil)
393 u.store.EXPECT().ListComments(mock.Anything, u.repo.Name, int64(2)).Return(nil, nil)
394 u.store.EXPECT().GetRead(mock.Anything, u.repo.Name, int64(2)).Return(domain.ObjectID{}, nil)
395 event := domain.IssueEvent{Issue: 1, MergeRequest: 2, Kind: domain.IssueEventResolved}
396 if mode == domain.IssuesOwner {
397 links.EXPECT().LinksOfMergeRequest(mock.Anything, u.repo.Name, int64(2)).Return([]domain.IssueLink{{Issue: 1, Closes: true}}, nil)
398 issues.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{Number: 1, State: domain.IssueStateResolved}, nil)
399 links.EXPECT().EventsOfMergeRequest(mock.Anything, u.repo.Name, int64(2)).Return([]domain.IssueEvent{event}, nil)
400 }
401 d, err := mrs.Get(context.Background(), domain.Root(), u.repo.Name, 2, ports.GetMergeRequestOptions{})
402 require.NoError(t, err)
403 if mode == domain.IssuesOff {
404 assert.Empty(t, d.Issues)
405 assert.Empty(t, d.IssueEvents)
406 } else {
407 require.Len(t, d.Issues, 1)
408 assert.Equal(t, domain.IssueStateResolved, d.Issues[0].State)
409 assert.True(t, d.Issues[0].Closes)
410 assert.Equal(t, []ports.IssueEventDetail{{Event: event, MergeRequestTitle: title}}, d.IssueEvents)
411 }
412 })
413 }
414}