detached 27e453c

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: "head is not a full id", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: "abc"}, wantCode: connect.CodeInvalidArgument},
144		{name: "invalid repo", req: &pb.MergeMergeRequestRequest{Repo: "../x", Number: 1}, wantCode: connect.CodeInvalidArgument},
145		{name: "head moved", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: head}, usecase: domain.ErrConflict, wantCode: connect.CodeAborted, wantCall: true},
146		{name: "checks failing", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1}, usecase: domain.ErrPreconditionFailed, wantCode: connect.CodeFailedPrecondition, wantCall: true},
147		{name: "forbidden", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1}, usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied, wantCall: true},
148	}
149	for _, tt := range tests {
150		t.Run(tt.name, func(t *testing.T) {
151			t.Parallel()
152			mrs := mocks.NewMergeRequestService(t)
153			if tt.wantCall {
154				mrs.EXPECT().Merge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), mock.MatchedBy(func(p ports.MergeParams) bool {
155					return p.DeleteSource == tt.req.GetDeleteSourceBranch() && (tt.req.GetHeadId() == "") == p.HeadID.IsZero()
156				})).Return(ports.MergeResult{MergeRequest: domain.MergeRequest{Number: 1}, SourceDeleted: true}, tt.usecase).Once()
157			}
158			resp, err := (&mergeRequestService{mrs: mrs}).MergeMergeRequest(ownerCtx(), connect.NewRequest(tt.req))
159			if tt.wantCode != 0 {
160				requireCode(t, tt.wantCode, err)
161				return
162			}
163			require.NoError(t, err)
164			assert.True(t, resp.Msg.GetSourceBranchDeleted())
165		})
166	}
167}
168
169func TestMergeRequestServiceCloseAndMarkRead(t *testing.T) {
170	t.Parallel()
171	t.Run("close not found", func(t *testing.T) {
172		t.Parallel()
173		mrs := mocks.NewMergeRequestService(t)
174		mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{}, domain.ErrNotFound).Once()
175		_, err := (&mergeRequestService{mrs: mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))
176		requireCode(t, connect.CodeNotFound, err)
177	})
178	t.Run("close ok", func(t *testing.T) {
179		t.Parallel()
180		mrs := mocks.NewMergeRequestService(t)
181		mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{Number: 9, State: domain.MRStateClosed}, nil).Once()
182		resp, err := (&mergeRequestService{mrs: mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))
183		require.NoError(t, err)
184		assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, resp.Msg.GetMergeRequest().GetState())
185	})
186	t.Run("mark read rejects a short head", func(t *testing.T) {
187		t.Parallel()
188		_, err := (&mergeRequestService{mrs: mocks.NewMergeRequestService(t)}).MarkRead(ownerCtx(),
189			connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1, HeadId: "abc"}))
190		requireCode(t, connect.CodeInvalidArgument, err)
191	})
192	t.Run("mark read unauthenticated", func(t *testing.T) {
193		t.Parallel()
194		mrs := mocks.NewMergeRequestService(t)
195		mrs.EXPECT().MarkRead(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), domain.ObjectID{}).Return(domain.ObjectID{}, domain.ErrUnauthenticated).Once()
196		_, err := (&mergeRequestService{mrs: mrs}).MarkRead(ownerCtx(), connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1}))
197		requireCode(t, connect.CodeUnauthenticated, err)
198	})
199}
200
201func TestMergeRequestServiceGet(t *testing.T) {
202	t.Parallel()
203	tests := []struct {
204		name     string
205		usecase  error
206		wantCode connect.Code
207	}{
208		{name: "ok"},
209		{name: "not found", usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound},
210		{name: "unauthenticated", usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated},
211	}
212	for _, tt := range tests {
213		t.Run(tt.name, func(t *testing.T) {
214			t.Parallel()
215			mrs := mocks.NewMergeRequestService(t)
216			mrs.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(4), ports.GetMergeRequestOptions{SinceLastRead: true}).
217				Return(ports.MergeRequestDetail{MergeRequest: domain.MergeRequest{Number: 4}, CommitsTruncated: true}, tt.usecase).Once()
218			resp, err := (&mergeRequestService{mrs: mrs}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 4, SinceLastRead: true}))
219			if tt.wantCode != 0 {
220				requireCode(t, tt.wantCode, err)
221				return
222			}
223			require.NoError(t, err)
224			assert.EqualValues(t, 4, resp.Msg.GetMergeRequest().GetNumber())
225			assert.True(t, resp.Msg.GetCommitsTruncated())
226		})
227	}
228}

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