detached 5278df2

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}
236
237func TestCompare(t *testing.T) {
238	for _, tc := range []struct {
239		name                  string
240		ancestor, more, zero  bool
241		ancestorErr, rangeErr error
242	}{
243		{name: "linear", ancestor: true}, {name: "bounded", ancestor: true, more: true},
244		{name: "unrelated histories"}, {name: "zero target forwards ancestry error", zero: true, ancestorErr: domain.ErrInvalid},
245		{name: "ancestor error", ancestorErr: domain.ErrInvalid},
246		{name: "range error", ancestor: true, rangeErr: domain.ErrInvalid},
247	} {
248		t.Run(tc.name, func(t *testing.T) {
249			u := newMRUnit(t)
250			old, tip := someID(t), oidFrom(t, "2222222222222222222222222222222222222222")
251			if tc.zero {
252				old = domain.ObjectID{}
253			}
254			u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
255			u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, old, tip).Return(tc.ancestor, tc.ancestorErr)
256			commits := []domain.Commit{{ID: tip}}
257			if tc.ancestorErr == nil {
258				u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, old, tip, ports.MaxCompareCommits).Return(commits, tc.more, tc.rangeErr)
259			}
260			got, err := u.mrs.Compare(context.Background(), domain.Root(), u.repo.Name, old, tip)
261			if tc.ancestorErr != nil {
262				require.ErrorIs(t, err, tc.ancestorErr)
263				return
264			}
265			if tc.rangeErr != nil {
266				require.ErrorIs(t, err, tc.rangeErr)
267				return
268			}
269			require.NoError(t, err)
270			assert.Equal(t, ports.CompareResult{Ahead: commits, Behind: !tc.ancestor, Exact: !tc.more}, got)
271		})
272	}
273}
274
275func TestPushSummary(t *testing.T) {
276	for _, tc := range []struct {
277		name                  string
278		ancestor, more        bool
279		ancestorErr, rangeErr error
280	}{
281		{name: "linear", ancestor: true}, {name: "bounded", ancestor: true, more: true},
282		{name: "unrelated histories"}, {name: "ancestor error", ancestorErr: domain.ErrInvalid},
283		{name: "range error", ancestor: true, rangeErr: domain.ErrInvalid},
284	} {
285		t.Run(tc.name, func(t *testing.T) {
286			u := newMRUnit(t)
287			old, tip := someID(t), oidFrom(t, "2222222222222222222222222222222222222222")
288			u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
289			u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, old, tip).Return(tc.ancestor, tc.ancestorErr)
290			if tc.ancestor && tc.ancestorErr == nil {
291				u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, old, tip, ports.MaxPushSummaryCommits).Return([]domain.Commit{{ID: tip}}, tc.more, tc.rangeErr)
292			}
293			got, err := u.mrs.PushSummary(context.Background(), domain.Root(), u.repo.Name, old, tip)
294			if tc.ancestorErr != nil {
295				require.ErrorIs(t, err, tc.ancestorErr)
296				return
297			}
298			if tc.rangeErr != nil {
299				require.ErrorIs(t, err, tc.rangeErr)
300				return
301			}
302			require.NoError(t, err)
303			want := ports.PushSummary{Rewritten: !tc.ancestor}
304			if tc.ancestor {
305				want.Count, want.More = 1, tc.more
306			}
307			assert.Equal(t, want, got)
308		})
309	}
310}
311
312func TestReadTrackingSharedPosition(t *testing.T) {
313	for _, tc := range []struct {
314		name      string
315		principal domain.Principal
316		readIndex int
317	}{
318		{"owner unread", domain.Owner(), 1}, {"owner already read", domain.Owner(), 0},
319		{"owner rewritten", domain.Owner(), -1}, {"anonymous", domain.Anonymous(), 1}, {"root token", domain.Root(), 1},
320	} {
321		t.Run(tc.name, func(t *testing.T) {
322			u := newMRUnit(t)
323			u.repo.Visibility = domain.VisibilityPublic
324			head := someID(t)
325			old := oidFrom(t, "2222222222222222222222222222222222222222")
326			read := old
327			if tc.readIndex == 0 {
328				read = head
329			}
330			mr := domain.MergeRequest{Repo: u.repo.Name, Number: 1, State: domain.MRStateClosed, HeadID: head}
331			u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
332			u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(mr, nil)
333			u.store.EXPECT().ListComments(mock.Anything, u.repo.Name, int64(1)).Return(nil, nil)
334			u.store.EXPECT().GetRead(mock.Anything, u.repo.Name, int64(1)).Return(read, nil)
335			u.git.EXPECT().HasCommit(mock.Anything, u.repo.Name, head).Return(true, nil)
336			commits := []domain.Commit{{ID: head}}
337			if tc.readIndex >= 0 {
338				commits = append(commits, domain.Commit{ID: old})
339			}
340			u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, domain.ObjectID{}, head, usecase.MaxMergeRequestCommits).Return(commits, false, nil)
341			detail, err := u.mrs.Get(context.Background(), tc.principal, u.repo.Name, 1, ports.GetMergeRequestOptions{})
342			require.NoError(t, err)
343			expected := 0
344			assert.Equal(t, read, detail.LastReadID)
345			assert.Equal(t, read != head, detail.HeadUnread)
346			if tc.readIndex > 0 {
347				expected = tc.readIndex
348			}
349			assert.Equal(t, expected, detail.NewCommits)
350		})
351	}
352}
353
354func TestHistoryAuthorizationAndEqualTips(t *testing.T) {
355	for _, allowed := range []bool{false, true} {
356		t.Run(map[bool]string{false: "private", true: "equal"}[allowed], func(t *testing.T) {
357			u := newMRUnit(t)
358			if allowed {
359				u.repo.Visibility = domain.VisibilityPublic
360			}
361			u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Twice()
362			id := someID(t)
363			comparison, err := u.mrs.Compare(context.Background(), domain.Anonymous(), u.repo.Name, id, id)
364			if allowed {
365				require.NoError(t, err)
366				assert.True(t, comparison.Exact)
367			} else {
368				require.ErrorIs(t, err, domain.ErrNotFound)
369			}
370			summary, err := u.mrs.PushSummary(context.Background(), domain.Anonymous(), u.repo.Name, id, id)
371			if allowed {
372				require.NoError(t, err)
373				assert.Zero(t, summary)
374			} else {
375				require.ErrorIs(t, err, domain.ErrNotFound)
376			}
377		})
378	}
379}

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 2

Tags 45

↑↓ moveenter openesc close