v0.33.0 cf26365

mergerequest_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6	"testing"
7
8	"connectrpc.com/connect"
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/mock"
11	"github.com/stretchr/testify/require"
12
13	pb "bnema.dev/ermite-api/gen/ermite"
14	"bnema.dev/ermite/internal/domain"
15	"bnema.dev/ermite/internal/ports"
16	"bnema.dev/ermite/internal/ports/mocks"
17)
18
19func TestMergeRequestServiceCreate(t *testing.T) {
20	t.Parallel()
21	tests := []struct {
22		name     string
23		repo     string
24		usecase  error
25		wantCode connect.Code
26		wantCall bool
27	}{
28		{name: "ok", repo: "demo", wantCall: true},
29		{name: "invalid repo", repo: "../x", wantCode: connect.CodeInvalidArgument},
30		{name: "not found", repo: "demo", usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true},
31		{name: "forbidden", repo: "demo", usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied, wantCall: true},
32		{name: "already open", repo: "demo", usecase: domain.ErrAlreadyExists, wantCode: connect.CodeAlreadyExists, wantCall: true},
33		{name: "invalid fields", repo: "demo", usecase: domain.ErrInvalid, wantCode: connect.CodeInvalidArgument, wantCall: true},
34	}
35	for _, tt := range tests {
36		t.Run(tt.name, func(t *testing.T) {
37			t.Parallel()
38			mrs := mocks.NewMergeRequestService(t)
39			if tt.wantCall {
40				mrs.EXPECT().Create(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), ports.CreateMergeRequestParams{
41					Title: "t", Description: "d", Source: "feat", Target: "main",
42				}).Return(domain.MergeRequest{Number: 7, Source: "feat", Target: "main", State: domain.MRStateOpen}, tt.usecase).Once()
43			}
44			resp, err := (&mergeRequestService{mrs: mrs}).CreateMergeRequest(ownerCtx(), connect.NewRequest(&pb.CreateMergeRequestRequest{
45				Repo: tt.repo, Title: "t", Description: "d", SourceBranch: "feat", TargetBranch: "main",
46			}))
47			if tt.wantCode != 0 {
48				requireCode(t, tt.wantCode, err)
49				return
50			}
51			require.NoError(t, err)
52			assert.EqualValues(t, 7, resp.Msg.GetMergeRequest().GetNumber())
53			assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_OPEN, resp.Msg.GetMergeRequest().GetState())
54			assert.Equal(t, pb.MergeabilityState_MERGEABILITY_STATE_CHECKING, resp.Msg.GetMergeRequest().GetMergeability().GetState())
55		})
56	}
57}
58
59func TestMergeRequestServiceListRejectsUnknownState(t *testing.T) {
60	t.Parallel()
61	_, err := (&mergeRequestService{mrs: mocks.NewMergeRequestService(t)}).ListMergeRequests(ownerCtx(),
62		connect.NewRequest(&pb.ListMergeRequestsRequest{Repo: "demo", State: pb.MergeRequestState(99)}))
63	requireCode(t, connect.CodeInvalidArgument, err)
64}
65
66func TestMergeRequestServiceListFiltersByState(t *testing.T) {
67	t.Parallel()
68	mrs := mocks.NewMergeRequestService(t)
69	mrs.EXPECT().List(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), ports.ListMergeRequestsParams{
70		State: domain.MRStateMerged, PageSize: 3, PageToken: "tok",
71	}).Return(ports.MergeRequestPage{MergeRequests: []domain.MergeRequest{{Number: 1}}, NextPageToken: "n"}, nil).Once()
72	resp, err := (&mergeRequestService{mrs: mrs}).ListMergeRequests(ownerCtx(), connect.NewRequest(&pb.ListMergeRequestsRequest{
73		Repo: "demo", State: pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, PageSize: 3, PageToken: "tok",
74	}))
75	require.NoError(t, err)
76	assert.Len(t, resp.Msg.GetMergeRequests(), 1)
77	assert.Equal(t, "n", resp.Msg.GetNextPageToken())
78}
79
80func TestMergeRequestServiceAddComment(t *testing.T) {
81	t.Parallel()
82	tests := []struct {
83		name     string
84		side     pb.DiffSide
85		usecase  error
86		wantCode connect.Code
87		wantCall bool
88	}{
89		{name: "ok", side: pb.DiffSide_DIFF_SIDE_NEW, wantCall: true},
90		{name: "unknown side", side: pb.DiffSide(99), wantCode: connect.CodeInvalidArgument},
91		{name: "merge request not open", side: pb.DiffSide_DIFF_SIDE_NEW, usecase: domain.ErrPreconditionFailed, wantCode: connect.CodeFailedPrecondition, wantCall: true},
92		{name: "concurrent update", side: pb.DiffSide_DIFF_SIDE_NEW, usecase: domain.ErrConflict, wantCode: connect.CodeAborted, wantCall: true},
93		{name: "not found", side: pb.DiffSide_DIFF_SIDE_NEW, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true},
94	}
95	for _, tt := range tests {
96		t.Run(tt.name, func(t *testing.T) {
97			t.Parallel()
98			mrs := mocks.NewMergeRequestService(t)
99			if tt.wantCall {
100				mrs.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(2), mock.Anything).
101					Return(domain.Comment{ID: 5}, tt.usecase).Once()
102			}
103			resp, err := (&mergeRequestService{mrs: mrs}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{
104				Repo: "demo", Number: 2, Path: "f.go", Line: 3, Side: tt.side, Text: "hi",
105			}))
106			if tt.wantCode != 0 {
107				requireCode(t, tt.wantCode, err)
108				return
109			}
110			require.NoError(t, err)
111			assert.Equal(t, "5", resp.Msg.GetComment().GetId())
112		})
113	}
114}
115
116func TestMergeRequestServiceResolveCommentMapsUnissuedIDsToZero(t *testing.T) {
117	t.Parallel()
118	for _, id := range []string{"", "abc", "-1", "0", "007", "99999999999999999999"} {
119		t.Run(id, func(t *testing.T) {
120			t.Parallel()
121			mrs := mocks.NewMergeRequestService(t)
122			mrs.EXPECT().ResolveComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(0), true).
123				Return(domain.Comment{}, domain.ErrNotFound).Once()
124			_, err := (&mergeRequestService{mrs: mrs}).ResolveComment(ownerCtx(), connect.NewRequest(&pb.ResolveCommentRequest{
125				Repo: "demo", Number: 1, CommentId: id, Resolved: true,
126			}))
127			requireCode(t, connect.CodeNotFound, err)
128		})
129	}
130}
131
132func TestMergeRequestServiceMerge(t *testing.T) {
133	t.Parallel()
134	const head = "0123456789abcdef0123456789abcdef01234567"
135	tests := []struct {
136		name     string
137		req      *pb.MergeMergeRequestRequest
138		usecase  error
139		wantCode connect.Code
140		wantCall bool
141	}{
142		{name: "ok", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: head, DeleteSourceBranch: true}, wantCall: true},
143		{name: "ignore checks", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: head, IgnoreChecks: true}, wantCall: true},
144		{name: "head is not a full id", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: "abc"}, wantCode: connect.CodeInvalidArgument},
145		{name: "invalid repo", req: &pb.MergeMergeRequestRequest{Repo: "../x", Number: 1}, wantCode: connect.CodeInvalidArgument},
146		{name: "head moved", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: head}, usecase: domain.ErrConflict, wantCode: connect.CodeAborted, wantCall: true},
147		{name: "checks failing", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1}, usecase: domain.ErrPreconditionFailed, wantCode: connect.CodeFailedPrecondition, wantCall: true},
148		{name: "forbidden", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1}, usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied, wantCall: true},
149	}
150	for _, tt := range tests {
151		t.Run(tt.name, func(t *testing.T) {
152			t.Parallel()
153			mrs := mocks.NewMergeRequestService(t)
154			if tt.wantCall {
155				mrs.EXPECT().Merge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), mock.MatchedBy(func(p ports.MergeParams) bool {
156					return p.IgnoreChecks == tt.req.GetIgnoreChecks() && p.DeleteSource == tt.req.GetDeleteSourceBranch() && (tt.req.GetHeadId() == "") == p.HeadID.IsZero()
157				})).Return(ports.MergeResult{MergeRequest: domain.MergeRequest{Number: 1}, SourceDeleted: true}, tt.usecase).Once()
158			}
159			resp, err := (&mergeRequestService{mrs: mrs}).MergeMergeRequest(ownerCtx(), connect.NewRequest(tt.req))
160			if tt.wantCode != 0 {
161				requireCode(t, tt.wantCode, err)
162				return
163			}
164			require.NoError(t, err)
165			assert.True(t, resp.Msg.GetSourceBranchDeleted())
166		})
167	}
168}
169
170func TestMergeRequestServiceCloseAndMarkRead(t *testing.T) {
171	t.Parallel()
172	t.Run("close not found", func(t *testing.T) {
173		t.Parallel()
174		mrs := mocks.NewMergeRequestService(t)
175		mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{}, domain.ErrNotFound).Once()
176		_, err := (&mergeRequestService{mrs: mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))
177		requireCode(t, connect.CodeNotFound, err)
178	})
179	t.Run("close ok", func(t *testing.T) {
180		t.Parallel()
181		mrs := mocks.NewMergeRequestService(t)
182		mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{Number: 9, State: domain.MRStateClosed}, nil).Once()
183		resp, err := (&mergeRequestService{mrs: mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))
184		require.NoError(t, err)
185		assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, resp.Msg.GetMergeRequest().GetState())
186	})
187	t.Run("mark read rejects a short head", func(t *testing.T) {
188		t.Parallel()
189		_, err := (&mergeRequestService{mrs: mocks.NewMergeRequestService(t)}).MarkRead(ownerCtx(),
190			connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1, HeadId: "abc"}))
191		requireCode(t, connect.CodeInvalidArgument, err)
192	})
193	t.Run("mark read unauthenticated", func(t *testing.T) {
194		t.Parallel()
195		mrs := mocks.NewMergeRequestService(t)
196		mrs.EXPECT().MarkRead(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), domain.ObjectID{}).Return(domain.ObjectID{}, domain.ErrUnauthenticated).Once()
197		_, err := (&mergeRequestService{mrs: mrs}).MarkRead(ownerCtx(), connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1}))
198		requireCode(t, connect.CodeUnauthenticated, err)
199	})
200}
201
202func TestMergeRequestServiceGet(t *testing.T) {
203	t.Parallel()
204	tests := []struct {
205		name     string
206		usecase  error
207		wantCode connect.Code
208	}{
209		{name: "ok"},
210		{name: "not found", usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound},
211		{name: "unauthenticated", usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated},
212	}
213	for _, tt := range tests {
214		t.Run(tt.name, func(t *testing.T) {
215			t.Parallel()
216			mrs := mocks.NewMergeRequestService(t)
217			mrs.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(4), ports.GetMergeRequestOptions{SinceLastRead: true}).
218				Return(ports.MergeRequestDetail{MergeRequest: domain.MergeRequest{Number: 4}, CommitsTruncated: true}, tt.usecase).Once()
219			resp, err := (&mergeRequestService{mrs: mrs}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 4, SinceLastRead: true}))
220			if tt.wantCode != 0 {
221				requireCode(t, tt.wantCode, err)
222				return
223			}
224			require.NoError(t, err)
225			assert.EqualValues(t, 4, resp.Msg.GetMergeRequest().GetNumber())
226			assert.True(t, resp.Msg.GetCommitsTruncated())
227		})
228	}
229}
230
231func TestMergeRequestServiceMarkReady(t *testing.T) {
232	t.Parallel()
233	for _, tt := range []struct {
234		name string
235		err  error
236		code connect.Code
237	}{
238		{name: "success"},
239		{name: "not a draft", err: domain.ErrPreconditionFailed, code: connect.CodeFailedPrecondition},
240		{name: "closed", err: domain.ErrPreconditionFailed, code: connect.CodeFailedPrecondition},
241		{name: "without mr:write", err: domain.ErrForbidden, code: connect.CodePermissionDenied},
242	} {
243		t.Run(tt.name, func(t *testing.T) {
244			t.Parallel()
245			mrs := mocks.NewMergeRequestService(t)
246			title, err := domain.ParseMRTitle("Ready")
247			require.NoError(t, err)
248			mrs.EXPECT().MarkReady(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1)).Return(domain.MergeRequest{Number: 1, Title: title, State: domain.MRStateOpen}, tt.err).Once()
249			res, err := (&mergeRequestService{mrs: mrs}).MarkReady(ownerCtx(), connect.NewRequest(&pb.MarkReadyRequest{Repo: "demo", Number: 1}))
250			if tt.code != 0 {
251				requireCode(t, tt.code, err)
252				return
253			}
254			require.NoError(t, err)
255			assert.Equal(t, "Ready", res.Msg.GetMergeRequest().GetTitle())
256		})
257	}
258}

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