1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore_test
4
5import (
6 "context"
7 "testing"
8 "time"
9
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12
13 "bnema.dev/ermite/internal/domain"
14 "bnema.dev/ermite/internal/ports"
15)
16
17func note(level domain.AnnotationLevel, step int32, msg string) domain.CIAnnotation {
18 return domain.CIAnnotation{Level: level, Message: msg, Step: step}
19}
20
21func replace(n domain.RepoName, run int64, attempt int32, job string, step int32, items ...domain.CIAnnotation) ports.CIStepAnnotations {
22 return ports.CIStepAnnotations{Repo: n, Exec: ports.CILogRef{RunID: run, JobID: job, Attempt: attempt}, Step: step, Items: items}
23}
24
25// Annotations are kept per execution of a job and per step: a report replaces
26// the annotations of the steps it names, keeps the others, and an empty report
27// removes the step's.
28func TestCIAnnotationsAreReplacedPerStep(t *testing.T) {
29 ctx := context.Background()
30 _, ci, n := ciRepo(t)
31 d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 2))
32 require.NoError(t, err)
33 a, b := d.Jobs[0].ID, d.Jobs[1].ID
34
35 full := domain.CIAnnotation{
36 Level: domain.AnnotationFailure, Title: "t", Message: "boom", File: "a/b.go",
37 StartLine: 3, EndLine: 5, StartColumn: 2, EndColumn: 9, Step: 2,
38 }
39 _, err = ci.Apply(ctx, ports.CIWrite{Annotations: []ports.CIStepAnnotations{
40 replace(n, d.Run.ID, 1, a, 2, full, note(domain.AnnotationNotice, 2, "second")),
41 replace(n, d.Run.ID, 1, a, 10, note(domain.AnnotationWarning, 10, "late step")),
42 replace(n, d.Run.ID, 1, b, 2, note(domain.AnnotationNotice, 2, "other job")),
43 }})
44 require.NoError(t, err)
45
46 got, err := ci.JobAnnotations(ctx, d.Run.ID, 1, a)
47 require.NoError(t, err)
48 require.Len(t, got, 3)
49 assert.Equal(t, full, got[0], "every field round-trips, in the order the step produced them")
50 assert.Equal(t, "second", got[1].Message)
51 assert.EqualValues(t, 10, got[2].Step, "steps sort numerically, not as text")
52
53 // Replace step 2 only: step 10 and the other job keep theirs.
54 _, err = ci.Apply(ctx, ports.CIWrite{Annotations: []ports.CIStepAnnotations{
55 replace(n, d.Run.ID, 1, a, 2, note(domain.AnnotationWarning, 2, "only one now")),
56 }})
57 require.NoError(t, err)
58 got, _ = ci.JobAnnotations(ctx, d.Run.ID, 1, a)
59 require.Len(t, got, 2)
60 assert.Equal(t, "only one now", got[0].Message)
61 assert.Equal(t, "late step", got[1].Message)
62 other, _ := ci.JobAnnotations(ctx, d.Run.ID, 1, b)
63 require.Len(t, other, 1)
64
65 // An empty list removes the step's annotations.
66 _, err = ci.Apply(ctx, ports.CIWrite{Annotations: []ports.CIStepAnnotations{replace(n, d.Run.ID, 1, a, 2)}})
67 require.NoError(t, err)
68 got, _ = ci.JobAnnotations(ctx, d.Run.ID, 1, a)
69 require.Len(t, got, 1)
70 assert.EqualValues(t, 10, got[0].Step)
71
72 // Another attempt of the job has its own.
73 none, err := ci.JobAnnotations(ctx, d.Run.ID, 2, a)
74 require.NoError(t, err)
75 assert.Empty(t, none)
76}
77
78// The annotations are written in the batch of the job: a stale job version
79// writes none of them, and a missing repository neither.
80func TestCIAnnotationsAreAtomicWithTheJob(t *testing.T) {
81 ctx := context.Background()
82 _, ci, n := ciRepo(t)
83 d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
84 require.NoError(t, err)
85 job, err := ci.GetJob(ctx, d.Run.ID, 1, d.Jobs[0].ID)
86 require.NoError(t, err)
87
88 stale := job
89 stale.Version--
90 _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{stale}, Annotations: []ports.CIStepAnnotations{
91 replace(n, d.Run.ID, 1, job.ID, 1, note(domain.AnnotationNotice, 1, "lost")),
92 }})
93 assert.ErrorIs(t, err, domain.ErrConflict)
94 got, _ := ci.JobAnnotations(ctx, d.Run.ID, 1, job.ID)
95 assert.Empty(t, got)
96
97 ghost := name(t, "ghost")
98 _, err = ci.Apply(ctx, ports.CIWrite{Annotations: []ports.CIStepAnnotations{
99 replace(ghost, d.Run.ID, 1, job.ID, 1, note(domain.AnnotationNotice, 1, "no repository")),
100 }})
101 assert.ErrorIs(t, err, domain.ErrNotFound, "no key is created under a repository that is gone")
102}
103
104// Annotations go with their run: the retention sweep, the deletion of the
105// repository, and the clearing of an assignment that is taken back.
106func TestCIAnnotationsAreDeleted(t *testing.T) {
107 ctx := context.Background()
108 s, ci, n := ciRepo(t)
109 mk := func() ports.CIRunDraft {
110 d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
111 require.NoError(t, err)
112 _, err = ci.Apply(ctx, ports.CIWrite{Annotations: []ports.CIStepAnnotations{
113 replace(n, d.Run.ID, 1, d.Jobs[0].ID, 1, note(domain.AnnotationNotice, 1, "x")),
114 replace(n, d.Run.ID, 1, d.Jobs[0].ID, 2, note(domain.AnnotationNotice, 2, "y")),
115 }})
116 require.NoError(t, err)
117 return d
118 }
119 count := func(d ports.CIRunDraft) int {
120 got, err := ci.JobAnnotations(ctx, d.Run.ID, 1, d.Jobs[0].ID)
121 require.NoError(t, err)
122 return len(got)
123 }
124
125 // clear
126 d := mk()
127 _, err := ci.Apply(ctx, ports.CIWrite{ClearAnnotations: []ports.CILogRef{{RunID: d.Run.ID, JobID: d.Jobs[0].ID, Attempt: 1}}})
128 require.NoError(t, err)
129 assert.Zero(t, count(d))
130
131 // sweep: an old completed run goes with its annotations, a recent one keeps them.
132 oldRun, recent := mk(), mk()
133 for _, tc := range []struct {
134 d ports.CIRunDraft
135 at time.Time
136 }{{oldRun, created}, {recent, created.Add(100 * 24 * time.Hour)}} {
137 run, _ := ci.GetRun(ctx, n, tc.d.Run.ID)
138 run.Status, run.Conclusion, run.CompletedAt = domain.CICompleted, domain.CISuccess, tc.at
139 job, _ := ci.GetJob(ctx, run.ID, 1, tc.d.Jobs[0].ID)
140 job.Phase, job.Conclusion = domain.CIPhaseEnded, domain.CISuccess
141 _, err := ci.Apply(ctx, ports.CIWrite{Runs: []domain.CIRun{run}, Jobs: []domain.CIJob{job}})
142 require.NoError(t, err)
143 }
144 ids, err := ci.Sweep(ctx, created.Add(90*24*time.Hour))
145 require.NoError(t, err)
146 assert.Contains(t, ids, oldRun.Run.ID)
147 assert.Zero(t, count(oldRun))
148 assert.Equal(t, 2, count(recent))
149
150 // repository deletion
151 require.NoError(t, s.Delete(ctx, n))
152 assert.Zero(t, count(recent))
153}
154
155func TestCIStepOutcomeRoundTrips(t *testing.T) {
156 ctx := context.Background()
157 _, ci, n := ciRepo(t)
158 d, err := ci.CreateRun(ctx, n, "w.yml", draftOf(n, "w.yml", 1))
159 require.NoError(t, err)
160 job, _ := ci.GetJob(ctx, d.Run.ID, 1, d.Jobs[0].ID)
161 job.Steps = []domain.CIStep{
162 {Number: 1, State: domain.CIStepCompleted, Conclusion: domain.CISuccess, Outcome: domain.CIFailure, StartedAt: created, CompletedAt: created},
163 {Number: 2, State: domain.CIStepCompleted, Conclusion: domain.CISuccess, StartedAt: created, CompletedAt: created},
164 }
165 _, err = ci.Apply(ctx, ports.CIWrite{Jobs: []domain.CIJob{job}})
166 require.NoError(t, err)
167 got, err := ci.GetJob(ctx, d.Run.ID, 1, job.ID)
168 require.NoError(t, err)
169 assert.Equal(t, domain.CIFailure, got.Steps[0].Outcome, "continue-on-error: failed, then succeeded")
170 assert.Equal(t, domain.CINoConclusion, got.Steps[1].Outcome, "stored without one: read as the conclusion")
171}