v0.36.0 1fb2421

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

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