detached 388d3be

mergerequests_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6	"context"
7	"errors"
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/domain"
16	"bnema.dev/ermite/internal/ports"
17	"bnema.dev/ermite/internal/ports/mocks"
18	"bnema.dev/ermite/internal/usecase"
19)
20
21type mrUnit struct {
22	meta  *mocks.RepoMetaStore
23	store *mocks.MergeRequestStore
24	repos *mocks.GitRepos
25	git   *mocks.MergeGit
26	mrs   *usecase.MergeRequests
27	repo  domain.Repo
28}
29
30func newMRUnit(t *testing.T) *mrUnit {
31	t.Helper()
32	u := &mrUnit{
33		meta:  mocks.NewRepoMetaStore(t),
34		store: mocks.NewMergeRequestStore(t),
35		repos: mocks.NewGitRepos(t),
36		git:   mocks.NewMergeGit(t),
37		repo:  domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPrivate, Settings: domain.DefaultRepoSettings()},
38	}
39	clock := mocks.NewClock(t)
40	clock.EXPECT().Now().Return(now).Maybe()
41	sched := mocks.NewMaintenanceScheduler(t)
42	sched.EXPECT().Schedule(mock.Anything, mock.Anything).Return().Maybe()
43	u.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
44		Meta: u.meta, Store: u.store, Repos: u.repos, Git: u.git,
45		Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: sched,
46	})
47	return u
48}
49
50// GO-004: a failure to read Git is an error for the log, not a field violation
51// that would show its text to the caller.
52func TestCreateReturnsGitFailuresAsErrors(t *testing.T) {
53	t.Parallel()
54	boom := errors.New("fatal: /srv/ermite/repos/proj.git: permission denied")
55	params := ports.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "main"}
56
57	t.Run("reading a branch", func(t *testing.T) {
58		t.Parallel()
59		u := newMRUnit(t)
60		u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
61		u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "feat").Return(domain.ObjectID{}, boom)
62		_, err := u.mrs.Create(context.Background(), domain.Root(), u.repo.Name, params)
63		require.ErrorIs(t, err, boom)
64		var fe *domain.InvalidFieldsError
65		assert.False(t, errors.As(err, &fe), "not a field violation: %v", err)
66		assert.NotErrorIs(t, err, domain.ErrInvalid)
67	})
68
69	t.Run("reading the default branch", func(t *testing.T) {
70		t.Parallel()
71		u := newMRUnit(t)
72		u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
73		u.repos.EXPECT().DefaultBranch(mock.Anything, u.repo.Name).Return("", false, boom)
74		_, err := u.mrs.Create(context.Background(), domain.Root(), u.repo.Name, ports.CreateMergeRequestParams{Title: "t", Source: "feat"})
75		require.ErrorIs(t, err, boom)
76		var fe *domain.InvalidFieldsError
77		assert.False(t, errors.As(err, &fe), "not a field violation: %v", err)
78	})
79
80	t.Run("a branch that does not exist stays a violation", func(t *testing.T) {
81		t.Parallel()
82		u := newMRUnit(t)
83		u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
84		u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "feat").Return(domain.ObjectID{}, domain.ErrNotFound)
85		u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "main").Return(someID(t), nil)
86		_, err := u.mrs.Create(context.Background(), domain.Root(), u.repo.Name, params)
87		assert.Contains(t, fieldsOf(t, err), "source_branch")
88	})
89}
90
91func someID(t *testing.T) domain.ObjectID {
92	t.Helper()
93	return oidFrom(t, oidA)
94}
95
96// GO-003: the settings are read again under the merge lock; a failure of that
97// read is returned, and a repository deleted meanwhile is not found.
98func TestMergeReturnsTheFailureToReadTheSettings(t *testing.T) {
99	t.Parallel()
100	boom := errors.New("kv: closed")
101	for label, tc := range map[string]struct{ err, want error }{
102		"read failure":      {boom, boom},
103		"deleted meanwhile": {domain.ErrNotFound, domain.ErrNotFound},
104	} {
105		t.Run(label, func(t *testing.T) {
106			t.Parallel()
107			u := newMRUnit(t)
108			u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Once()
109			u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(domain.Repo{}, tc.err).Once()
110			u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{
111				Repo: u.repo.Name, Number: 1, State: domain.MRStateOpen, Source: "feat", Target: "main",
112			}, nil)
113			_, err := u.mrs.Merge(context.Background(), domain.Root(), u.repo.Name, 1, ports.MergeParams{HeadID: someID(t)})
114			require.ErrorIs(t, err, tc.want)
115			if tc.want == boom {
116				assert.NotErrorIs(t, err, domain.ErrNotFound)
117			}
118		})
119	}
120}
121
122func TestNoteMutationRetries(t *testing.T) {
123	for _, action := range []string{"edit", "delete", "resolve"} {
124		t.Run(action, func(t *testing.T) {
125			u := newMRUnit(t)
126			u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
127			mr := domain.MergeRequest{Repo: u.repo.Name, Number: 1, State: domain.MRStateOpen}
128			text, err := domain.ParsePlainText("before")
129			require.NoError(t, err)
130			c := domain.Comment{ID: 1, Author: "root", Text: text}
131			freshMR := mr
132			freshMR.Version = 2
133			fresh := c
134			fresh.Version = 3
135			fresh.CreatedAt = now.Add(-time.Hour)
136			if action != "resolve" {
137				fresh.Resolved = true
138			}
139			want := fresh
140			switch action {
141			case "edit":
142				want.Text, err = domain.ParsePlainText("after")
143				require.NoError(t, err)
144				want.EditedAt = now.UTC()
145			case "delete":
146				want.Text, err = domain.ParsePlainText("")
147				require.NoError(t, err)
148				want.Deleted = true
149				want.EditedAt = now.UTC()
150			case "resolve":
151				want.Resolved = true
152			}
153			u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(mr, nil).Once()
154			u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(freshMR, nil).Once()
155			u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(c, nil).Once()
156			u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(fresh, nil).Once()
157			u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
158			u.store.EXPECT().SaveComment(mock.Anything, mock.MatchedBy(func(saved domain.MergeRequest) bool {
159				return saved.Version == freshMR.Version
160			}), mock.MatchedBy(func(saved domain.Comment) bool { return saved == want })).Return(nil).Once()
161			bus := mocks.NewEventBus(t)
162			bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(e domain.Event) bool {
163				switch event := e.(type) {
164				case domain.CommentEdited:
165					return action == "edit" && event.Comment == want
166				case domain.CommentDeleted:
167					return action == "delete" && event.Comment == want
168				case domain.CommentResolved:
169					return action == "resolve" && event.Comment == want
170				}
171				return false
172			})).Return().Once()
173			clock := mocks.NewClock(t)
174			clock.EXPECT().Now().Return(now)
175			svc := usecase.NewMergeRequests(usecase.MergeRequestsConfig{Meta: u.meta, Store: u.store, Bus: bus, Clock: clock})
176			var got domain.Comment
177			switch action {
178			case "edit":
179				got, err = svc.EditComment(context.Background(), domain.Root(), u.repo.Name, 1, 1, "after")
180			case "delete":
181				got, err = svc.DeleteComment(context.Background(), domain.Root(), u.repo.Name, 1, 1)
182			case "resolve":
183				got, err = svc.ResolveComment(context.Background(), domain.Root(), u.repo.Name, 1, 1, true)
184			}
185			require.NoError(t, err)
186			assert.Equal(t, want, got)
187		})
188	}
189}
190
191// A reply whose root is deleted while the save is retried is rejected.
192func TestAddNoteRechecksReplyRootOnConflict(t *testing.T) {
193	u := newMRUnit(t)
194	u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
195	u.git.EXPECT().BranchHead(mock.Anything, mock.Anything, mock.Anything).Return(domain.ObjectID{}, domain.ErrNotFound)
196	u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{State: domain.MRStateOpen}, nil).Twice()
197	u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(domain.Comment{ID: 1}, nil).Once()
198	u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(domain.Comment{ID: 1, Deleted: true}, nil).Once()
199	u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once()
200	_, err := u.mrs.AddComment(context.Background(), domain.Root(), u.repo.Name, 1, ports.AddCommentParams{ReplyTo: 1, Text: "x"})
201	require.ErrorIs(t, err, domain.ErrInvalid)
202}
203
204func TestAddNoteValidation(t *testing.T) {
205	for _, tc := range []struct {
206		name    string
207		params  ports.AddCommentParams
208		target  domain.Comment
209		missing bool
210	}{
211		{name: "range without path", params: ports.AddCommentParams{StartLine: 1, Text: "x"}},
212		{name: "equal ends", params: ports.AddCommentParams{Path: "a", StartLine: 3, Line: 3, Side: domain.SideNew, Text: "x"}},
213		{name: "reversed ends", params: ports.AddCommentParams{Path: "a", StartLine: 4, Line: 3, Side: domain.SideNew, Text: "x"}},
214		{name: "reply anchor", params: ports.AddCommentParams{ReplyTo: 1, Path: "a", Line: 1, Side: domain.SideNew, Text: "x"}},
215		{name: "reply verdict", params: ports.AddCommentParams{ReplyTo: 1, Verdict: domain.VerdictApprove}},
216		{name: "reply to reply", params: ports.AddCommentParams{ReplyTo: 1, Text: "x"}, target: domain.Comment{ReplyTo: 2}},
217		{name: "deleted root", params: ports.AddCommentParams{ReplyTo: 1, Text: "x"}, target: domain.Comment{Deleted: true}},
218		{name: "missing root", params: ports.AddCommentParams{ReplyTo: 1, Text: "x"}, missing: true},
219	} {
220		t.Run(tc.name, func(t *testing.T) {
221			u := newMRUnit(t)
222			u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
223			u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{State: domain.MRStateOpen}, nil)
224			if tc.name == "reply to reply" || tc.name == "deleted root" || tc.missing {
225				var err error
226				if tc.missing {
227					err = domain.ErrNotFound
228				}
229				u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(tc.target, err)
230			}
231			_, err := u.mrs.AddComment(context.Background(), domain.Root(), u.repo.Name, 1, tc.params)
232			require.ErrorIs(t, err, domain.ErrInvalid)
233		})
234	}
235}

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