1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package cisummaries_test
4
5import (
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13
14 "bnema.dev/ermite/internal/adapters/cisummaries"
15 "bnema.dev/ermite/internal/domain"
16 "bnema.dev/ermite/internal/ports"
17)
18
19var ref = ports.CILogRef{RunID: 7, JobID: "7-0-0", Attempt: 1}
20
21func TestPutListReplaceAndRemove(t *testing.T) {
22 ctx := t.Context()
23 root := t.TempDir()
24 s := cisummaries.New(root)
25
26 got, more, err := s.List(ctx, ref, 20, 0)
27 require.NoError(t, err)
28 assert.Empty(t, got)
29 assert.False(t, more)
30
31 require.NoError(t, s.Put(ctx, ref, 10, "# ten"))
32 require.NoError(t, s.Put(ctx, ref, 2, "two"))
33 // The layout is the documented one.
34 b, err := os.ReadFile(filepath.Join(root, "7", "7-0-0", "1", "2.md"))
35 require.NoError(t, err)
36 assert.Equal(t, "two", string(b))
37
38 got, _, err = s.List(ctx, ref, 20, 0)
39 require.NoError(t, err)
40 assert.Equal(t, []domain.CIStepSummary{{Step: 2, Markdown: "two"}, {Step: 10, Markdown: "# ten"}}, got, "by step number, not by name")
41
42 // Replace, and an empty markdown removes.
43 require.NoError(t, s.Put(ctx, ref, 2, "again"))
44 got, _, _ = s.List(ctx, ref, 20, 0)
45 assert.Equal(t, "again", got[0].Markdown)
46 require.NoError(t, s.Put(ctx, ref, 2, ""))
47 require.NoError(t, s.Put(ctx, ref, 3, ""), "removing what is not there is fine")
48 got, _, _ = s.List(ctx, ref, 20, 0)
49 require.Len(t, got, 1)
50 assert.EqualValues(t, 10, got[0].Step)
51
52 // Another attempt and another job have their own.
53 other := ports.CILogRef{RunID: 7, JobID: "7-0-0", Attempt: 2}
54 got, _, _ = s.List(ctx, other, 20, 0)
55 assert.Empty(t, got)
56
57 // A write leaves no temporary file behind.
58 entries, err := os.ReadDir(filepath.Join(root, "7", "7-0-0", "1"))
59 require.NoError(t, err)
60 for _, e := range entries {
61 assert.False(t, strings.HasPrefix(e.Name(), ".tmp"), e.Name())
62 }
63}
64
65func TestListStopsAtTheLimit(t *testing.T) {
66 ctx := t.Context()
67 s := cisummaries.New(t.TempDir())
68 for i := int32(1); i <= 25; i++ {
69 require.NoError(t, s.Put(ctx, ref, i, "s"))
70 }
71 got, more, err := s.List(ctx, ref, 20, 0)
72 require.NoError(t, err)
73 assert.Len(t, got, 20)
74 assert.True(t, more)
75 assert.EqualValues(t, 20, got[19].Step, "the first 20 steps that have one")
76 got, more, _ = s.List(ctx, ref, 25, 0)
77 assert.Len(t, got, 25)
78 assert.False(t, more)
79}
80
81// A temporary file left by a crash, or a stray file, is not a summary.
82func TestListIgnoresStrayFiles(t *testing.T) {
83 ctx := t.Context()
84 root := t.TempDir()
85 s := cisummaries.New(root)
86 require.NoError(t, s.Put(ctx, ref, 1, "one"))
87 dir := filepath.Join(root, "7", "7-0-0", "1")
88 for _, n := range []string{".tmp-123", "x.md", "01.md", "0.md", "-1.md", "notes.txt"} {
89 require.NoError(t, os.WriteFile(filepath.Join(dir, n), []byte("stray"), 0o600))
90 }
91 got, _, err := s.List(ctx, ref, 20, 0)
92 require.NoError(t, err)
93 require.Len(t, got, 1)
94 assert.Equal(t, "one", got[0].Markdown)
95}
96
97func TestRemoveJobRemoveRunsAndRunIDs(t *testing.T) {
98 ctx := t.Context()
99 s := cisummaries.New(t.TempDir())
100 ids, err := s.RunIDs(ctx)
101 require.NoError(t, err)
102 assert.Empty(t, ids, "no directory yet")
103
104 second := ports.CILogRef{RunID: 7, JobID: "7-1-0", Attempt: 1}
105 third := ports.CILogRef{RunID: 8, JobID: "8-0-0", Attempt: 1}
106 for _, r := range []ports.CILogRef{ref, second, third} {
107 require.NoError(t, s.Put(ctx, r, 1, "x"))
108 }
109 ids, _ = s.RunIDs(ctx)
110 assert.ElementsMatch(t, []int64{7, 8}, ids)
111
112 require.NoError(t, s.RemoveJob(ctx, ref))
113 got, _, _ := s.List(ctx, ref, 20, 0)
114 assert.Empty(t, got)
115 got, _, _ = s.List(ctx, second, 20, 0)
116 assert.Len(t, got, 1, "the other job of the run stays")
117
118 require.NoError(t, s.RemoveRuns(ctx, []int64{7, 0, -3, 99}))
119 got, _, _ = s.List(ctx, second, 20, 0)
120 assert.Empty(t, got)
121 got, _, _ = s.List(ctx, third, 20, 0)
122 assert.Len(t, got, 1)
123 ids, _ = s.RunIDs(ctx)
124 assert.Equal(t, []int64{8}, ids)
125}
126
127// A reference that could leave the directory is refused, never joined.
128func TestRefsCannotEscape(t *testing.T) {
129 ctx := t.Context()
130 root := t.TempDir()
131 s := cisummaries.New(filepath.Join(root, "summaries"))
132 for _, bad := range []ports.CILogRef{
133 {RunID: 7, JobID: "../../x", Attempt: 1},
134 {RunID: 7, JobID: "7-0-0/../..", Attempt: 1},
135 {RunID: 0, JobID: "7-0-0", Attempt: 1},
136 {RunID: 7, JobID: "7-0-0", Attempt: 0},
137 } {
138 assert.ErrorIs(t, s.Put(ctx, bad, 1, "x"), domain.ErrNotFound)
139 got, _, err := s.List(ctx, bad, 20, 0)
140 require.NoError(t, err)
141 assert.Empty(t, got)
142 }
143 assert.ErrorIs(t, s.Put(ctx, ref, 0, "x"), domain.ErrNotFound)
144 assert.ErrorIs(t, s.Put(ctx, ref, domain.CIMaxSteps+1, "x"), domain.ErrNotFound)
145 entries, _ := os.ReadDir(root)
146 assert.Empty(t, entries, "nothing was written")
147}
148
149func TestASummaryOfTheMaximumSizeRoundTrips(t *testing.T) {
150 ctx := t.Context()
151 s := cisummaries.New(t.TempDir())
152 big := strings.Repeat("x", domain.CIMaxSummaryBytes)
153 require.NoError(t, s.Put(ctx, ref, 1, big))
154 got, _, err := s.List(ctx, ref, 20, 0)
155 require.NoError(t, err)
156 assert.Len(t, got[0].Markdown, domain.CIMaxSummaryBytes)
157}
158
159// With a byte budget the files are sized before they are read: a summary that
160// does not fit, and every one after it, comes back Omitted and is never opened.
161func TestListReadsNoMoreThanTheBudget(t *testing.T) {
162 ctx := t.Context()
163 root := t.TempDir()
164 s := cisummaries.New(root)
165 require.NoError(t, s.Put(ctx, ref, 1, strings.Repeat("a", 100)))
166 require.NoError(t, s.Put(ctx, ref, 2, strings.Repeat("b", 100)))
167 require.NoError(t, s.Put(ctx, ref, 3, strings.Repeat("c", 10)))
168 require.NoError(t, s.Put(ctx, ref, 4, "d"))
169 dir := filepath.Join(root, "7", "7-0-0", "1")
170 // A file that cannot be opened proves it was sized, not read.
171 if os.Geteuid() != 0 {
172 for _, n := range []string{"2.md", "3.md", "4.md"} {
173 require.NoError(t, os.Chmod(filepath.Join(dir, n), 0))
174 }
175 }
176
177 got, more, err := s.List(ctx, ref, 20, 150)
178 require.NoError(t, err)
179 assert.False(t, more)
180 require.Len(t, got, 4)
181 assert.Equal(t, strings.Repeat("a", 100), got[0].Markdown)
182 assert.False(t, got[0].Omitted)
183 for _, o := range got[1:] {
184 assert.True(t, o.Omitted, "step %d", o.Step)
185 assert.Empty(t, o.Markdown)
186 }
187 assert.EqualValues(t, []int32{1, 2, 3, 4}, []int32{got[0].Step, got[1].Step, got[2].Step, got[3].Step}, "the steps are still listed")
188}
189
190func TestListBudgetBoundaries(t *testing.T) {
191 ctx := t.Context()
192 s := cisummaries.New(t.TempDir())
193 require.NoError(t, s.Put(ctx, ref, 1, "12345"))
194 require.NoError(t, s.Put(ctx, ref, 2, "678"))
195
196 got, _, _ := s.List(ctx, ref, 20, 8)
197 assert.False(t, got[0].Omitted || got[1].Omitted, "exactly the budget fits")
198 got, _, _ = s.List(ctx, ref, 20, 7)
199 assert.False(t, got[0].Omitted)
200 assert.True(t, got[1].Omitted, "one byte over")
201 got, _, _ = s.List(ctx, ref, 20, 4)
202 assert.True(t, got[0].Omitted && got[1].Omitted, "the first does not fit")
203 got, _, _ = s.List(ctx, ref, 20, 0)
204 assert.False(t, got[0].Omitted || got[1].Omitted, "no budget, no bound")
205
206 // A smaller file after one that does not fit is still omitted: the order
207 // of steps is kept and nothing is read past the first omission.
208 require.NoError(t, s.Put(ctx, ref, 3, "x"))
209 got, _, _ = s.List(ctx, ref, 20, 7)
210 assert.True(t, got[2].Omitted)
211}