detached 83e495f

git_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package rpc
4
5import (
6	"strings"
7	"testing"
8
9	"connectrpc.com/connect"
10	"github.com/stretchr/testify/assert"
11	"github.com/stretchr/testify/mock"
12	"github.com/stretchr/testify/require"
13
14	pb "bnema.dev/ermite-api/gen/ermite"
15	"bnema.dev/ermite/internal/domain"
16	"bnema.dev/ermite/internal/ports"
17	"bnema.dev/ermite/internal/ports/mocks"
18)
19
20func TestGitServiceGetTree(t *testing.T) {
21	t.Parallel()
22	tests := []struct {
23		name     string
24		req      *pb.GetTreeRequest
25		usecase  error
26		wantCode connect.Code
27		wantCall bool
28	}{
29		{name: "ok", req: &pb.GetTreeRequest{Repo: "demo", Revision: "main", Path: "src"}, wantCall: true},
30		{name: "invalid repo", req: &pb.GetTreeRequest{Repo: "../x", Revision: "main"}, wantCode: connect.CodeInvalidArgument},
31		{name: "invalid path", req: &pb.GetTreeRequest{Repo: "demo", Revision: "main", Path: "a/../b"}, wantCode: connect.CodeInvalidArgument},
32		{name: "repo not visible", req: &pb.GetTreeRequest{Repo: "demo", Revision: "main"}, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true},
33		{name: "path is a file", req: &pb.GetTreeRequest{Repo: "demo", Revision: "main", Path: "f"}, usecase: domain.ErrNotDirectory, wantCode: connect.CodeInvalidArgument, wantCall: true},
34	}
35	for _, tt := range tests {
36		t.Run(tt.name, func(t *testing.T) {
37			t.Parallel()
38			browse := mocks.NewBrowseService(t)
39			if tt.wantCall {
40				browse.EXPECT().GetTree(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything, mock.Anything, false).
41					Return(domain.Tree{}, tt.usecase).Once()
42			}
43			resp, err := (&gitService{browse}).GetTree(ownerCtx(), connect.NewRequest(tt.req))
44			if tt.wantCode != 0 {
45				requireCode(t, tt.wantCode, err)
46				return
47			}
48			require.NoError(t, err)
49			assert.Empty(t, resp.Msg.GetEntries())
50		})
51	}
52}
53
54func mustObjectID(t *testing.T, s string) domain.ObjectID {
55	t.Helper()
56	id, err := domain.ParseObjectID(s)
57	require.NoError(t, err)
58	return id
59}
60
61func TestGitServiceListPaths(t *testing.T) {
62	t.Parallel()
63	commit := mustObjectID(t, strings.Repeat("a", 40))
64	tree := mustObjectID(t, strings.Repeat("b", 40))
65	tests := []struct {
66		name     string
67		req      *pb.ListPathsRequest
68		list     domain.PathList
69		usecase  error
70		wantCode connect.Code
71		wantCall bool
72	}{
73		{
74			name: "ok", req: &pb.ListPathsRequest{Repo: "demo", Revision: "main"}, wantCall: true,
75			list: domain.PathList{CommitID: commit, TreeID: tree, Paths: []string{"a.go", "b/c.go"}, Truncated: true},
76		},
77		{name: "default revision", req: &pb.ListPathsRequest{Repo: "demo"}, wantCall: true, list: domain.PathList{CommitID: commit, TreeID: tree}},
78		{name: "invalid repo", req: &pb.ListPathsRequest{Repo: "../x"}, wantCode: connect.CodeInvalidArgument},
79		{name: "repo not visible", req: &pb.ListPathsRequest{Repo: "demo"}, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true},
80		{name: "revision not found", req: &pb.ListPathsRequest{Repo: "demo", Revision: "nope"}, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true},
81	}
82	for _, tt := range tests {
83		t.Run(tt.name, func(t *testing.T) {
84			t.Parallel()
85			browse := mocks.NewBrowseService(t)
86			if tt.wantCall {
87				browse.EXPECT().ListPaths(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything).
88					Return(tt.list, tt.usecase).Once()
89			}
90			resp, err := (&gitService{browse}).ListPaths(ownerCtx(), connect.NewRequest(tt.req))
91			if tt.wantCode != 0 {
92				requireCode(t, tt.wantCode, err)
93				return
94			}
95			require.NoError(t, err)
96			assert.Equal(t, tt.list.CommitID.String(), resp.Msg.GetCommitId())
97			assert.Equal(t, tt.list.TreeID.String(), resp.Msg.GetTreeId())
98			assert.Equal(t, tt.list.Paths, resp.Msg.GetPaths())
99			assert.Equal(t, tt.list.Truncated, resp.Msg.GetTruncated())
100		})
101	}
102}
103
104func TestGitServiceListRefs(t *testing.T) {
105	t.Parallel()
106	tests := []struct {
107		name     string
108		req      *pb.ListRefsRequest
109		usecase  error
110		wantCode connect.Code
111		wantCall bool
112	}{
113		{name: "ok", req: &pb.ListRefsRequest{Repo: "demo", PageSize: 10}, wantCall: true},
114		{name: "unknown kind", req: &pb.ListRefsRequest{Repo: "demo", Kinds: []pb.RefKind{pb.RefKind(99)}}, wantCode: connect.CodeInvalidArgument},
115		{name: "invalid repo", req: &pb.ListRefsRequest{Repo: ""}, wantCode: connect.CodeInvalidArgument},
116		{name: "bad page token", req: &pb.ListRefsRequest{Repo: "demo", PageToken: "zz"}, usecase: domain.ErrInvalidPageToken, wantCode: connect.CodeInvalidArgument, wantCall: true},
117		{name: "not found", req: &pb.ListRefsRequest{Repo: "demo"}, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true},
118	}
119	for _, tt := range tests {
120		t.Run(tt.name, func(t *testing.T) {
121			t.Parallel()
122			browse := mocks.NewBrowseService(t)
123			if tt.wantCall {
124				browse.EXPECT().ListRefs(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything).
125					Return(ports.RefPage{NextPageToken: "n"}, tt.usecase).Once()
126			}
127			resp, err := (&gitService{browse}).ListRefs(ownerCtx(), connect.NewRequest(tt.req))
128			if tt.wantCode != 0 {
129				requireCode(t, tt.wantCode, err)
130				return
131			}
132			require.NoError(t, err)
133			assert.Equal(t, "n", resp.Msg.GetNextPageToken())
134		})
135	}
136}
137
138func TestGitServiceGetCommitAndResolveRevision(t *testing.T) {
139	t.Parallel()
140	invalid := []struct{ name, repo, rev string }{
141		{"invalid repo", "../x", "main"},
142		{"invalid revision", "demo", "a b"},
143	}
144	for _, tt := range invalid {
145		t.Run("GetCommit "+tt.name, func(t *testing.T) {
146			t.Parallel()
147			_, err := (&gitService{mocks.NewBrowseService(t)}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: tt.repo, Revision: tt.rev}))
148			requireCode(t, connect.CodeInvalidArgument, err)
149		})
150		t.Run("ResolveRevision "+tt.name, func(t *testing.T) {
151			t.Parallel()
152			_, err := (&gitService{mocks.NewBrowseService(t)}).ResolveRevision(ownerCtx(), connect.NewRequest(&pb.ResolveRevisionRequest{Repo: tt.repo, Revision: tt.rev}))
153			requireCode(t, connect.CodeInvalidArgument, err)
154		})
155	}
156
157	t.Run("GetCommit not found", func(t *testing.T) {
158		t.Parallel()
159		browse := mocks.NewBrowseService(t)
160		browse.EXPECT().GetCommit(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything, mock.Anything).Return(domain.CommitDetail{}, domain.ErrNotFound).Once()
161		_, err := (&gitService{browse}).GetCommit(ownerCtx(), connect.NewRequest(&pb.GetCommitRequest{Repo: "demo", Revision: "main"}))
162		requireCode(t, connect.CodeNotFound, err)
163	})
164	t.Run("ResolveRevision ok", func(t *testing.T) {
165		t.Parallel()
166		browse := mocks.NewBrowseService(t)
167		browse.EXPECT().ResolveRevision(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything).
168			Return(domain.ResolvedRevision{Ref: "refs/heads/main"}, nil).Once()
169		resp, err := (&gitService{browse}).ResolveRevision(ownerCtx(), connect.NewRequest(&pb.ResolveRevisionRequest{Repo: "demo", Revision: "main"}))
170		require.NoError(t, err)
171		assert.Equal(t, "refs/heads/main", resp.Msg.GetRef())
172	})
173}
174
175func TestGitServiceListCommits(t *testing.T) {
176	t.Parallel()
177	tests := []struct {
178		name     string
179		req      *pb.ListCommitsRequest
180		usecase  error
181		wantCode connect.Code
182		wantCall bool
183	}{
184		{name: "ok", req: &pb.ListCommitsRequest{Repo: "demo", Revision: "main"}, wantCall: true},
185		{name: "invalid path", req: &pb.ListCommitsRequest{Repo: "demo", Revision: "main", Path: "/abs/../x"}, wantCode: connect.CodeInvalidArgument},
186		{name: "bad page size", req: &pb.ListCommitsRequest{Repo: "demo", Revision: "main", PageSize: -1}, usecase: domain.ErrInvalidPageSize, wantCode: connect.CodeInvalidArgument, wantCall: true},
187		{name: "not found", req: &pb.ListCommitsRequest{Repo: "demo", Revision: "nope"}, usecase: domain.ErrNotFound, wantCode: connect.CodeNotFound, wantCall: true},
188	}
189	for _, tt := range tests {
190		t.Run(tt.name, func(t *testing.T) {
191			t.Parallel()
192			browse := mocks.NewBrowseService(t)
193			if tt.wantCall {
194				browse.EXPECT().ListCommits(mock.Anything, domain.Owner(), mustRepoName(t, "demo"), mock.Anything).
195					Return(ports.HistoryPage{}, tt.usecase).Once()
196			}
197			_, err := (&gitService{browse}).ListCommits(ownerCtx(), connect.NewRequest(tt.req))
198			if tt.wantCode != 0 {
199				requireCode(t, tt.wantCode, err)
200				return
201			}
202			require.NoError(t, err)
203		})
204	}
205}
206
207// GetBlob validates before it opens anything or touches the stream.
208func TestGitServiceGetBlobRejectsBadInput(t *testing.T) {
209	t.Parallel()
210	tests := []struct {
211		name string
212		req  *pb.GetBlobRequest
213	}{
214		{"invalid repo", &pb.GetBlobRequest{Repo: "../x", Revision: "main", Path: "f"}},
215		{"missing path", &pb.GetBlobRequest{Repo: "demo", Revision: "main"}},
216		{"invalid path", &pb.GetBlobRequest{Repo: "demo", Revision: "main", Path: "a/../b"}},
217	}
218	for _, tt := range tests {
219		t.Run(tt.name, func(t *testing.T) {
220			t.Parallel()
221			err := (&gitService{mocks.NewBrowseService(t)}).GetBlob(ownerCtx(), connect.NewRequest(tt.req), nil)
222			requireCode(t, connect.CodeInvalidArgument, err)
223		})
224	}
225}

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