1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "context"
7 "testing"
8
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"
12
13 "bnema.dev/ermite/internal/domain"
14 "bnema.dev/ermite/internal/ports"
15 "bnema.dev/ermite/internal/ports/mocks"
16 "bnema.dev/ermite/internal/usecase"
17)
18
19type issueUnit struct {
20 meta *mocks.RepoMetaStore
21 store *mocks.IssueStore
22 mrs *mocks.MergeRequestStore
23 bus *mocks.EventBus
24 issues *usecase.Issues
25 repo domain.Repo
26}
27
28func newIssueUnit(t *testing.T, vis domain.Visibility, mode domain.IssuesMode) *issueUnit {
29 t.Helper()
30 settings := domain.DefaultRepoSettings()
31 settings.Issues = mode
32 u := &issueUnit{
33 meta: mocks.NewRepoMetaStore(t),
34 store: mocks.NewIssueStore(t),
35 mrs: mocks.NewMergeRequestStore(t),
36 bus: mocks.NewEventBus(t),
37 repo: domain.Repo{Name: name(t, "proj"), Visibility: vis, Settings: settings},
38 }
39 clock := mocks.NewClock(t)
40 clock.EXPECT().Now().Return(now).Maybe()
41 u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Maybe()
42 expectRepoMutation(t, u.meta, u.repo, mock.MatchedBy(func(r domain.Repo) bool { return r.ActiveAt.Equal(now) && r.PushedAt.IsZero() })).Maybe()
43 u.issues = usecase.NewIssues(usecase.IssuesConfig{Meta: u.meta, Store: u.store, MergeRequests: u.mrs, Bus: u.bus, Clock: clock})
44 return u
45}
46
47func issuePrincipal(scopes ...domain.Scope) domain.Principal {
48 return domain.TokenPrincipal(domain.Token{ID: "t", Label: "agent-1", Scopes: scopes, BranchPrefixes: []string{"agent/"}})
49}
50
51func TestIssuesOffHidesEverything(t *testing.T) {
52 t.Parallel()
53 for _, mode := range []domain.IssuesMode{domain.IssuesOff, ""} {
54 u := newIssueUnit(t, domain.VisibilityPublic, mode)
55 ctx, n := context.Background(), u.repo.Name
56 _, err := u.issues.Create(ctx, domain.Root(), n, ports.CreateIssueParams{Title: "t"})
57 assert.ErrorIs(t, err, domain.ErrNotFound)
58 _, err = u.issues.List(ctx, domain.Root(), n, ports.ListIssuesParams{})
59 assert.ErrorIs(t, err, domain.ErrNotFound)
60 _, err = u.issues.Get(ctx, domain.Owner(), n, 1)
61 assert.ErrorIs(t, err, domain.ErrNotFound)
62 _, err = u.issues.AddComment(ctx, domain.Root(), n, 1, "x")
63 assert.ErrorIs(t, err, domain.ErrNotFound)
64 _, err = u.issues.Close(ctx, domain.Root(), n, 1)
65 assert.ErrorIs(t, err, domain.ErrNotFound)
66 _, err = u.issues.Reopen(ctx, domain.Root(), n, 1)
67 assert.ErrorIs(t, err, domain.ErrNotFound)
68 u.mrs.EXPECT().Get(mock.Anything, n, int64(1)).Return(domain.MergeRequest{Number: 1}, nil)
69 kind, err := u.issues.Kind(ctx, domain.Root(), n, 1)
70 require.NoError(t, err)
71 assert.Equal(t, domain.NumberMergeRequest, kind)
72 }
73}
74
75func TestIssueAccessRules(t *testing.T) {
76 t.Parallel()
77 ctx := context.Background()
78 t.Run("private repository hides reads", func(t *testing.T) {
79 t.Parallel()
80 u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
81 _, err := u.issues.List(ctx, domain.Anonymous(), u.repo.Name, ports.ListIssuesParams{})
82 assert.ErrorIs(t, err, domain.ErrNotFound)
83 _, err = u.issues.Get(ctx, issuePrincipal(domain.ScopeIssueWrite), u.repo.Name, 1)
84 assert.ErrorIs(t, err, domain.ErrNotFound, "issue:write does not read")
85 })
86 t.Run("writes", func(t *testing.T) {
87 t.Parallel()
88 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
89 p := ports.CreateIssueParams{Title: "t"}
90 _, err := u.issues.Create(ctx, domain.Anonymous(), u.repo.Name, p)
91 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
92 _, err = u.issues.Create(ctx, issuePrincipal(domain.ScopeRepoRead), u.repo.Name, p)
93 assert.ErrorIs(t, err, domain.ErrForbidden)
94 })
95 t.Run("private write without the scope", func(t *testing.T) {
96 t.Parallel()
97 u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
98 _, err := u.issues.Create(ctx, issuePrincipal(domain.ScopeRepoWrite), u.repo.Name, ports.CreateIssueParams{Title: "t"})
99 assert.ErrorIs(t, err, domain.ErrNotFound)
100 })
101}
102
103func TestIssueCreateAndTransitions(t *testing.T) {
104 t.Parallel()
105 ctx := context.Background()
106 p := issuePrincipal(domain.ScopeIssueWrite) // branch-limited: issues write no ref
107
108 t.Run("create", func(t *testing.T) {
109 t.Parallel()
110 u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
111 u.store.EXPECT().Create(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool {
112 return i.Repo == u.repo.Name && i.Title.String() == "Crash" && i.Body.String() == "why" &&
113 i.State == domain.IssueStateOpen && i.Author == "agent-1" && i.CreatedAt.Equal(now)
114 })).RunAndReturn(func(_ context.Context, i domain.Issue) (domain.Issue, error) { i.Number = 7; return i, nil })
115 u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
116 o, ok := ev.(domain.IssueOpened)
117 return ok && o.Issue.Number == 7 && o.Visibility == domain.VisibilityPrivate
118 })).Return()
119 got, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: " Crash ", Body: "why"})
120 require.NoError(t, err)
121 assert.EqualValues(t, 7, got.Number)
122 u.meta.AssertCalled(t, "Mutate", mock.Anything, u.repo.Name, mock.Anything)
123 })
124
125 t.Run("invalid text stores nothing", func(t *testing.T) {
126 t.Parallel()
127 u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
128 _, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: ""})
129 var fe *domain.InvalidFieldsError
130 require.ErrorAs(t, err, &fe)
131 })
132
133 open := domain.Issue{Repo: name(t, "proj"), Number: 3, State: domain.IssueStateOpen, Version: 1}
134
135 t.Run("close with every update conflicting is an error and publishes nothing", func(t *testing.T) {
136 t.Parallel()
137 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
138 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(open, nil)
139 u.store.EXPECT().Update(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict)
140 _, err := u.issues.Close(ctx, p, u.repo.Name, 3)
141 assert.ErrorIs(t, err, domain.ErrConflict)
142 })
143
144 t.Run("close retries a conflict then publishes", func(t *testing.T) {
145 t.Parallel()
146 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
147 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(open, nil).Twice()
148 u.store.EXPECT().Update(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
149 u.store.EXPECT().Update(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool { return i.State == domain.IssueStateClosed }), mock.Anything).Return(nil).Once()
150 u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
151 c, ok := ev.(domain.IssueClosed)
152 return ok && c.Issue.State == domain.IssueStateClosed
153 })).Return()
154 got, err := u.issues.Close(ctx, p, u.repo.Name, 3)
155 require.NoError(t, err)
156 assert.Equal(t, domain.IssueStateClosed, got.State)
157 })
158
159 t.Run("close of a closed issue", func(t *testing.T) {
160 t.Parallel()
161 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
162 closed := open
163 closed.State = domain.IssueStateClosed
164 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(closed, nil)
165 _, err := u.issues.Close(ctx, p, u.repo.Name, 3)
166 assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
167 })
168
169 t.Run("reopen", func(t *testing.T) {
170 t.Parallel()
171 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
172 closed := open
173 closed.State = domain.IssueStateClosed
174 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(closed, nil)
175 u.store.EXPECT().Update(mock.Anything, mock.Anything, mock.Anything).Return(nil)
176 u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool { _, ok := ev.(domain.IssueReopened); return ok })).Return()
177 got, err := u.issues.Reopen(ctx, p, u.repo.Name, 3)
178 require.NoError(t, err)
179 assert.Equal(t, domain.IssueStateOpen, got.State)
180 })
181
182 t.Run("reopen of an open issue", func(t *testing.T) {
183 t.Parallel()
184 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
185 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(open, nil)
186 _, err := u.issues.Reopen(ctx, p, u.repo.Name, 3)
187 assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
188 })
189}
190
191func TestIssueAddComment(t *testing.T) {
192 t.Parallel()
193 ctx := context.Background()
194 p := issuePrincipal(domain.ScopeIssueWrite)
195 issue := domain.Issue{Repo: name(t, "proj"), Number: 3, State: domain.IssueStateClosed, CommentSeq: 4, Version: 1}
196
197 t.Run("every save conflicting is an error and publishes nothing", func(t *testing.T) {
198 t.Parallel()
199 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
200 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(issue, nil)
201 u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict)
202 _, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, "hi") // the bus mock fails on any Publish
203 assert.ErrorIs(t, err, domain.ErrConflict)
204 })
205
206 t.Run("on a closed issue, with a retry", func(t *testing.T) {
207 t.Parallel()
208 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
209 fresh := issue
210 fresh.CommentSeq = 5
211 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(issue, nil).Once()
212 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(fresh, nil).Once()
213 u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
214 u.store.EXPECT().SaveComment(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool { return i.CommentSeq == 6 }),
215 mock.MatchedBy(func(c domain.IssueComment) bool { return c.ID == 6 && c.Author == "agent-1" && c.Text.String() == "hi" })).Return(nil).Once()
216 u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
217 c, ok := ev.(domain.IssueCommented)
218 return ok && c.Number == 3 && c.Comment.ID == 6
219 })).Return()
220 c, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, "hi")
221 require.NoError(t, err)
222 assert.EqualValues(t, 6, c.ID)
223 })
224
225 t.Run("blank", func(t *testing.T) {
226 t.Parallel()
227 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
228 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(issue, nil)
229 _, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, " \n ")
230 var fe *domain.InvalidFieldsError
231 require.ErrorAs(t, err, &fe)
232 })
233
234 t.Run("full", func(t *testing.T) {
235 t.Parallel()
236 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
237 full := issue
238 full.CommentSeq = usecase.MaxCommentsPerIssue
239 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(full, nil)
240 _, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, "x")
241 assert.ErrorIs(t, err, domain.ErrResourceExhausted)
242 })
243}
244
245func TestIssueKind(t *testing.T) {
246 t.Parallel()
247 ctx := context.Background()
248 t.Run("issue", func(t *testing.T) {
249 t.Parallel()
250 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
251 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{Number: 1}, nil)
252 k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
253 require.NoError(t, err)
254 assert.Equal(t, domain.NumberIssue, k)
255 })
256 t.Run("merge request", func(t *testing.T) {
257 t.Parallel()
258 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
259 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{}, domain.ErrNotFound)
260 u.mrs.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{Number: 1}, nil)
261 k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
262 require.NoError(t, err)
263 assert.Equal(t, domain.NumberMergeRequest, k)
264 })
265 t.Run("none", func(t *testing.T) {
266 t.Parallel()
267 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
268 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{}, domain.ErrNotFound)
269 u.mrs.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{}, domain.ErrNotFound)
270 k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
271 require.NoError(t, err)
272 assert.Equal(t, domain.NumberNone, k)
273 })
274 t.Run("private repository", func(t *testing.T) {
275 t.Parallel()
276 u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
277 _, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
278 assert.ErrorIs(t, err, domain.ErrNotFound)
279 })
280}
281
282func TestIssueTransitionMatrix(t *testing.T) {
283 for _, from := range []domain.IssueState{domain.IssueStateOpen, domain.IssueStateClosed, domain.IssueStateResolved} {
284 for _, to := range []domain.IssueState{domain.IssueStateOpen, domain.IssueStateClosed, domain.IssueStateResolved} {
285 t.Run(string(from)+"/"+string(to), func(t *testing.T) {
286 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
287 ctx, p := context.Background(), issuePrincipal(domain.ScopeIssueWrite)
288 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(domain.Issue{Repo: u.repo.Name, Number: 3, State: from, ClosedByMergeRequest: 9}, nil)
289 allowed := (from == domain.IssueStateOpen && to != domain.IssueStateOpen) || (from != domain.IssueStateOpen && to == domain.IssueStateOpen)
290 kind := map[domain.IssueState]domain.IssueEventKind{domain.IssueStateOpen: domain.IssueEventReopened, domain.IssueStateClosed: domain.IssueEventClosed, domain.IssueStateResolved: domain.IssueEventResolved}[to]
291 if allowed {
292 u.store.EXPECT().Update(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool {
293 return i.State == to && i.ClosedByMergeRequest == 0 && i.UpdatedAt.Equal(now)
294 }), mock.MatchedBy(func(e []domain.IssueEvent) bool {
295 return len(e) == 1 && e[0].Kind == kind && e[0].Actor == "agent-1" && e[0].CreatedAt.Equal(now) && e[0].MergeRequest == 0
296 })).Return(nil).Once()
297 u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(e domain.Event) bool { return e.EventKind() == "issue."+string(kind) })).Return().Once()
298 }
299 fn := u.issues.Close
300 if to == domain.IssueStateOpen {
301 fn = u.issues.Reopen
302 }
303 if to == domain.IssueStateResolved {
304 fn = u.issues.Resolve
305 }
306 got, err := fn(ctx, p, u.repo.Name, 3)
307 if allowed {
308 require.NoError(t, err)
309 assert.Equal(t, to, got.State)
310 } else {
311 require.ErrorIs(t, err, domain.ErrPreconditionFailed)
312 }
313 })
314 }
315 }
316}
317
318func TestIssueHistoryTitles(t *testing.T) {
319 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
320 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(domain.Issue{Number: 3}, nil)
321 u.store.EXPECT().ListComments(mock.Anything, u.repo.Name, int64(3)).Return(nil, nil)
322 events := []domain.IssueEvent{{ID: 1, MergeRequest: 4}, {ID: 2, MergeRequest: 4}, {ID: 3, MergeRequest: 5}, {ID: 4}}
323 u.store.EXPECT().ListEvents(mock.Anything, u.repo.Name, int64(3)).Return(events, nil)
324 title, err := domain.ParseMRTitle("Fix crash")
325 require.NoError(t, err)
326 u.mrs.EXPECT().Get(mock.Anything, u.repo.Name, int64(4)).Return(domain.MergeRequest{Title: title}, nil).Once()
327 u.mrs.EXPECT().Get(mock.Anything, u.repo.Name, int64(5)).Return(domain.MergeRequest{}, domain.ErrNotFound).Once()
328 d, err := u.issues.Get(context.Background(), domain.Root(), u.repo.Name, 3)
329 require.NoError(t, err)
330 require.Len(t, d.Events, 4)
331 for n, event := range events {
332 assert.Equal(t, event, d.Events[n].Event)
333 }
334 assert.Equal(t, title, d.Events[0].MergeRequestTitle)
335 assert.Equal(t, title, d.Events[1].MergeRequestTitle)
336 assert.True(t, d.Events[2].MergeRequestTitle == (domain.MRTitle{}))
337}
338
339func TestResolveAccess(t *testing.T) {
340 for _, tc := range []struct {
341 name string
342 mode domain.IssuesMode
343 p domain.Principal
344 want error
345 }{
346 {"off", domain.IssuesOff, domain.Root(), domain.ErrNotFound},
347 {"anonymous", domain.IssuesOwner, domain.Anonymous(), domain.ErrUnauthenticated},
348 {"read only", domain.IssuesOwner, issuePrincipal(domain.ScopeRepoRead), domain.ErrForbidden},
349 } {
350 t.Run(tc.name, func(t *testing.T) {
351 u := newIssueUnit(t, domain.VisibilityPublic, tc.mode)
352 _, err := u.issues.Resolve(context.Background(), tc.p, u.repo.Name, 1)
353 require.ErrorIs(t, err, tc.want)
354 })
355 }
356}