1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package readertest
4
5import (
6 "bytes"
7 "context"
8 "errors"
9 "io"
10 "os"
11 "os/exec"
12 "strings"
13 "testing"
14
15 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require"
17
18 "bnema.dev/ermite/internal/domain"
19 "bnema.dev/ermite/internal/ports"
20)
21
22// Budget bounds how many revisions and paths Compare asks about, so that it
23// stays fast on a large repository.
24type Budget struct {
25 Revs int // revisions (refs and commits) for trees and blobs
26 LogRevs int // revisions for history pages
27 Dirs int // directories per revision
28 Files int // files per revision
29 LogPaths int // paths for path-limited history
30 LastTrees int // trees listed with last commits
31}
32
33// SmallBudget suits the fixtures.
34var SmallBudget = Budget{Revs: 24, LogRevs: 8, Dirs: 10, Files: 10, LogPaths: 6, LastTrees: 6}
35
36// LargeBudget suits a repository of thousands of commits.
37var LargeBudget = Budget{Revs: 4, LogRevs: 2, Dirs: 6, Files: 8, LogPaths: 5, LastTrees: 3}
38
39// errClass names the kind of an error of the port, so that two readers agree
40// on what went wrong without agreeing on the message.
41func errClass(err error) string {
42 switch {
43 case err == nil:
44 return "ok"
45 case errors.Is(err, domain.ErrNotFound):
46 return "not found"
47 case errors.Is(err, domain.ErrNotFile):
48 return "not a file"
49 case errors.Is(err, domain.ErrNotDirectory):
50 return "not a directory"
51 case errors.Is(err, domain.ErrInvalidPageSize):
52 return "invalid page size"
53 case errors.Is(err, domain.ErrInvalidPath):
54 return "invalid path"
55 case errors.Is(err, domain.ErrInvalidObjectID):
56 return "invalid object id"
57 case errors.Is(err, domain.ErrInvalidRevision):
58 return "invalid revision"
59 case errors.Is(err, domain.ErrInvalidPageToken):
60 return "invalid page token"
61 case errors.Is(err, domain.ErrInvalidRefKind):
62 return "invalid ref kind"
63 case errors.Is(err, domain.ErrInvalid):
64 return "invalid argument"
65 case errors.Is(err, context.Canceled):
66 return "canceled"
67 }
68 return "other" // outside the domain errors: the messages of two readers differ
69}
70
71// Queries are the questions put to both readers about one repository.
72type Queries struct {
73 Revs []string // revisions as a client writes them
74 LogRevs []string
75 Dirs []string
76 Files []string
77 LogPaths []string
78 RefPaths []string
79}
80
81// GitOut runs git in dir and returns its trimmed output.
82func GitOut(t testing.TB, dir string, args ...string) string {
83 t.Helper()
84 cmd := exec.Command("git", append([]string{"--git-dir=" + dir}, args...)...)
85 cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + dir, "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "LC_ALL=C"}
86 out, err := cmd.Output()
87 require.NoError(t, err, "git %s", strings.Join(args, " "))
88 return strings.TrimRight(string(out), "\n")
89}
90
91func lines(s string) []string {
92 if s == "" {
93 return nil
94 }
95 return strings.Split(s, "\n")
96}
97
98// spread picks at most n elements of s, evenly, always the first.
99func spread[T any](s []T, n int) []T {
100 if len(s) <= n || n <= 0 {
101 return s
102 }
103 out := make([]T, 0, n)
104 for i := range n {
105 out = append(out, s[i*len(s)/n])
106 }
107 return out
108}
109
110// Derive builds the questions for the repository in dir with git.
111func Derive(t testing.TB, dir string, b Budget) Queries {
112 t.Helper()
113 var q Queries
114 revs := []string{"", "HEAD", "nope", "deadbeefdeadbeef", strings.Repeat("ab", 20), "refs/heads", "refs/nope/x"}
115 for _, l := range lines(GitOut(t, dir, "for-each-ref", "--format=%(refname)", "refs/heads", "refs/tags")) {
116 revs = append(revs, l)
117 if short, ok := strings.CutPrefix(l, "refs/heads/"); ok {
118 revs = append(revs, short)
119 } else if short, ok := strings.CutPrefix(l, "refs/tags/"); ok {
120 revs = append(revs, short)
121 }
122 }
123 for _, l := range lines(GitOut(t, dir, "for-each-ref", "--format=%(objectname)", "refs/heads", "refs/tags")) {
124 revs = append(revs, l, l[:7], l[:12]) // tag objects too: they are not commits when they lead elsewhere
125 }
126 var commits []string
127 {
128 // An empty repository has no commit: rev-list --all prints nothing.
129 cmd := exec.Command("git", "--git-dir="+dir, "rev-list", "--all", "--missing=allow-any")
130 cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + dir, "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null"}
131 out, err := cmd.Output()
132 require.NoError(t, err)
133 commits = lines(strings.TrimRight(string(out), "\n"))
134 }
135 for _, c := range spread(commits, b.Revs) {
136 revs = append(revs, c, c[:7], c[:10])
137 }
138 if len(commits) > 0 {
139 tree := GitOut(t, dir, "rev-parse", commits[0]+"^{tree}")
140 revs = append(revs, tree, tree[:8]) // a tree is not a commit
141 }
142 q.Revs = dedupe(revs)
143 var logRevs []string
144 for _, r := range q.Revs {
145 if r == "" || r == "HEAD" || strings.HasPrefix(r, "refs/") || len(r) == 40 {
146 logRevs = append(logRevs, r)
147 }
148 }
149 q.LogRevs = spread(logRevs, b.LogRevs+4)
150
151 // Paths of the newest commit of HEAD, or of the first commit.
152 if len(commits) > 0 {
153 var dirs, files []string
154 for _, l := range lines(GitOut(t, dir, "ls-tree", "-r", "-t", "--format=%(objecttype) %(path)", commits[0])) {
155 typ, p, _ := strings.Cut(l, " ")
156 switch typ {
157 case "tree":
158 dirs = append(dirs, p)
159 case "blob":
160 files = append(files, p)
161 }
162 }
163 q.Dirs = append([]string{""}, spread(dirs, b.Dirs)...)
164 q.Dirs = append(q.Dirs, "nope", "nope/deeper", "*")
165 if len(files) > 0 {
166 q.Dirs = append(q.Dirs, files[0]) // a file where a directory is wanted
167 }
168 q.Files = append(spread(files, b.Files), "", "nope", "nope/x")
169 if len(dirs) > 0 {
170 q.Files = append(q.Files, dirs[0]) // a directory where a file is wanted
171 }
172 q.LogPaths = append([]string{""}, spread(files, b.LogPaths/2+1)...)
173 q.LogPaths = append(q.LogPaths, spread(dirs, b.LogPaths/2+1)...)
174 q.LogPaths = append(q.LogPaths, "nope", "*")
175 q.LogPaths = dedupe(q.LogPaths)
176 }
177 for _, r := range q.Revs {
178 if r != "" {
179 q.RefPaths = append(q.RefPaths, r, r+"/dir/file", r+"/x")
180 }
181 }
182 q.RefPaths = append(q.RefPaths, "", "/", "nope", "nope/x", "a//b", "../x", "main/", "HEAD/x", "refs/heads/main/x")
183 q.RefPaths = dedupe(spread(q.RefPaths, 4*b.Revs+12))
184 return q
185}
186
187func dedupe(s []string) []string {
188 seen := make(map[string]bool, len(s))
189 out := s[:0:0]
190 for _, v := range s {
191 if !seen[v] {
192 seen[v] = true
193 out = append(out, v)
194 }
195 }
196 return out
197}
198
199// Compare asks both readers the same questions about the repository and
200// requires the same answers: the same values, and the same kind of error. want
201// is the oracle (the CLI reader).
202func Compare(t *testing.T, want, got ports.RepoReader, repo domain.RepoName, dir string, b Budget) {
203 t.Helper()
204 ctx := context.Background()
205 q := Derive(t, dir, b)
206 t.Logf("%d revisions, %d dirs, %d files, %d log paths", len(q.Revs), len(q.Dirs), len(q.Files), len(q.LogPaths))
207
208 t.Run("ListRefs", func(t *testing.T) {
209 w, werr := want.ListRefs(ctx, repo)
210 g, gerr := got.ListRefs(ctx, repo)
211 require.Equal(t, errClass(werr), errClass(gerr))
212 assert.Equal(t, w, g)
213 })
214 t.Run("ResolveRevision", func(t *testing.T) {
215 for _, s := range q.Revs {
216 rev, err := domain.ParseRevision(s)
217 if err != nil {
218 continue
219 }
220 w, werr := want.ResolveRevision(ctx, repo, rev)
221 g, gerr := got.ResolveRevision(ctx, repo, rev)
222 if assert.Equal(t, errClass(werr), errClass(gerr), "revision %q", s) {
223 assert.Equal(t, w, g, "revision %q", s)
224 }
225 }
226 })
227 t.Run("ResolveRefPath", func(t *testing.T) {
228 for _, p := range q.RefPaths {
229 w, werr := want.ResolveRefPath(ctx, repo, p)
230 g, gerr := got.ResolveRefPath(ctx, repo, p)
231 if assert.Equal(t, errClass(werr), errClass(gerr), "path %q", p) {
232 assert.Equal(t, w, g, "path %q", p)
233 }
234 }
235 })
236 revs := make([]domain.Revision, 0, len(q.Revs))
237 for _, s := range spread(q.Revs, b.Revs+8) {
238 if rev, err := domain.ParseRevision(s); err == nil {
239 revs = append(revs, rev)
240 }
241 }
242 t.Run("GetTree", func(t *testing.T) {
243 last := 0
244 for _, rev := range revs {
245 for _, p := range q.Dirs {
246 path, err := domain.ParseTreePath(p)
247 if err != nil {
248 continue
249 }
250 w, werr := want.GetTree(ctx, repo, rev, path, false)
251 g, gerr := got.GetTree(ctx, repo, rev, path, false)
252 if assert.Equal(t, errClass(werr), errClass(gerr), "tree %q at %q", p, rev) {
253 assert.Equal(t, w, g, "tree %q at %q", p, rev)
254 }
255 // Last commits for the first few revisions only: each is a history walk.
256 if werr == nil && last < b.LastTrees*3 && len(w.Entries) > 0 {
257 last++
258 w, werr := want.GetTree(ctx, repo, rev, path, true)
259 g, gerr := got.GetTree(ctx, repo, rev, path, true)
260 if assert.Equal(t, errClass(werr), errClass(gerr), "tree+last %q at %q", p, rev) {
261 assert.Equal(t, w, g, "tree+last %q at %q", p, rev)
262 }
263 }
264 }
265 }
266 })
267 t.Run("ListPaths", func(t *testing.T) {
268 for _, rev := range revs {
269 for _, lim := range [...][2]int{{domain.MaxPathEntries, domain.MaxPathBytes}, {5, domain.MaxPathBytes}, {domain.MaxPathEntries, 60}} {
270 w, werr := want.ListPaths(ctx, repo, rev, lim[0], lim[1])
271 g, gerr := got.ListPaths(ctx, repo, rev, lim[0], lim[1])
272 if assert.Equal(t, errClass(werr), errClass(gerr), "paths at %q %v", rev, lim) {
273 assert.Equal(t, w, g, "paths at %q %v", rev, lim)
274 }
275 }
276 }
277 })
278 t.Run("OpenBlob", func(t *testing.T) {
279 read := func(r ports.RepoReader, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, []byte, error) {
280 info, rc, err := r.OpenBlob(ctx, repo, rev, path)
281 if err != nil {
282 require.Nil(t, rc)
283 return info, nil, err
284 }
285 body, rerr := io.ReadAll(rc)
286 require.NoError(t, rerr)
287 require.NoError(t, rc.Close())
288 return info, body, nil
289 }
290 for _, rev := range spread(revs, b.Revs/2+4) {
291 for _, p := range q.Files {
292 path, err := domain.ParseTreePath(p)
293 if err != nil {
294 continue
295 }
296 wi, wb, werr := read(want, rev, path)
297 gi, gb, gerr := read(got, rev, path)
298 if assert.Equal(t, errClass(werr), errClass(gerr), "blob %q at %q", p, rev) && werr == nil {
299 assert.Equal(t, wi, gi, "blob %q at %q", p, rev)
300 assert.True(t, bytes.Equal(wb, gb), "content of %q at %q differs", p, rev)
301 }
302 }
303 }
304 })
305 t.Run("ListCommits", func(t *testing.T) {
306 pages := [...][2]int{{0, 1000}, {0, 3}, {2, 3}, {5, 2}, {40, 7}, {5000, 20}}
307 ids := func(p ports.CommitPage) string {
308 var sb strings.Builder
309 for _, c := range p.Commits {
310 sb.WriteString(c.ID.String()[:8] + " ")
311 }
312 return sb.String()
313 }
314 for _, s := range q.LogRevs {
315 rev, err := domain.ParseRevision(s)
316 if err != nil {
317 continue
318 }
319 for _, p := range q.LogPaths {
320 path, err := domain.ParseTreePath(p)
321 if err != nil {
322 continue
323 }
324 for _, pg := range pages {
325 cq := ports.CommitQuery{Revision: rev, Path: path, Skip: pg[0], Limit: pg[1]}
326 w, werr := want.ListCommits(ctx, repo, cq)
327 g, gerr := got.ListCommits(ctx, repo, cq)
328 if assert.Equal(t, errClass(werr), errClass(gerr), "log %q -- %q %v", s, p, pg) {
329 if !assert.Equal(t, w, g, "log %q -- %q %v\nwant %s\n got %s", s, p, pg, ids(w), ids(g)) {
330 return
331 }
332 }
333 }
334 }
335 }
336 for _, cq := range []ports.CommitQuery{{Limit: 0}, {Limit: -1}, {Limit: ports.MaxCommitLimit + 1}, {Limit: 1, Skip: -1}} {
337 _, werr := want.ListCommits(ctx, repo, cq)
338 _, gerr := got.ListCommits(ctx, repo, cq)
339 assert.Equal(t, errClass(werr), errClass(gerr), "%+v", cq)
340 }
341 })
342 t.Run("missing repository", func(t *testing.T) {
343 nope := RepoName(t, "no-such-repo")
344 for name, call := range map[string]func(ports.RepoReader) error{
345 "ListRefs": func(r ports.RepoReader) error { _, err := r.ListRefs(ctx, nope); return err },
346 "ResolveRevision": func(r ports.RepoReader) error { _, err := r.ResolveRevision(ctx, nope, domain.Revision{}); return err },
347 "ResolveRefPath": func(r ports.RepoReader) error { _, err := r.ResolveRefPath(ctx, nope, "main"); return err },
348 "GetTree": func(r ports.RepoReader) error {
349 _, err := r.GetTree(ctx, nope, domain.Revision{}, domain.TreePath{}, false)
350 return err
351 },
352 "ListPaths": func(r ports.RepoReader) error {
353 _, err := r.ListPaths(ctx, nope, domain.Revision{}, domain.MaxPathEntries, domain.MaxPathBytes)
354 return err
355 },
356 "OpenBlob": func(r ports.RepoReader) error {
357 _, _, err := r.OpenBlob(ctx, nope, domain.Revision{}, domain.TreePath{})
358 return err
359 },
360 "ListCommits": func(r ports.RepoReader) error {
361 _, err := r.ListCommits(ctx, nope, ports.CommitQuery{Limit: 1})
362 return err
363 },
364 } {
365 assert.Equal(t, errClass(call(want)), errClass(call(got)), name)
366 }
367 })
368}