1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "context"
7 "strings"
8 "sync"
9 "testing"
10 "time"
11
12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"
15
16 "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"
18 "bnema.dev/ermite/internal/ports/mocks"
19 "bnema.dev/ermite/internal/usecase"
20)
21
22func treePath(t *testing.T, s string) domain.TreePath {
23 t.Helper()
24 p, err := domain.ParseTreePath(s)
25 require.NoError(t, err)
26 return p
27}
28
29func treeWithLast(c string) domain.Tree {
30 commit := domain.Commit{ID: oid(c), Parents: []domain.ObjectID{oid("0")}, Summary: "s"}
31 return domain.Tree{
32 CommitID: oid("a"),
33 Entries: []domain.TreeEntry{{Name: "f", Path: "d/f", LastCommit: &commit}},
34 }
35}
36
37// TestGetTreeLastCommitCache covers which GetTree calls are served from the
38// cache: only the ones with last commits at a full commit id.
39func TestGetTreeLastCommitCache(t *testing.T) {
40 t.Parallel()
41 ctx := context.Background()
42 pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
43 full := domain.RevisionOf(oid("a"))
44 branch, err := domain.ParseRevision("main")
45 require.NoError(t, err)
46 dir := treePath(t, "d")
47
48 tests := []struct {
49 name string
50 rev domain.Revision
51 includeLast bool
52 wantReaderOn int // reader calls for two identical requests
53 }{
54 {"full id with last commits is cached", full, true, 1},
55 {"full id without last commits is not", full, false, 2},
56 {"branch name is not", branch, true, 2},
57 {"HEAD is not", domain.Revision{}, true, 2},
58 }
59 for _, tc := range tests {
60 t.Run(tc.name, func(t *testing.T) {
61 t.Parallel()
62 f := newBrowseFixture(t)
63 f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil)
64 f.reader.EXPECT().GetTree(mock.Anything, pub.Name, tc.rev, dir, tc.includeLast).
65 Return(treeWithLast("b"), nil).Times(tc.wantReaderOn)
66 for range 2 {
67 tree, err := f.browse.GetTree(ctx, domain.Anonymous(), pub.Name, tc.rev, dir, tc.includeLast)
68 require.NoError(t, err)
69 assert.Equal(t, "d/f", tree.Entries[0].Path)
70 }
71 })
72 }
73}
74
75func TestGetTreeLastCommitCacheKey(t *testing.T) {
76 t.Parallel()
77 ctx := context.Background()
78 pubA := domain.Repo{Name: name(t, "a"), Visibility: domain.VisibilityPublic}
79 pubB := domain.Repo{Name: name(t, "b"), Visibility: domain.VisibilityPublic}
80 revA, revB := domain.RevisionOf(oid("a")), domain.RevisionOf(oid("b"))
81 d1, d2 := treePath(t, "d1"), treePath(t, "d2")
82
83 // Each distinct (repo, commit, path) is one reader call, then a hit.
84 keys := []struct {
85 repo domain.Repo
86 rev domain.Revision
87 path domain.TreePath
88 }{
89 {pubA, revA, d1}, {pubB, revA, d1}, {pubA, revB, d1}, {pubA, revA, d2}, {pubA, revA, domain.TreePath{}},
90 }
91 f := newBrowseFixture(t)
92 f.meta.EXPECT().Get(mock.Anything, mock.Anything).RunAndReturn(
93 func(_ context.Context, n domain.RepoName) (domain.Repo, error) {
94 return domain.Repo{Name: n, Visibility: domain.VisibilityPublic}, nil
95 })
96 for _, k := range keys {
97 f.reader.EXPECT().GetTree(mock.Anything, k.repo.Name, k.rev, k.path, true).Return(treeWithLast("b"), nil).Once()
98 }
99 for range 2 {
100 for _, k := range keys {
101 _, err := f.browse.GetTree(ctx, domain.Anonymous(), k.repo.Name, k.rev, k.path, true)
102 require.NoError(t, err)
103 }
104 }
105}
106
107// TestHistoryCacheNotServedAcrossRecreate: a repository deleted and created
108// again under the same name does not get the answers of the old one for a
109// commit id they both could hold.
110func TestHistoryCacheNotServedAcrossRecreate(t *testing.T) {
111 t.Parallel()
112 ctx := context.Background()
113 created := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
114 rev, dir := domain.RevisionOf(oid("a")), treePath(t, "d")
115 n := name(t, "pub")
116 old := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: created}
117 again := domain.Repo{Name: n, Visibility: domain.VisibilityPublic, CreatedAt: created.Add(time.Hour)}
118
119 tests := []struct {
120 name string
121 call func(b *usecase.Browse) error
122 // expect registers the reader call of the old repository (answers)
123 // and of the new one (not found).
124 expect func(r *mocks.RepoReader)
125 }{
126 {"tree with last commits", func(b *usecase.Browse) error {
127 _, err := b.GetTree(ctx, domain.Anonymous(), n, rev, dir, true)
128 return err
129 }, func(r *mocks.RepoReader) {
130 r.EXPECT().GetTree(mock.Anything, n, rev, dir, true).Return(treeWithLast("b"), nil).Once()
131 r.EXPECT().GetTree(mock.Anything, n, rev, dir, true).Return(domain.Tree{}, domain.ErrNotFound).Once()
132 }},
133 {"last change of a path", func(b *usecase.Browse) error {
134 _, err := b.ListCommits(ctx, domain.Anonymous(), n, ports.ListCommitsParams{Revision: rev, Path: dir, PageSize: 1})
135 return err
136 }, func(r *mocks.RepoReader) {
137 q := ports.CommitQuery{Revision: rev, Path: dir, Limit: 1}
138 r.EXPECT().ListCommits(mock.Anything, n, q).Return(ports.CommitPage{
139 Head: oid("a"), Commits: []domain.Commit{{ID: oid("c")}},
140 }, nil).Once()
141 r.EXPECT().ListCommits(mock.Anything, n, q).Return(ports.CommitPage{}, domain.ErrNotFound).Once()
142 }},
143 }
144 for _, tc := range tests {
145 t.Run(tc.name, func(t *testing.T) {
146 t.Parallel()
147 f := newBrowseFixture(t)
148 f.meta.EXPECT().Get(mock.Anything, n).Return(old, nil).Twice() // warm, hit
149 f.meta.EXPECT().Get(mock.Anything, n).Return(again, nil).Once()
150 tc.expect(f.reader)
151 require.NoError(t, tc.call(f.browse))
152 require.NoError(t, tc.call(f.browse)) // served from the cache
153 assert.ErrorIs(t, tc.call(f.browse), domain.ErrNotFound)
154 })
155 }
156}
157
158// TestGetTreeCacheKeepsAccessRule: a hit never skips the access check, and an
159// error is not cached.
160func TestGetTreeCacheKeepsAccessRule(t *testing.T) {
161 t.Parallel()
162 ctx := context.Background()
163 priv := domain.Repo{Name: name(t, "priv"), Visibility: domain.VisibilityPrivate}
164 rev, dir := domain.RevisionOf(oid("a")), treePath(t, "d")
165
166 f := newBrowseFixture(t)
167 f.meta.EXPECT().Get(mock.Anything, priv.Name).Return(priv, nil)
168 f.reader.EXPECT().GetTree(mock.Anything, priv.Name, rev, dir, true).Return(domain.Tree{}, domain.ErrNotDirectory).Once()
169 f.reader.EXPECT().GetTree(mock.Anything, priv.Name, rev, dir, true).Return(treeWithLast("b"), nil).Once()
170
171 _, err := f.browse.GetTree(ctx, domain.Root(), priv.Name, rev, dir, true)
172 require.ErrorIs(t, err, domain.ErrNotDirectory) // not cached
173 _, err = f.browse.GetTree(ctx, domain.Root(), priv.Name, rev, dir, true)
174 require.NoError(t, err) // now cached
175 _, err = f.browse.GetTree(ctx, domain.Anonymous(), priv.Name, rev, dir, true)
176 assert.ErrorIs(t, err, domain.ErrNotFound)
177}
178
179// TestGetTreeCacheReturnsCopies: a caller that edits its tree does not alter
180// what the next one gets.
181func TestGetTreeCacheReturnsCopies(t *testing.T) {
182 t.Parallel()
183 ctx := context.Background()
184 pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
185 rev, dir := domain.RevisionOf(oid("a")), treePath(t, "d")
186 f := newBrowseFixture(t)
187 f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil)
188 f.reader.EXPECT().GetTree(mock.Anything, pub.Name, rev, dir, true).Return(treeWithLast("b"), nil).Once()
189
190 for range 3 {
191 tree, err := f.browse.GetTree(ctx, domain.Anonymous(), pub.Name, rev, dir, true)
192 require.NoError(t, err)
193 require.Len(t, tree.Entries, 1)
194 assert.Equal(t, "s", tree.Entries[0].LastCommit.Summary)
195 tree.Entries[0].LastCommit.Summary = "changed"
196 tree.Entries[0].LastCommit.Parents[0] = oid("f")
197 tree.Entries[0].Name = "changed"
198 }
199}
200
201// TestHistoryCacheBounds: the entry bound and the byte bound evict the least
202// recently used answer.
203func TestHistoryCacheBounds(t *testing.T) {
204 t.Parallel()
205 ctx := context.Background()
206 pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
207 paths := []string{"p0", "p1", "p2"}
208
209 bigTree := treeWithLast("b")
210 bigTree.Entries[0].Name = strings.Repeat("x", 4096)
211
212 tests := []struct {
213 name string
214 entries int
215 bytes int64
216 tree domain.Tree
217 wantCached []bool // after requesting p0, p1, p2 (and touching p0 before p2 when touch)
218 touch bool
219 }{
220 {"entry bound", 2, 1 << 20, treeWithLast("b"), []bool{false, true, true}, false},
221 {"entry bound is LRU", 2, 1 << 20, treeWithLast("b"), []bool{true, false, true}, true},
222 {"byte bound", 100, 6000, bigTree, []bool{false, false, true}, false},
223 {"entry larger than the byte bound", 100, 1000, bigTree, []bool{false, false, false}, false},
224 {"disabled by entries", 0, 1 << 20, treeWithLast("b"), []bool{false, false, false}, false},
225 {"disabled by bytes", 10, 0, treeWithLast("b"), []bool{false, false, false}, false},
226 }
227 for _, tc := range tests {
228 t.Run(tc.name, func(t *testing.T) {
229 t.Parallel()
230 f := newBrowseFixture(t)
231 f.browse.WithHistoryCache(tc.entries, tc.bytes)
232 f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil)
233 rev := domain.RevisionOf(oid("a"))
234 calls := map[string]int{}
235 f.reader.EXPECT().GetTree(mock.Anything, pub.Name, rev, mock.Anything, true).
236 RunAndReturn(func(_ context.Context, _ domain.RepoName, _ domain.Revision, p domain.TreePath, _ bool) (domain.Tree, error) {
237 calls[p.String()]++
238 return tc.tree, nil
239 })
240 get := func(p string) {
241 _, err := f.browse.GetTree(ctx, domain.Anonymous(), pub.Name, rev, treePath(t, p), true)
242 require.NoError(t, err)
243 }
244 get("p0")
245 get("p1")
246 if tc.touch {
247 get("p0")
248 }
249 get("p2")
250 before := map[string]int{"p0": calls["p0"], "p1": calls["p1"], "p2": calls["p2"]}
251 // Probe from the newest to the oldest so probing one does not evict
252 // the next one asked: asking a miss stores it.
253 for i := len(paths) - 1; i >= 0; i-- {
254 if !tc.wantCached[i] {
255 continue
256 }
257 get(paths[i])
258 assert.Equal(t, before[paths[i]], calls[paths[i]], "%s should be a hit", paths[i])
259 }
260 for i, p := range paths {
261 if tc.wantCached[i] {
262 continue
263 }
264 get(p)
265 assert.Equal(t, before[p]+1, calls[p], "%s should be a miss", p)
266 break // a miss stores the entry and may evict the others
267 }
268 })
269 }
270}
271
272// TestListCommitsLastChangeCache: the latest commit of a path at a full
273// commit id is cached; every other page shape is not.
274func TestListCommitsLastChangeCache(t *testing.T) {
275 t.Parallel()
276 ctx := context.Background()
277 pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
278 full := domain.RevisionOf(oid("a"))
279 main, err := domain.ParseRevision("main")
280 require.NoError(t, err)
281 file := treePath(t, "d/file.go")
282
283 tests := []struct {
284 name string
285 rev domain.Revision
286 path domain.TreePath
287 size int
288 calls int // reader calls for two identical requests
289 }{
290 {"last change at a full id", full, file, 1, 1},
291 {"last change of the root at a full id", full, domain.TreePath{}, 1, 1},
292 {"name", main, file, 1, 2},
293 {"HEAD", domain.Revision{}, file, 1, 2},
294 {"larger page", full, file, 2, 2},
295 }
296 for _, tc := range tests {
297 t.Run(tc.name, func(t *testing.T) {
298 t.Parallel()
299 f := newBrowseFixture(t)
300 f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil)
301 q := ports.CommitQuery{Revision: tc.rev, Path: tc.path, Limit: tc.size}
302 f.reader.EXPECT().ListCommits(mock.Anything, pub.Name, q).Return(ports.CommitPage{
303 Head: oid("a"),
304 Commits: []domain.Commit{{ID: oid("c"), Parents: []domain.ObjectID{oid("d")}}},
305 }, nil).Times(tc.calls)
306 for range 2 {
307 page, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name,
308 ports.ListCommitsParams{Revision: tc.rev, Path: tc.path, PageSize: tc.size})
309 require.NoError(t, err)
310 require.Len(t, page.Commits, 1)
311 page.Commits[0].Parents[0] = oid("f") // must not leak into the cache
312 }
313 })
314 }
315
316 t.Run("a following page is not cached", func(t *testing.T) {
317 t.Parallel()
318 f := newBrowseFixture(t)
319 f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil)
320 f.reader.EXPECT().ListCommits(mock.Anything, pub.Name, ports.CommitQuery{Revision: full, Path: file, Limit: 1}).
321 Return(ports.CommitPage{Head: oid("a"), Commits: []domain.Commit{{ID: oid("c")}}, HasMore: true}, nil).Once()
322 first, err := f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Revision: full, Path: file, PageSize: 1})
323 require.NoError(t, err)
324 require.NotEmpty(t, first.NextPageToken)
325 f.reader.EXPECT().ListCommits(mock.Anything, pub.Name, ports.CommitQuery{Revision: domain.RevisionOf(oid("a")), Path: file, Skip: 1, Limit: 1}).
326 Return(ports.CommitPage{Head: oid("a")}, nil).Twice()
327 for range 2 {
328 _, err = f.browse.ListCommits(ctx, domain.Anonymous(), pub.Name, ports.ListCommitsParams{Path: file, PageSize: 1, PageToken: first.NextPageToken})
329 require.NoError(t, err)
330 }
331 })
332}
333
334func TestHistoryCacheConcurrent(t *testing.T) {
335 t.Parallel()
336 ctx := context.Background()
337 pub := domain.Repo{Name: name(t, "pub"), Visibility: domain.VisibilityPublic}
338 f := newBrowseFixture(t)
339 f.browse.WithHistoryCache(4, 1<<20)
340 f.meta.EXPECT().Get(mock.Anything, pub.Name).Return(pub, nil)
341 f.reader.EXPECT().GetTree(mock.Anything, pub.Name, mock.Anything, mock.Anything, true).Return(treeWithLast("b"), nil)
342 paths := []string{"a", "b", "c", "d", "e", "f", "g"}
343 var wg sync.WaitGroup
344 for w := range 8 {
345 wg.Go(func() {
346 for i := range 200 {
347 p, err := domain.ParseTreePath(paths[(i+w)%len(paths)])
348 if err != nil {
349 t.Error(err)
350 return
351 }
352 if _, err := f.browse.GetTree(ctx, domain.Anonymous(), pub.Name, domain.RevisionOf(oid("a")), p, true); err != nil {
353 t.Error(err)
354 return
355 }
356 }
357 })
358 }
359 wg.Wait()
360}