detached 0e6522c

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}).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		})
55	}
56}
57
58func TestMergeRequestServiceListRejectsUnknownState(t *testing.T) {
59	t.Parallel()
60	_, err := (&mergeRequestService{mocks.NewMergeRequestService(t)}).ListMergeRequests(ownerCtx(),
61		connect.NewRequest(&pb.ListMergeRequestsRequest{Repo: "demo", State: pb.MergeRequestState(99)}))
62	requireCode(t, connect.CodeInvalidArgument, err)
63}
64
65func TestMergeRequestServiceListFiltersByState(t *testing.T) {
66	t.Parallel()
67	mrs := mocks.NewMergeRequestService(t)
68	mrs.EXPECT().List(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), ports.ListMergeRequestsParams{
69		State: domain.MRStateMerged, PageSize: 3, PageToken: "tok",
70	}).Return(ports.MergeRequestPage{MergeRequests: []domain.MergeRequest{{Number: 1}}, NextPageToken: "n"}, nil).Once()
71	resp, err := (&mergeRequestService{mrs}).ListMergeRequests(ownerCtx(), connect.NewRequest(&pb.ListMergeRequestsRequest{
72		Repo: "demo", State: pb.MergeRequestState_MERGE_REQUEST_STATE_MERGED, PageSize: 3, PageToken: "tok",
73	}))
74	require.NoError(t, err)
75	assert.Len(t, resp.Msg.GetMergeRequests(), 1)
76	assert.Equal(t, "n", resp.Msg.GetNextPageToken())
77}
78
79func TestMergeRequestServiceAddComment(t *testing.T) {
80	t.Parallel()
81	tests := []struct {
82		name     string
83		side     pb.DiffSide
84		usecase  error
85		wantCode connect.Code
86		wantCall bool
87	}{
88		{name: "ok", side: pb.DiffSide_DIFF_SIDE_NEW, wantCall: true},
89		{name: "unknown side", side: pb.DiffSide(99), wantCode: connect.CodeInvalidArgument},
90		{name: "merge request not open", side: pb.DiffSide_DIFF_SIDE_NEW, usecase: domain.ErrPreconditionFailed, wantCode: connect.CodeFailedPrecondition, wantCall: true},
91		{name: "concurrent update", side: pb.DiffSide_DIFF_SIDE_NEW, usecase: domain.ErrConflict, wantCode: connect.CodeAborted, wantCall: true},
92		{name: "not found", side: pb.DiffSide_DIFF_SIDE_NEW, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true},
93	}
94	for _, tt := range tests {
95		t.Run(tt.name, func(t *testing.T) {
96			t.Parallel()
97			mrs := mocks.NewMergeRequestService(t)
98			if tt.wantCall {
99				mrs.EXPECT().AddComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(2), mock.Anything).
100					Return(domain.Comment{ID: 5}, tt.usecase).Once()
101			}
102			resp, err := (&mergeRequestService{mrs}).AddComment(ownerCtx(), connect.NewRequest(&pb.AddCommentRequest{
103				Repo: "demo", Number: 2, Path: "f.go", Line: 3, Side: tt.side, Text: "hi",
104			}))
105			if tt.wantCode != 0 {
106				requireCode(t, tt.wantCode, err)
107				return
108			}
109			require.NoError(t, err)
110			assert.Equal(t, "5", resp.Msg.GetComment().GetId())
111		})
112	}
113}
114
115func TestMergeRequestServiceResolveCommentMapsUnissuedIDsToZero(t *testing.T) {
116	t.Parallel()
117	for _, id := range []string{"", "abc", "-1", "0", "007", "99999999999999999999"} {
118		t.Run(id, func(t *testing.T) {
119			t.Parallel()
120			mrs := mocks.NewMergeRequestService(t)
121			mrs.EXPECT().ResolveComment(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), int64(0), true).
122				Return(domain.Comment{}, domain.ErrNotFound).Once()
123			_, err := (&mergeRequestService{mrs}).ResolveComment(ownerCtx(), connect.NewRequest(&pb.ResolveCommentRequest{
124				Repo: "demo", Number: 1, CommentId: id, Resolved: true,
125			}))
126			requireCode(t, connect.CodeNotFound, err)
127		})
128	}
129}
130
131func TestMergeRequestServiceMerge(t *testing.T) {
132	t.Parallel()
133	const head = "0123456789abcdef0123456789abcdef01234567"
134	tests := []struct {
135		name     string
136		req      *pb.MergeMergeRequestRequest
137		usecase  error
138		wantCode connect.Code
139		wantCall bool
140	}{
141		{name: "ok", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: head, DeleteSourceBranch: true}, wantCall: true},
142		{name: "head is not a full id", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: "abc"}, wantCode: connect.CodeInvalidArgument},
143		{name: "invalid repo", req: &pb.MergeMergeRequestRequest{Repo: "../x", Number: 1}, wantCode: connect.CodeInvalidArgument},
144		{name: "head moved", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1, HeadId: head}, usecase: domain.ErrConflict, wantCode: connect.CodeAborted, wantCall: true},
145		{name: "checks failing", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1}, usecase: domain.ErrPreconditionFailed, wantCode: connect.CodeFailedPrecondition, wantCall: true},
146		{name: "forbidden", req: &pb.MergeMergeRequestRequest{Repo: "demo", Number: 1}, usecase: domain.ErrForbidden, wantCode: connect.CodePermissionDenied, wantCall: true},
147	}
148	for _, tt := range tests {
149		t.Run(tt.name, func(t *testing.T) {
150			t.Parallel()
151			mrs := mocks.NewMergeRequestService(t)
152			if tt.wantCall {
153				mrs.EXPECT().Merge(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), mock.MatchedBy(func(p ports.MergeParams) bool {
154					return p.DeleteSource == tt.req.GetDeleteSourceBranch() && (tt.req.GetHeadId() == "") == p.HeadID.IsZero()
155				})).Return(ports.MergeResult{MergeRequest: domain.MergeRequest{Number: 1}, SourceDeleted: true}, tt.usecase).Once()
156			}
157			resp, err := (&mergeRequestService{mrs}).MergeMergeRequest(ownerCtx(), connect.NewRequest(tt.req))
158			if tt.wantCode != 0 {
159				requireCode(t, tt.wantCode, err)
160				return
161			}
162			require.NoError(t, err)
163			assert.True(t, resp.Msg.GetSourceBranchDeleted())
164		})
165	}
166}
167
168func TestMergeRequestServiceCloseAndMarkRead(t *testing.T) {
169	t.Parallel()
170	t.Run("close not found", func(t *testing.T) {
171		t.Parallel()
172		mrs := mocks.NewMergeRequestService(t)
173		mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{}, domain.ErrNotFound).Once()
174		_, err := (&mergeRequestService{mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))
175		requireCode(t, connect.CodeNotFound, err)
176	})
177	t.Run("close ok", func(t *testing.T) {
178		t.Parallel()
179		mrs := mocks.NewMergeRequestService(t)
180		mrs.EXPECT().Close(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(9)).Return(domain.MergeRequest{Number: 9, State: domain.MRStateClosed}, nil).Once()
181		resp, err := (&mergeRequestService{mrs}).CloseMergeRequest(ownerCtx(), connect.NewRequest(&pb.CloseMergeRequestRequest{Repo: "demo", Number: 9}))
182		require.NoError(t, err)
183		assert.Equal(t, pb.MergeRequestState_MERGE_REQUEST_STATE_CLOSED, resp.Msg.GetMergeRequest().GetState())
184	})
185	t.Run("mark read rejects a short head", func(t *testing.T) {
186		t.Parallel()
187		_, err := (&mergeRequestService{mocks.NewMergeRequestService(t)}).MarkRead(ownerCtx(),
188			connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1, HeadId: "abc"}))
189		requireCode(t, connect.CodeInvalidArgument, err)
190	})
191	t.Run("mark read unauthenticated", func(t *testing.T) {
192		t.Parallel()
193		mrs := mocks.NewMergeRequestService(t)
194		mrs.EXPECT().MarkRead(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(1), domain.ObjectID{}).Return(domain.ObjectID{}, domain.ErrUnauthenticated).Once()
195		_, err := (&mergeRequestService{mrs}).MarkRead(ownerCtx(), connect.NewRequest(&pb.MarkReadRequest{Repo: "demo", Number: 1}))
196		requireCode(t, connect.CodeUnauthenticated, err)
197	})
198}
199
200func TestMergeRequestServiceGet(t *testing.T) {
201	t.Parallel()
202	tests := []struct {
203		name     string
204		usecase  error
205		wantCode connect.Code
206	}{
207		{name: "ok"},
208		{name: "not found", usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound},
209		{name: "unauthenticated", usecase: domain.ErrUnauthenticated, wantCode: connect.CodeUnauthenticated},
210	}
211	for _, tt := range tests {
212		t.Run(tt.name, func(t *testing.T) {
213			t.Parallel()
214			mrs := mocks.NewMergeRequestService(t)
215			mrs.EXPECT().Get(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), int64(4), ports.GetMergeRequestOptions{SinceLastRead: true}).
216				Return(ports.MergeRequestDetail{MergeRequest: domain.MergeRequest{Number: 4}, CommitsTruncated: true}, tt.usecase).Once()
217			resp, err := (&mergeRequestService{mrs}).GetMergeRequest(ownerCtx(), connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "demo", Number: 4, SinceLastRead: true}))
218			if tt.wantCode != 0 {
219				requireCode(t, tt.wantCode, err)
220				return
221			}
222			require.NoError(t, err)
223			assert.EqualValues(t, 4, resp.Msg.GetMergeRequest().GetNumber())
224			assert.True(t, resp.Msg.GetCommitsTruncated())
225		})
226	}
227}

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