internal/control/feedline_test.go
260 lines · 10267 bytes
12 symbols in this file
TestFeedLinesFoldsBuildRunsBySHATestFeedLinesSingleBuildGetsStateTestFeedLinesDoesNotFoldAcrossDifferentSHAsTestFeedLinesNoSHANeverFoldsTestFeedLinesNonBuildEventBreaksFoldTestFeedLinesRunStatePrecedenceTestFeedLinesParsesWhenTTestFeedLinesNamesTheLabelsOnALabelledIssueTestFeedLinesNamesTheLabelsOnALabelledMRTestFeedLinesSplitsRepeatedJobTestFeedLinesScheduleAbsorbsPushJobsTestFeedLineSentence
1package control
2
3import (
4 "fmt"
5 "reflect"
6 "testing"
7 "time"
8
9 "gitbay.org/gitbay/internal/store"
10)
11
12// D04: build events on the same commit fold into one feed line, a "run",
13// whose State is the worst of the folded jobs' outcomes.
14func TestFeedLinesFoldsBuildRunsBySHA(t *testing.T) {
15 events := []store.FeedEvent{
16 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
17 Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
18 {RepoPath: "alice/app", Actor: "alice", Kind: "build.failure",
19 Data: `{"number":2,"job":"lint","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
20 }
21 lines := FeedLines(events)
22 if len(lines) != 1 {
23 t.Fatalf("feedLines returned %d lines, want 1: %+v", len(lines), lines)
24 }
25 l := lines[0]
26 if l.State != "failure" {
27 t.Errorf("State = %q, want failure", l.State)
28 }
29 if l.Verb != "ran 2 jobs on" {
30 t.Errorf("Verb = %q, want %q", l.Verb, "ran 2 jobs on")
31 }
32 if l.Ref != "aaaaaaaaaa" {
33 t.Errorf("Ref = %q, want short sha", l.Ref)
34 }
35 if l.URL != "/alice/app/commit/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
36 t.Errorf("URL = %q", l.URL)
37 }
38 if len(l.Jobs) != 2 || l.Jobs[0] != "unit" || l.Jobs[1] != "lint" {
39 t.Errorf("Jobs = %+v", l.Jobs)
40 }
41}
42
43// A single build event with a sha still gets a State, and keeps its
44// ordinary verb/ref/url — a run of one job reads the same as before.
45func TestFeedLinesSingleBuildGetsState(t *testing.T) {
46 events := []store.FeedEvent{
47 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
48 Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
49 }
50 lines := FeedLines(events)
51 if len(lines) != 1 {
52 t.Fatalf("feedLines returned %d lines, want 1", len(lines))
53 }
54 l := lines[0]
55 if l.State != "success" {
56 t.Errorf("State = %q, want success", l.State)
57 }
58 if l.Verb != "build success" || l.Ref != "unit" || l.URL != "/alice/app/builds/1" {
59 t.Errorf("single build line changed shape: %+v", l)
60 }
61}
62
63// Two different commits never fold, even back to back.
64func TestFeedLinesDoesNotFoldAcrossDifferentSHAs(t *testing.T) {
65 events := []store.FeedEvent{
66 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
67 Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
68 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
69 Data: `{"number":2,"job":"lint","sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}`},
70 }
71 lines := FeedLines(events)
72 if len(lines) != 2 {
73 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines)
74 }
75}
76
77// An event with no sha (an older event recorded before this field existed)
78// never folds into anything, even when it shares a repo with an adjacent
79// build event.
80func TestFeedLinesNoSHANeverFolds(t *testing.T) {
81 events := []store.FeedEvent{
82 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", Data: `{"number":1,"job":"unit"}`},
83 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success", Data: `{"number":2,"job":"lint"}`},
84 }
85 lines := FeedLines(events)
86 if len(lines) != 2 {
87 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines)
88 }
89 if lines[0].State != "" || lines[1].State != "" {
90 t.Errorf("shaless build lines got a State: %+v", lines)
91 }
92}
93
94// A non-build event between two builds of the same commit breaks the
95// fold: only adjacent build events on the same commit combine.
96func TestFeedLinesNonBuildEventBreaksFold(t *testing.T) {
97 events := []store.FeedEvent{
98 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
99 Data: `{"number":1,"job":"unit","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
100 {RepoPath: "alice/app", Actor: "bob", Kind: "issue.created", Data: `{"number":1}`},
101 {RepoPath: "alice/app", Actor: "alice", Kind: "build.failure",
102 Data: `{"number":2,"job":"lint","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`},
103 }
104 lines := FeedLines(events)
105 if len(lines) != 3 {
106 t.Fatalf("feedLines returned %d lines, want 3: %+v", len(lines), lines)
107 }
108}
109
110// worstStatus governs the run's combined State the same way it governs
111// combinedStatus for the builds tab.
112func TestFeedLinesRunStatePrecedence(t *testing.T) {
113 cases := []struct {
114 statuses []string
115 want string
116 }{
117 {[]string{"success", "success"}, "success"},
118 {[]string{"success", "pending"}, "pending"},
119 {[]string{"pending", "running"}, "running"},
120 {[]string{"running", "cancelled"}, "cancelled"},
121 {[]string{"cancelled", "failure"}, "failure"},
122 }
123 for _, tc := range cases {
124 events := make([]store.FeedEvent, len(tc.statuses))
125 for i, s := range tc.statuses {
126 // Distinct job names: a repeat would split the line (#240).
127 events[i] = store.FeedEvent{RepoPath: "alice/app", Actor: "alice", Kind: "build." + s,
128 Data: fmt.Sprintf(`{"number":1,"job":"j%d","sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`, i)}
129 }
130 lines := FeedLines(events)
131 if len(lines) != 1 || lines[0].State != tc.want {
132 t.Errorf("statuses %v: got %+v, want State %q", tc.statuses, lines, tc.want)
133 }
134 }
135}
136
137// D05: feedLines parses the stored RFC3339 timestamp into WhenT for the
138// template's relative-time rendering; an unparseable value leaves it zero
139// rather than panicking or guessing.
140func TestFeedLinesParsesWhenT(t *testing.T) {
141 events := []store.FeedEvent{
142 {RepoPath: "alice/app", Actor: "alice", Kind: "issue.created",
143 Data: `{"number":1}`, CreatedAt: "2026-09-10T12:00:00Z"},
144 {RepoPath: "alice/app", Actor: "alice", Kind: "issue.created",
145 Data: `{"number":2}`, CreatedAt: "not-a-time"},
146 }
147 lines := FeedLines(events)
148 want, _ := time.Parse(time.RFC3339Nano, "2026-09-10T12:00:00Z")
149 if !lines[0].WhenT.Equal(want) {
150 t.Errorf("WhenT = %v, want %v", lines[0].WhenT, want)
151 }
152 if !lines[1].WhenT.IsZero() {
153 t.Errorf("WhenT for bad timestamp = %v, want zero", lines[1].WhenT)
154 }
155 // When is preserved for anything that still reads the raw string.
156 if lines[0].When != "2026-09-10T12:00:00Z" {
157 t.Errorf("When = %q", lines[0].When)
158 }
159}
160
161// A labelled issue names which labels were added, not just "issue
162// labeled": the sentence is otherwise silent about what changed.
163func TestFeedLinesNamesTheLabelsOnALabelledIssue(t *testing.T) {
164 events := []store.FeedEvent{
165 {RepoPath: "krz/gitbay", Actor: "cmc", Kind: "issue.labeled",
166 Data: `{"number":262,"labels":["ops","security"]}`},
167 }
168 lines := FeedLines(events)
169 if len(lines) != 1 {
170 t.Fatalf("FeedLines returned %d lines, want 1", len(lines))
171 }
172 l := lines[0]
173 if l.Verb != "labelled" || l.Ref != "#262" || l.Extra != "ops, security" {
174 t.Errorf("got %+v", l)
175 }
176}
177
178// Same for a labelled merge request.
179func TestFeedLinesNamesTheLabelsOnALabelledMR(t *testing.T) {
180 events := []store.FeedEvent{
181 {RepoPath: "krz/gitbay", Actor: "cmc", Kind: "mr.labeled",
182 Data: `{"number":471,"labels":["review"]}`},
183 }
184 lines := FeedLines(events)
185 if len(lines) != 1 || lines[0].Verb != "labelled" || lines[0].Ref != "!471" || lines[0].Extra != "review" {
186 t.Errorf("got %+v", lines)
187 }
188}
189
190// A scheduled job firing daily on an unchanged tip is a separate event
191// each tick, not another job of one run (#240): a repeated job name starts
192// a new line, so three days read as three lines rather than "ran 3 jobs on"
193// one commit.
194func TestFeedLinesSplitsRepeatedJob(t *testing.T) {
195 const sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
196 var events []store.FeedEvent
197 for _, status := range []string{"success", "failure", "success"} {
198 events = append(events, store.FeedEvent{RepoPath: "alice/app", Actor: "alice",
199 Kind: "build." + status, Data: `{"number":1,"job":"instances","sha":"` + sha + `"}`})
200 }
201 lines := FeedLines(events)
202 if len(lines) != 3 {
203 t.Fatalf("feedLines returned %d lines, want 3: %+v", len(lines), lines)
204 }
205 for i, want := range []string{"success", "failure", "success"} {
206 if lines[i].Verb != "build "+want || lines[i].Ref != "instances" {
207 t.Errorf("line %d: %+v, want Verb %q on job instances", i, lines[i], "build "+want)
208 }
209 }
210}
211
212// What the job-name rule cannot do, documented so the limit is not
213// rediscovered as a bug: these events are recorded per job at finish time,
214// so the oldest scheduled line on a commit folds in the push's jobs. The
215// builds tab does not have this problem — groupRuns has created_at.
216func TestFeedLinesScheduleAbsorbsPushJobs(t *testing.T) {
217 const sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
218 events := []store.FeedEvent{
219 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
220 Data: `{"number":3,"job":"instances","sha":"` + sha + `"}`},
221 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
222 Data: `{"number":2,"job":"instances","sha":"` + sha + `"}`},
223 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
224 Data: `{"number":1,"job":"lint","sha":"` + sha + `"}`},
225 }
226 lines := FeedLines(events)
227 if len(lines) != 2 {
228 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines)
229 }
230 if !reflect.DeepEqual(lines[1].Jobs, []string{"instances", "lint"}) {
231 t.Errorf("second line jobs: %+v, want the schedule and the push folded", lines[1].Jobs)
232 }
233}
234
235func TestFeedLineSentence(t *testing.T) {
236 l := FeedLine{Actor: "cmc", Verb: "opened issue", Repo: "krz/gitbay", Ref: "#1"}
237 if got, want := l.Sentence(), "cmc opened issue krz/gitbay#1"; got != want {
238 t.Errorf("Sentence() = %q, want %q", got, want)
239 }
240
241 l.Extra = "ops, security"
242 if got, want := l.Sentence(), "cmc opened issue krz/gitbay#1 ops, security"; got != want {
243 t.Errorf("Sentence() with Extra = %q, want %q", got, want)
244 }
245
246 for _, c := range []struct {
247 l FeedLine
248 want string
249 }{
250 {FeedLine{Actor: "cmc", Verb: "released", Repo: "krz/gitbay", Ref: "v1.30.0"}, "cmc released krz/gitbay v1.30.0"},
251 {FeedLine{Actor: "cmc", Verb: "build success", Repo: "krz/gitbay", Ref: "test"}, "cmc build success krz/gitbay test"},
252 {FeedLine{Actor: "cmc", Verb: "ran 2 jobs on", Repo: "krz/gitbay", Ref: "0123456789"}, "cmc ran 2 jobs on krz/gitbay 0123456789"},
253 {FeedLine{Actor: "cmc", Verb: "merged", Repo: "krz/gitbay", Ref: "!3"}, "cmc merged krz/gitbay!3"},
254 {FeedLine{Actor: "cmc", Verb: "repository created", Repo: "krz/gitbay"}, "cmc repository created krz/gitbay"},
255 } {
256 if got := c.l.Sentence(); got != c.want {
257 t.Errorf("Sentence() = %q, want %q", got, c.want)
258 }
259 }
260}