issues_integration_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6	"context"
7	"path/filepath"
8	"testing"
9	"time"
10
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/mock"
13	"github.com/stretchr/testify/require"
14
15	"bnema.dev/ermite/internal/adapters/eventbus"
16	"bnema.dev/ermite/internal/adapters/kvstore"
17	"bnema.dev/ermite/internal/domain"
18	"bnema.dev/ermite/internal/ports"
19	"bnema.dev/ermite/internal/ports/mocks"
20	"bnema.dev/ermite/internal/usecase"
21)
22
23// Issues over the real store: shared numbers with merge requests, the life of
24// an issue and its events.
25func TestIssuesOverTheStore(t *testing.T) {
26	ctx := context.Background()
27	store, err := kvstore.Open(ctx, filepath.Join(t.TempDir(), "kv"))
28	require.NoError(t, err)
29	t.Cleanup(func() { require.NoError(t, store.Close(context.Background())) })
30	bus := eventbus.NewWithBuffer(64)
31	events := bus.Subscribe(ctx)
32	n := name(t, "proj")
33	settings := domain.DefaultRepoSettings()
34	settings.Issues = domain.IssuesOwner
35	require.NoError(t, store.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: now, Settings: settings}))
36
37	issues := usecase.NewIssues(usecase.IssuesConfig{Meta: store, Store: store.Issues(), MergeRequests: store.MergeRequests(), Bus: bus, Clock: fixedClock{}})
38	agent := domain.TokenPrincipal(domain.Token{ID: "t", Label: "bot", Scopes: []domain.Scope{domain.ScopeIssueWrite}, BranchPrefixes: []string{"x/"}})
39
40	mrTitle, err := domain.ParseMRTitle("Change")
41	require.NoError(t, err)
42	mr, err := store.MergeRequests().Create(ctx, domain.MergeRequest{
43		Repo: n, Title: mrTitle, State: domain.MRStateOpen, Source: "feat", Target: "main", Author: "a", CreatedAt: now, UpdatedAt: now,
44	})
45	require.NoError(t, err)
46	assert.EqualValues(t, 1, mr.Number)
47
48	issue, err := issues.Create(ctx, agent, n, ports.CreateIssueParams{Title: "Crash", Body: "details"})
49	require.NoError(t, err)
50	assert.EqualValues(t, 2, issue.Number, "the number follows the merge request")
51	assert.Equal(t, "bot", issue.Author)
52	repo, err := store.Get(ctx, n)
53	require.NoError(t, err)
54	assert.True(t, repo.ActiveAt.Equal(now))
55	assert.True(t, repo.PushedAt.IsZero())
56
57	kind, err := issues.Kind(ctx, domain.Anonymous(), n, 1)
58	require.NoError(t, err)
59	assert.Equal(t, domain.NumberMergeRequest, kind)
60	kind, err = issues.Kind(ctx, domain.Anonymous(), n, 2)
61	require.NoError(t, err)
62	assert.Equal(t, domain.NumberIssue, kind)
63	kind, err = issues.Kind(ctx, domain.Anonymous(), n, 3)
64	require.NoError(t, err)
65	assert.Equal(t, domain.NumberNone, kind)
66
67	_, err = issues.AddComment(ctx, agent, n, 2, "me too")
68	require.NoError(t, err)
69	_, err = issues.Close(ctx, agent, n, 2)
70	require.NoError(t, err)
71	_, err = issues.AddComment(ctx, agent, n, 2, "after close")
72	require.NoError(t, err)
73	_, err = issues.Close(ctx, agent, n, 2)
74	assert.ErrorIs(t, err, domain.ErrPreconditionFailed)
75	_, err = issues.Reopen(ctx, agent, n, 2)
76	require.NoError(t, err)
77
78	d, err := issues.Get(ctx, domain.Anonymous(), n, 2)
79	require.NoError(t, err)
80	assert.Equal(t, domain.IssueStateOpen, d.Issue.State)
81	require.Len(t, d.Comments, 2)
82	assert.Equal(t, "me too", d.Comments[0].Text.String())
83	assert.EqualValues(t, 2, d.Issue.CommentSeq)
84
85	page, err := issues.List(ctx, domain.Anonymous(), n, ports.ListIssuesParams{State: domain.IssueStateClosed})
86	require.NoError(t, err)
87	assert.Empty(t, page.Issues)
88
89	var kinds []string
90	for len(events) > 0 {
91		kinds = append(kinds, (<-events).EventKind())
92	}
93	assert.Equal(t, []string{"issue.opened", "issue.commented", "issue.closed", "issue.commented", "issue.reopened"}, kinds)
94}
95
96// Moderated submissions are private work until approval makes them public activity.
97func TestModeratedIssueActivityOverStore(t *testing.T) {
98	ctx := t.Context()
99	store, err := kvstore.Open(ctx, t.TempDir())
100	require.NoError(t, err)
101	t.Cleanup(func() { require.NoError(t, store.Close(ctx)) })
102	n := name(t, "proj")
103	initial := now.Add(-time.Hour)
104	require.NoError(t, store.Create(ctx, domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: initial, ActiveAt: initial, Settings: withIssues(domain.IssuesPublic)}))
105	clock := mocks.NewClock(t)
106	at := now
107	clock.EXPECT().Now().RunAndReturn(func() time.Time { return at }).Maybe()
108	keys, err := usecase.NewIssueKeys(ctx, store.IssueKeys())
109	require.NoError(t, err)
110	limiter := mocks.NewSubmissionLimiter(t)
111	limiter.EXPECT().Recent(mock.Anything, mock.Anything).Return(0, 0).Maybe()
112	limiter.EXPECT().Reserve(mock.Anything, mock.Anything).Return(true, time.Duration(0), func() {}).Maybe()
113	bus := mocks.NewEventBus(t)
114	var kinds []string
115	bus.EXPECT().Publish(mock.Anything, mock.Anything).Run(func(_ context.Context, ev domain.Event) {
116		repo, err := store.Get(ctx, n)
117		require.NoError(t, err)
118		if ev.EventKind() == "issue.opened" {
119			require.Equal(t, at, repo.ActiveAt)
120		} else {
121			require.Equal(t, initial, repo.ActiveAt)
122		}
123		kinds = append(kinds, ev.EventKind())
124	}).Maybe()
125	submissions := usecase.NewIssueSubmissions(usecase.IssueSubmissionsConfig{Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Limiter: limiter, Keys: keys, Bus: bus, Clock: clock, BaseDifficulty: testBits, MaxDifficulty: testBits})
126	moderation := usecase.NewIssueModeration(usecase.IssueModerationConfig{Meta: store, Pending: store.PendingIssues(), Bans: store.IssueBans(), Bus: bus, Clock: clock})
127	submit := func() domain.PendingIssueID {
128		ch, err := submissions.Challenge(ctx, n, "client")
129		require.NoError(t, err)
130		sub := ports.IssueSubmission{Token: ch.Token, FormToken: ch.FormToken, Solution: solve(t, ch), Title: "Bug", Body: "Details"}
131		at = at.Add(usecase.MinFillTime)
132		require.NoError(t, submissions.Submit(ctx, n, "client", sub))
133		repo, err := store.Get(ctx, n)
134		require.NoError(t, err)
135		require.Equal(t, initial, repo.ActiveAt)
136		page, err := store.PendingIssues().List(ctx, n, ports.ListPendingParams{PageSize: 10})
137		require.NoError(t, err)
138		require.Len(t, page.Issues, 1)
139		return page.Issues[0].ID
140	}
141	id := submit()
142	require.NoError(t, moderation.Reject(ctx, domain.Owner(), n, id, false))
143	repo, err := store.Get(ctx, n)
144	require.NoError(t, err)
145	require.Equal(t, initial, repo.ActiveAt)
146	id = submit()
147	_, err = moderation.Approve(ctx, domain.Owner(), n, id)
148	require.NoError(t, err)
149	repo, err = store.Get(ctx, n)
150	require.NoError(t, err)
151	require.Equal(t, at, repo.ActiveAt)
152	require.True(t, repo.PushedAt.IsZero())
153	require.Equal(t, []string{"issue.submitted", "issue.pending_removed", "issue.submitted", "issue.opened"}, kinds)
154}

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