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}