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 _, err = u.issues.Kind(ctx, domain.Root(), n, 1)
69 assert.ErrorIs(t, err, domain.ErrNotFound)
70 }
71}
72
73func TestIssueAccessRules(t *testing.T) {
74 t.Parallel()
75 ctx := context.Background()
76 t.Run("private repository hides reads", func(t *testing.T) {
77 t.Parallel()
78 u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
79 _, err := u.issues.List(ctx, domain.Anonymous(), u.repo.Name, ports.ListIssuesParams{})
80 assert.ErrorIs(t, err, domain.ErrNotFound)
81 _, err = u.issues.Get(ctx, issuePrincipal(domain.ScopeIssueWrite), u.repo.Name, 1)
82 assert.ErrorIs(t, err, domain.ErrNotFound, "issue:write does not read")
83 })
84 t.Run("writes", func(t *testing.T) {
85 t.Parallel()
86 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
87 p := ports.CreateIssueParams{Title: "t"}
88 _, err := u.issues.Create(ctx, domain.Anonymous(), u.repo.Name, p)
89 assert.ErrorIs(t, err, domain.ErrUnauthenticated)
90 _, err = u.issues.Create(ctx, issuePrincipal(domain.ScopeRepoRead), u.repo.Name, p)
91 assert.ErrorIs(t, err, domain.ErrForbidden)
92 })
93 t.Run("private write without the scope", func(t *testing.T) {
94 t.Parallel()
95 u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
96 _, err := u.issues.Create(ctx, issuePrincipal(domain.ScopeRepoWrite), u.repo.Name, ports.CreateIssueParams{Title: "t"})
97 assert.ErrorIs(t, err, domain.ErrNotFound)
98 })
99}
100
101func TestIssueCreateAndTransitions(t *testing.T) {
102 t.Parallel()
103 ctx := context.Background()
104 p := issuePrincipal(domain.ScopeIssueWrite) // branch-limited: issues write no ref
105
106 t.Run("create", func(t *testing.T) {
107 t.Parallel()
108 u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
109 u.store.EXPECT().Create(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool {
110 return i.Repo == u.repo.Name && i.Title.String() == "Crash" && i.Body.String() == "why" &&
111 i.State == domain.IssueStateOpen && i.Author == "agent-1" && i.CreatedAt.Equal(now)
112 })).RunAndReturn(func(_ context.Context, i domain.Issue) (domain.Issue, error) { i.Number = 7; return i, nil })
113 u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
114 o, ok := ev.(domain.IssueOpened)
115 return ok && o.Issue.Number == 7 && o.Visibility == domain.VisibilityPrivate
116 })).Return()
117 got, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: " Crash ", Body: "why"})
118 require.NoError(t, err)
119 assert.EqualValues(t, 7, got.Number)
120 u.meta.AssertCalled(t, "Mutate", mock.Anything, u.repo.Name, mock.Anything)
121 })
122
123 t.Run("invalid text stores nothing", func(t *testing.T) {
124 t.Parallel()
125 u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
126 _, err := u.issues.Create(ctx, p, u.repo.Name, ports.CreateIssueParams{Title: ""})
127 var fe *domain.InvalidFieldsError
128 require.ErrorAs(t, err, &fe)
129 })
130
131 open := domain.Issue{Repo: name(t, "proj"), Number: 3, State: domain.IssueStateOpen, Version: 1}
132
133 t.Run("close with every update conflicting is an error and publishes nothing", func(t *testing.T) {
134 t.Parallel()
135 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
136 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(open, nil)
137 u.store.EXPECT().Update(mock.Anything, mock.Anything).Return(domain.ErrConflict)
138 _, err := u.issues.Close(ctx, p, u.repo.Name, 3)
139 assert.ErrorIs(t, err, domain.ErrConflict)
140 })
141
142 t.Run("close retries a conflict then publishes", func(t *testing.T) {
143 t.Parallel()
144 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
145 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(open, nil).Twice()
146 u.store.EXPECT().Update(mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
147 u.store.EXPECT().Update(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool { return i.State == domain.IssueStateClosed })).Return(nil).Once()
148 u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
149 c, ok := ev.(domain.IssueClosed)
150 return ok && c.Issue.State == domain.IssueStateClosed
151 })).Return()
152 got, err := u.issues.Close(ctx, p, u.repo.Name, 3)
153 require.NoError(t, err)
154 assert.Equal(t, domain.IssueStateClosed, got.State)
155 })
156
157 t.Run("close of a closed issue", func(t *testing.T) {
158 t.Parallel()
159 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
160 closed := open
161 closed.State = domain.IssueStateClosed
162 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(closed, nil)
163 _, err := u.issues.Close(ctx, p, u.repo.Name, 3)
164 assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
165 })
166
167 t.Run("reopen", func(t *testing.T) {
168 t.Parallel()
169 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
170 closed := open
171 closed.State = domain.IssueStateClosed
172 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(closed, nil)
173 u.store.EXPECT().Update(mock.Anything, mock.Anything).Return(nil)
174 u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool { _, ok := ev.(domain.IssueReopened); return ok })).Return()
175 got, err := u.issues.Reopen(ctx, p, u.repo.Name, 3)
176 require.NoError(t, err)
177 assert.Equal(t, domain.IssueStateOpen, got.State)
178 })
179
180 t.Run("reopen of an open issue", func(t *testing.T) {
181 t.Parallel()
182 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
183 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(open, nil)
184 _, err := u.issues.Reopen(ctx, p, u.repo.Name, 3)
185 assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
186 })
187}
188
189func TestIssueAddComment(t *testing.T) {
190 t.Parallel()
191 ctx := context.Background()
192 p := issuePrincipal(domain.ScopeIssueWrite)
193 issue := domain.Issue{Repo: name(t, "proj"), Number: 3, State: domain.IssueStateClosed, CommentSeq: 4, Version: 1}
194
195 t.Run("every save conflicting is an error and publishes nothing", func(t *testing.T) {
196 t.Parallel()
197 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
198 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(issue, nil)
199 u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict)
200 _, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, "hi") // the bus mock fails on any Publish
201 assert.ErrorIs(t, err, domain.ErrConflict)
202 })
203
204 t.Run("on a closed issue, with a retry", func(t *testing.T) {
205 t.Parallel()
206 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
207 fresh := issue
208 fresh.CommentSeq = 5
209 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(issue, nil).Once()
210 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(fresh, nil).Once()
211 u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
212 u.store.EXPECT().SaveComment(mock.Anything, mock.MatchedBy(func(i domain.Issue) bool { return i.CommentSeq == 6 }),
213 mock.MatchedBy(func(c domain.IssueComment) bool { return c.ID == 6 && c.Author == "agent-1" && c.Text.String() == "hi" })).Return(nil).Once()
214 u.bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(ev domain.Event) bool {
215 c, ok := ev.(domain.IssueCommented)
216 return ok && c.Number == 3 && c.Comment.ID == 6
217 })).Return()
218 c, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, "hi")
219 require.NoError(t, err)
220 assert.EqualValues(t, 6, c.ID)
221 })
222
223 t.Run("blank", func(t *testing.T) {
224 t.Parallel()
225 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
226 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(issue, nil)
227 _, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, " \n ")
228 var fe *domain.InvalidFieldsError
229 require.ErrorAs(t, err, &fe)
230 })
231
232 t.Run("full", func(t *testing.T) {
233 t.Parallel()
234 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
235 full := issue
236 full.CommentSeq = usecase.MaxCommentsPerIssue
237 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(3)).Return(full, nil)
238 _, err := u.issues.AddComment(ctx, p, u.repo.Name, 3, "x")
239 assert.ErrorIs(t, err, domain.ErrResourceExhausted)
240 })
241}
242
243func TestIssueKind(t *testing.T) {
244 t.Parallel()
245 ctx := context.Background()
246 t.Run("issue", func(t *testing.T) {
247 t.Parallel()
248 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
249 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{Number: 1}, nil)
250 k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
251 require.NoError(t, err)
252 assert.Equal(t, domain.NumberIssue, k)
253 })
254 t.Run("merge request", func(t *testing.T) {
255 t.Parallel()
256 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
257 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{}, domain.ErrNotFound)
258 u.mrs.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{Number: 1}, nil)
259 k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
260 require.NoError(t, err)
261 assert.Equal(t, domain.NumberMergeRequest, k)
262 })
263 t.Run("none", func(t *testing.T) {
264 t.Parallel()
265 u := newIssueUnit(t, domain.VisibilityPublic, domain.IssuesOwner)
266 u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.Issue{}, domain.ErrNotFound)
267 u.mrs.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{}, domain.ErrNotFound)
268 k, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
269 require.NoError(t, err)
270 assert.Equal(t, domain.NumberNone, k)
271 })
272 t.Run("private repository", func(t *testing.T) {
273 t.Parallel()
274 u := newIssueUnit(t, domain.VisibilityPrivate, domain.IssuesOwner)
275 _, err := u.issues.Kind(ctx, domain.Anonymous(), u.repo.Name, 1)
276 assert.ErrorIs(t, err, domain.ErrNotFound)
277 })
278}