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