detached aa7340c

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	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}

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