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).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).Return(domain.ErrConflict).Once()
149 u.store.EXPECT().Update(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool { return i.State == domain.IssueStateClosed })).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).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}