1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "context"
7 "fmt"
8 "strings"
9 "sync"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15
16 "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"
18)
19
20func TestParseActionRef(t *testing.T) {
21 t.Parallel()
22 tests := []struct {
23 uses string
24 owner, repo, path, ref, fail string
25 }{
26 {uses: "actions/checkout@v4", owner: "actions", repo: "checkout", ref: "v4"},
27 {uses: "actions/cache/save@v4", owner: "actions", repo: "cache", path: "save", ref: "v4"},
28 {uses: "o/r/a/b/c@feature/x", owner: "o", repo: "r", path: "a/b/c", ref: "feature/x"},
29 {uses: "o\\r\\p@v1", owner: "o", repo: "r", path: "p", ref: "v1"}, // GitHub splits on both separators
30 {uses: "o//r@v1", owner: "o", repo: "r", ref: "v1"}, // and ignores empty segments
31 {uses: "noslash@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'noslash@v1'"},
32 {uses: "o/r", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r'"},
33 {uses: "o/r@", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r@'"},
34 {uses: "@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual '@v1'"},
35 {uses: "o/r@a@b", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r@a@b'"},
36 {uses: "o/r/../x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/../x@v1'"},
37 {uses: "o/r/./x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'o/r/./x@v1'"},
38 {uses: "a/...@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/...@v1'"},
39 {uses: "../..@" + strings.Repeat("a", 40), fail: "Expected format {org}/{repo}[/path]@ref. Actual '../..@" + strings.Repeat("a", 40) + "'"},
40 {uses: "a%2f/b@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a%2f/b@v1'"},
41 {uses: "a/b?x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/b?x@v1'"},
42 {uses: "a/b#x@v1", fail: "Expected format {org}/{repo}[/path]@ref. Actual 'a/b#x@v1'"},
43 {uses: "-a/_b.c@v1", owner: "-a", repo: "_b.c", ref: "v1"},
44 {uses: "", fail: "Expected format {org}/{repo}[/path]@ref. Actual ''"},
45 }
46 for _, tt := range tests {
47 t.Run(tt.uses, func(t *testing.T) {
48 t.Parallel()
49 a := parseActionRef(tt.uses)
50 assert.Equal(t, tt.fail, a.err)
51 if tt.fail == "" {
52 assert.Equal(t, []string{tt.owner, tt.repo, tt.path, tt.ref}, []string{a.owner, a.repo, a.path, a.ref})
53 }
54 })
55 }
56 assert.Equal(t, parseActionRef("Actions/Checkout@v4").key(), parseActionRef("actions/checkout@v4").key(), "names are case-insensitive")
57 assert.NotEqual(t, parseActionRef("a/b@V4").key(), parseActionRef("a/b@v4").key(), "refs are not")
58}
59
60func TestPickRemoteRef(t *testing.T) {
61 t.Parallel()
62 refs := []ports.RemoteRef{
63 {Name: "refs/heads/v1", ID: "b1"},
64 {Name: "refs/tags/v1", ID: "t1"},
65 {Name: "refs/tags/v2", ID: "tobj", Peeled: "c2"},
66 {Name: "refs/heads/main", ID: "m"},
67 }
68 assert.Equal(t, "t1", pickRemoteRef(refs, "v1"), "a tag before a branch")
69 assert.Equal(t, "c2", pickRemoteRef(refs, "v2"), "the peeled commit of an annotated tag")
70 assert.Equal(t, "m", pickRemoteRef(refs, "main"))
71 assert.Empty(t, pickRemoteRef(refs, "refs/heads/main"), "short names only")
72 assert.Empty(t, pickRemoteRef(refs, "nope"))
73 assert.Empty(t, pickRemoteRef(nil, "v1"))
74}
75
76// countingRemote answers ListRefs from a function and counts the calls.
77type countingRemote struct {
78 calls int
79 answer func() ([]ports.RemoteRef, error)
80}
81
82func (r *countingRemote) ListRefs(context.Context, string, string) ([]ports.RemoteRef, error) {
83 r.calls++
84 return r.answer()
85}
86
87// The refs of a github.com repository are listed once for the jobs of the next
88// minutes; when github.com does not answer, the last list is used.
89func TestRemoteRefsCache(t *testing.T) {
90 v4 := []ports.RemoteRef{{Name: "refs/tags/v4", ID: strings.Repeat("a", 40)}}
91 remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) { return v4, nil }}
92 now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
93 c := &CI{cfg: CIConfig{Remote: remote}, remoteSlots: make(chan struct{}, 1), remoteCache: newRefsCache()}
94 list := func() ([]ports.RemoteRef, error) {
95 r := c.newRemoteRefs(1)
96 r.now = func() time.Time { return now }
97 return r.list(context.Background(), "Actions", "checkout")
98 }
99
100 got, err := list()
101 require.NoError(t, err)
102 assert.Equal(t, v4, got)
103 now = now.Add(refsFresh - time.Second)
104 _, err = list()
105 require.NoError(t, err)
106 assert.Equal(t, 1, remote.calls, "a fresh list is not asked again, whatever the case of the name")
107 rerun := c.newRemoteRefs(2)
108 rerun.now = func() time.Time { return now }
109 _, err = rerun.list(context.Background(), "actions", "checkout")
110 require.NoError(t, err)
111 assert.Equal(t, 2, remote.calls, "a re-run asks again")
112
113 now = now.Add(refsFresh + time.Second) // the re-run listed it afresh
114 remote.answer = func() ([]ports.RemoteRef, error) { return nil, fmt.Errorf("%w: timeout", domain.ErrUnavailable) }
115 got, err = list()
116 require.NoError(t, err, "github.com did not answer: the last list is used")
117 assert.Equal(t, v4, got)
118 assert.Equal(t, 3, remote.calls, "an old list is asked again")
119
120 now = now.Add(refsStale)
121 _, err = list()
122 require.ErrorIs(t, err, domain.ErrUnavailable, "too old to stand in")
123
124 remote.answer = func() ([]ports.RemoteRef, error) { return nil, domain.ErrNotFound }
125 c.remoteCache = newRefsCache()
126 _, err = list()
127 require.ErrorIs(t, err, domain.ErrNotFound)
128 _, err = list()
129 require.ErrorIs(t, err, domain.ErrNotFound)
130 assert.Equal(t, 6, remote.calls, "a missing repository is not cached")
131}
132
133// A stale list stands in without becoming younger, the oldest repository makes
134// room for a new one, and the cache is safe for jobs resolving at once.
135func TestRefsCacheBounds(t *testing.T) {
136 v4 := []ports.RemoteRef{{Name: "refs/tags/v4", ID: strings.Repeat("a", 40)}}
137 now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
138 remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) { return v4, nil }}
139 c := &CI{cfg: CIConfig{Remote: remote}, remoteSlots: make(chan struct{}, 1), remoteCache: newRefsCache()}
140 list := func(repo string) error {
141 r := c.newRemoteRefs(1)
142 r.now = func() time.Time { return now }
143 _, err := r.list(context.Background(), "actions", repo)
144 return err
145 }
146 require.NoError(t, list("checkout"))
147 remote.answer = func() ([]ports.RemoteRef, error) { return nil, domain.ErrUnavailable }
148 now = now.Add(refsStale - time.Hour)
149 require.NoError(t, list("checkout"), "stands in")
150 now = now.Add(time.Hour)
151 require.ErrorIs(t, list("checkout"), domain.ErrUnavailable, "standing in did not make it younger")
152
153 cache := newRefsCache()
154 for i := range refsMax {
155 cache.put(fmt.Sprintf("o/r%d", i), v4, now.Add(time.Duration(i)*time.Second))
156 }
157 cache.put("o/new", v4, now.Add(time.Hour))
158 _, ok := cache.stale("o/r0", now)
159 assert.False(t, ok, "the oldest is evicted")
160 _, ok = cache.stale("o/r1", now.Add(time.Second))
161 assert.True(t, ok)
162 assert.Len(t, cache.m, refsMax)
163
164 var wg sync.WaitGroup
165 for i := range 8 {
166 wg.Go(func() {
167 key := fmt.Sprintf("o/r%d", i%3)
168 cache.put(key, v4, now)
169 cache.get(key, now)
170 cache.stale(key, now)
171 })
172 }
173 wg.Wait()
174}
175
176// An unanswered ls-remote fails the call with the action named.
177func TestResolveActionNamesTheActionGitHubDidNotAnswer(t *testing.T) {
178 remote := &countingRemote{answer: func() ([]ports.RemoteRef, error) {
179 return nil, fmt.Errorf("%w: git ls-remote actions/checkout did not answer within 20s (2 attempts)", domain.ErrUnavailable)
180 }}
181 c := &CI{cfg: CIConfig{Remote: remote, Owner: "owner", Clock: stillClock{}}, remoteSlots: make(chan struct{}, 1), remoteCache: newRefsCache()}
182 _, _, err := c.resolveAction(context.Background(), domain.CIJob{}, parseActionRef("actions/checkout@v4"), c.newRemoteRefs(1), pusherAccess{})
183 require.ErrorIs(t, err, domain.ErrUnavailable)
184 assert.Contains(t, err.Error(), "actions/checkout@v4")
185 assert.Contains(t, err.Error(), "github.com did not answer")
186}
187
188type stillClock struct{}
189
190func (stillClock) Now() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) }