v0.27.0 0039612

convert_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6	"context"
7	"fmt"
8	"testing"
9	"time"
10
11	"connectrpc.com/connect"
12	"github.com/stretchr/testify/assert"
13	"github.com/stretchr/testify/require"
14	"google.golang.org/genproto/googleapis/rpc/errdetails"
15	"google.golang.org/protobuf/types/known/fieldmaskpb"
16
17	pb "bnema.dev/ermite-api/gen/ermite"
18	"bnema.dev/ermite/internal/domain"
19	"bnema.dev/ermite/internal/ports"
20)
21
22// Every value of the domain maps to a real value of the contract, never to
23// UNSPECIFIED, and the mappers that have an inverse round-trip. A value added
24// to the domain and forgotten here would reach clients as "not set".
25func TestEnumMappersCoverEveryDomainValue(t *testing.T) {
26	t.Parallel()
27	t.Run("visibility", func(t *testing.T) {
28		t.Parallel()
29		for _, v := range []domain.Visibility{domain.VisibilityPublic, domain.VisibilityPrivate} {
30			got := visibilityToPB(v)
31			assert.NotEqual(t, pb.Visibility_VISIBILITY_UNSPECIFIED, got, v)
32			back, err := visibilityFromPB(got)
33			require.NoError(t, err, v)
34			assert.Equal(t, v, back)
35		}
36		assert.Equal(t, pb.Visibility_VISIBILITY_UNSPECIFIED, visibilityToPB(""))
37		_, err := visibilityFromPB(pb.Visibility(99))
38		assert.ErrorIs(t, err, domain.ErrInvalidVisibility)
39	})
40
41	t.Run("scope", func(t *testing.T) {
42		t.Parallel()
43		for _, s := range domain.AllScopes() {
44			got, ok := scopeToPB[s]
45			require.True(t, ok, "scope %s has no counterpart in the contract", s)
46			assert.NotEqual(t, pb.Scope_SCOPE_UNSPECIFIED, got, s)
47			back, err := scopeFromPB(got)
48			require.NoError(t, err, s)
49			assert.Equal(t, s, back)
50		}
51		_, err := scopeFromPB(pb.Scope_SCOPE_UNSPECIFIED)
52		assert.ErrorIs(t, err, domain.ErrInvalidScope)
53		// Every scope of the contract is a scope of the domain.
54		for v := range pb.Scope_name {
55			if pb.Scope(v) == pb.Scope_SCOPE_UNSPECIFIED {
56				continue
57			}
58			_, err := scopeFromPB(pb.Scope(v))
59			assert.NoError(t, err, pb.Scope(v))
60		}
61	})
62
63	t.Run("ref kind", func(t *testing.T) {
64		t.Parallel()
65		for _, k := range []domain.RefKind{domain.RefKindHead, domain.RefKindBranch, domain.RefKindTag} {
66			got := refKindToPB(k)
67			assert.NotEqual(t, pb.RefKind_REF_KIND_UNSPECIFIED, got, k)
68			back, err := refKindFromPB(got)
69			require.NoError(t, err, k)
70			assert.Equal(t, k, back)
71		}
72		_, err := refKindFromPB(pb.RefKind_REF_KIND_UNSPECIFIED)
73		assert.ErrorIs(t, err, domain.ErrInvalidRefKind)
74	})
75
76	t.Run("entry kind", func(t *testing.T) {
77		t.Parallel()
78		seen := map[pb.EntryKind]domain.EntryKind{}
79		for _, k := range []domain.EntryKind{
80			domain.EntryFile, domain.EntryExecutable, domain.EntryDirectory, domain.EntrySymlink, domain.EntrySubmodule,
81		} {
82			got := entryKindToPB(k)
83			assert.NotEqual(t, pb.EntryKind_ENTRY_KIND_UNSPECIFIED, got, k)
84			assert.NotContains(t, seen, got, "%s and %s share a value", k, seen[got])
85			seen[got] = k
86		}
87	})
88
89	t.Run("file status", func(t *testing.T) {
90		t.Parallel()
91		seen := map[pb.FileStatus]domain.FileStatus{}
92		for _, s := range []domain.FileStatus{
93			domain.FileAdded, domain.FileDeleted, domain.FileModified, domain.FileRenamed, domain.FileCopied, domain.FileTypeChanged,
94		} {
95			got := fileStatusToPB(s)
96			assert.NotEqual(t, pb.FileStatus_FILE_STATUS_UNSPECIFIED, got, s)
97			assert.NotContains(t, seen, got, "%s and %s share a value", s, seen[got])
98			seen[got] = s
99		}
100	})
101
102	t.Run("line kind", func(t *testing.T) {
103		t.Parallel()
104		seen := map[pb.LineKind]domain.LineKind{}
105		for _, k := range []domain.LineKind{domain.LineContext, domain.LineAddition, domain.LineDeletion} {
106			got := lineKindToPB(k)
107			assert.NotEqual(t, pb.LineKind_LINE_KIND_UNSPECIFIED, got, k)
108			assert.NotContains(t, seen, got, "%s and %s share a value", k, seen[got])
109			seen[got] = k
110		}
111	})
112
113	// Every value of the contract except UNSPECIFIED is produced by some value
114	// of the domain: nothing in the contract is unreachable.
115	t.Run("the contract has no value the domain cannot produce", func(t *testing.T) {
116		t.Parallel()
117		produced := func(names map[int32]string, from func(v int32) bool) {
118			for v, n := range names {
119				if v == 0 {
120					continue
121				}
122				assert.True(t, from(v), "%s is never produced", n)
123			}
124		}
125		produced(pb.EntryKind_name, func(v int32) bool {
126			for _, k := range []domain.EntryKind{domain.EntryFile, domain.EntryExecutable, domain.EntryDirectory, domain.EntrySymlink, domain.EntrySubmodule} {
127				if entryKindToPB(k) == pb.EntryKind(v) {
128					return true
129				}
130			}
131			return false
132		})
133		produced(pb.FileStatus_name, func(v int32) bool {
134			for _, k := range []domain.FileStatus{domain.FileAdded, domain.FileDeleted, domain.FileModified, domain.FileRenamed, domain.FileCopied, domain.FileTypeChanged} {
135				if fileStatusToPB(k) == pb.FileStatus(v) {
136					return true
137				}
138			}
139			return false
140		})
141		produced(pb.LineKind_name, func(v int32) bool {
142			for _, k := range []domain.LineKind{domain.LineContext, domain.LineAddition, domain.LineDeletion} {
143				if lineKindToPB(k) == pb.LineKind(v) {
144					return true
145				}
146			}
147			return false
148		})
149	})
150}
151
152func TestMergeModeMappers(t *testing.T) {
153	t.Parallel()
154	for _, m := range domain.MergeModes() {
155		got := mergeModeToPB(m)
156		assert.NotEqual(t, pb.MergeMode_MERGE_MODE_UNSPECIFIED, got, m)
157		back, err := mergeModeFromPB(got)
158		require.NoError(t, err, m)
159		assert.Equal(t, m, back)
160	}
161	// Unspecified in a merge request is the default mode, left to the use case;
162	// in the settings it is refused.
163	m, err := mergeModeFromPB(pb.MergeMode_MERGE_MODE_UNSPECIFIED)
164	require.NoError(t, err)
165	assert.Empty(t, m)
166	_, err = mergeModesFromPB([]pb.MergeMode{pb.MergeMode_MERGE_MODE_UNSPECIFIED})
167	assert.ErrorIs(t, err, domain.ErrInvalid)
168	_, err = mergeModeFromPB(pb.MergeMode(99))
169	assert.ErrorIs(t, err, domain.ErrInvalid)
170	// Every mode of the contract is known.
171	for v := range pb.MergeMode_name {
172		if pb.MergeMode(v) != pb.MergeMode_MERGE_MODE_UNSPECIFIED {
173			_, err := mergeModeFromPB(pb.MergeMode(v))
174			assert.NoError(t, err, pb.MergeMode(v))
175		}
176	}
177}
178
179func TestMergeRequestEnumMappers(t *testing.T) {
180	t.Parallel()
181	for _, s := range []domain.MRState{domain.MRStateOpen, domain.MRStateMerged, domain.MRStateClosed} {
182		got := mergeRequestStateToPB(s)
183		assert.NotEqual(t, pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED, got, s)
184		back, err := mergeRequestStateFromPB(got)
185		require.NoError(t, err, s)
186		assert.Equal(t, s, back)
187	}
188	all, err := mergeRequestStateFromPB(pb.MergeRequestState_MERGE_REQUEST_STATE_UNSPECIFIED)
189	require.NoError(t, err)
190	assert.Empty(t, all, "unspecified means every state")
191	_, err = mergeRequestStateFromPB(99)
192	assert.ErrorIs(t, err, domain.ErrInvalid)
193
194	for _, s := range []domain.DiffSide{domain.SideOld, domain.SideNew, domain.SideNone} {
195		back, err := diffSideFromPB(diffSideToPB(s))
196		require.NoError(t, err, s)
197		assert.Equal(t, s, back)
198	}
199	assert.Equal(t, pb.DiffSide_DIFF_SIDE_UNSPECIFIED, diffSideToPB(domain.SideNone), "a general comment")
200	_, err = diffSideFromPB(99)
201	assert.ErrorIs(t, err, domain.ErrInvalid)
202}
203
204func TestCommentID(t *testing.T) {
205	t.Parallel()
206	assert.EqualValues(t, 7, commentID("7"))
207	for _, bad := range []string{"", "0", "-1", "07", "+7", "x", "7 ", "9223372036854775808"} {
208		assert.Zero(t, commentID(bad), "%q names no comment", bad)
209	}
210}
211
212func TestRepoToPBAlwaysHasSettings(t *testing.T) {
213	t.Parallel()
214	// A repository value without settings (never stored with them) reads as the defaults.
215	r := repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x")}})
216	assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_FAST_FORWARD, pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes())
217	assert.True(t, r.GetSettings().GetFailingChecksBlock())
218
219	custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}}
220	r = repoToPB(ports.RepoView{Repo: domain.Repo{Name: mustName(t, "x"), Settings: custom}})
221	assert.Equal(t, []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH}, r.GetSettings().GetAllowedMergeModes())
222	assert.False(t, r.GetSettings().GetFailingChecksBlock())
223}
224
225func mustName(t *testing.T, s string) domain.RepoName {
226	t.Helper()
227	n, err := domain.ParseRepoName(s)
228	require.NoError(t, err)
229	return n
230}
231
232func TestUpdateParamsSettingsPaths(t *testing.T) {
233	t.Parallel()
234	msg := func(paths ...string) *pb.UpdateRepoRequest {
235		return &pb.UpdateRepoRequest{
236			UpdateMask: &fieldmaskpb.FieldMask{Paths: paths},
237			Settings: &pb.RepoSettings{
238				AllowedMergeModes:  []pb.MergeMode{pb.MergeMode_MERGE_MODE_SQUASH},
239				FailingChecksBlock: true,
240			},
241		}
242	}
243	p, err := updateParams(msg("settings"))
244	require.NoError(t, err)
245	require.NotNil(t, p.AllowedMergeModes)
246	assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, *p.AllowedMergeModes)
247	require.NotNil(t, p.FailingChecksBlock)
248	assert.True(t, *p.FailingChecksBlock)
249
250	p, err = updateParams(msg("settings.allowed_merge_modes"))
251	require.NoError(t, err)
252	assert.NotNil(t, p.AllowedMergeModes)
253	assert.Nil(t, p.FailingChecksBlock, "not named: left alone")
254
255	p, err = updateParams(msg("settings.failing_checks_block"))
256	require.NoError(t, err)
257	assert.Nil(t, p.AllowedMergeModes)
258	assert.NotNil(t, p.FailingChecksBlock)
259
260	// Modes named with nothing to take them from are an empty list, which the use case refuses.
261	p, err = updateParams(&pb.UpdateRepoRequest{UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"settings.allowed_merge_modes"}}})
262	require.NoError(t, err)
263	require.NotNil(t, p.AllowedMergeModes)
264	assert.Empty(t, *p.AllowedMergeModes)
265
266	_, err = updateParams(msg("settings.other"))
267	assert.ErrorIs(t, err, domain.ErrInvalid)
268}
269
270func TestConnectErrorCarriesTheFieldsOfARejection(t *testing.T) {
271	t.Parallel()
272	var v domain.Violations
273	v.Add("title", "must be one line")
274	v.Add("source_branch", "does not exist")
275	err := connectError(context.Background(), fmt.Errorf("create: %w", v.Err()))
276	var ce *connect.Error
277	require.ErrorAs(t, err, &ce)
278	assert.Equal(t, connect.CodeInvalidArgument, ce.Code())
279	require.Len(t, ce.Details(), 1)
280	val, derr := ce.Details()[0].Value()
281	require.NoError(t, derr)
282	br, ok := val.(*errdetails.BadRequest)
283	require.True(t, ok)
284	require.Len(t, br.GetFieldViolations(), 2)
285	assert.Equal(t, "title", br.GetFieldViolations()[0].GetField())
286	assert.Equal(t, "must be one line", br.GetFieldViolations()[0].GetDescription())
287
288	// Other errors carry no detail, and a text error alone is invalid_argument.
289	err = connectError(context.Background(), domain.ErrNotFound)
290	require.ErrorAs(t, err, &ce)
291	assert.Empty(t, ce.Details())
292	_, perr := domain.ParsePlainText("\x07")
293	err = connectError(context.Background(), perr)
294	require.ErrorAs(t, err, &ce)
295	assert.Equal(t, connect.CodeInvalidArgument, ce.Code())
296}
297
298func TestWatchEventToPBMergeRequestEvents(t *testing.T) {
299	t.Parallel()
300	repo := mustName(t, "proj")
301	title, err := domain.ParseMRTitle("Add x")
302	require.NoError(t, err)
303	mr := domain.MergeRequest{Repo: repo, Number: 4, Title: title, Source: "feat", Target: "main", State: domain.MRStateMerged, MergeMode: domain.MergeModeSquash}
304	at := time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)
305
306	for name, tc := range map[string]struct {
307		ev   domain.Event
308		pick func(*pb.Event) *pb.MergeRequest
309	}{
310		"opened": {domain.MergeRequestOpened{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestOpened().GetMergeRequest() }},
311		"merged": {domain.MergeRequestMerged{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestMerged().GetMergeRequest() }},
312		"closed": {domain.MergeRequestClosed{MergeRequest: mr, At: at}, func(e *pb.Event) *pb.MergeRequest { return e.GetMergeRequestClosed().GetMergeRequest() }},
313	} {
314		t.Run(name, func(t *testing.T) {
315			t.Parallel()
316			got := watchEventToPB(ports.WatchEvent{ID: "e1", Event: tc.ev})
317			require.NotNil(t, got)
318			assert.Equal(t, "e1", got.GetId())
319			assert.Equal(t, at, got.GetTime().AsTime())
320			m := tc.pick(got)
321			require.NotNil(t, m)
322			assert.Equal(t, "proj", m.GetRepo())
323			assert.EqualValues(t, 4, m.GetNumber())
324			assert.Equal(t, "Add x", m.GetTitle())
325			assert.Equal(t, pb.MergeMode_MERGE_MODE_SQUASH, m.GetMergeMode())
326		})
327	}
328
329	c := domain.Comment{ID: 9, Author: "root", Path: "a.txt", Line: 3, Side: domain.SideOld, CreatedAt: at}
330	got := watchEventToPB(ports.WatchEvent{ID: "e2", Event: domain.CommentAdded{Repo: repo, Number: 4, Comment: c, At: at}})
331	added := got.GetCommentAdded()
332	require.NotNil(t, added)
333	assert.Equal(t, "proj", added.GetRepo())
334	assert.EqualValues(t, 4, added.GetNumber())
335	assert.Equal(t, "9", added.GetComment().GetId())
336	assert.Equal(t, pb.DiffSide_DIFF_SIDE_OLD, added.GetComment().GetSide())
337	assert.EqualValues(t, 3, added.GetComment().GetLine())
338}

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 3

Tags 44

↑↓ moveenter openesc close