detached 98d36bf

issues_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 4

Tags 45

↑↓ moveenter openesc close