internal/httpd/feed_test.go
204 lines · 8061 bytes
1package httpd
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 scheduled job firing daily on an unchanged tip is a separate event
162// each tick, not another job of one run (#240): a repeated job name starts
163// a new line, so three days read as three lines rather than "ran 3 jobs on"
164// one commit.
165func TestFeedLinesSplitsRepeatedJob(t *testing.T) {
166 const sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
167 var events []store.FeedEvent
168 for _, status := range []string{"success", "failure", "success"} {
169 events = append(events, store.FeedEvent{RepoPath: "alice/app", Actor: "alice",
170 Kind: "build." + status, Data: `{"number":1,"job":"instances","sha":"` + sha + `"}`})
171 }
172 lines := feedLines(events)
173 if len(lines) != 3 {
174 t.Fatalf("feedLines returned %d lines, want 3: %+v", len(lines), lines)
175 }
176 for i, want := range []string{"success", "failure", "success"} {
177 if lines[i].Verb != "build "+want || lines[i].Ref != "instances" {
178 t.Errorf("line %d: %+v, want Verb %q on job instances", i, lines[i], "build "+want)
179 }
180 }
181}
182
183// What the job-name rule cannot do, documented so the limit is not
184// rediscovered as a bug: these events are recorded per job at finish time,
185// so the oldest scheduled line on a commit folds in the push's jobs. The
186// builds tab does not have this problem — groupRuns has created_at.
187func TestFeedLinesScheduleAbsorbsPushJobs(t *testing.T) {
188 const sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
189 events := []store.FeedEvent{
190 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
191 Data: `{"number":3,"job":"instances","sha":"` + sha + `"}`},
192 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
193 Data: `{"number":2,"job":"instances","sha":"` + sha + `"}`},
194 {RepoPath: "alice/app", Actor: "alice", Kind: "build.success",
195 Data: `{"number":1,"job":"lint","sha":"` + sha + `"}`},
196 }
197 lines := feedLines(events)
198 if len(lines) != 2 {
199 t.Fatalf("feedLines returned %d lines, want 2: %+v", len(lines), lines)
200 }
201 if !reflect.DeepEqual(lines[1].Jobs, []string{"instances", "lint"}) {
202 t.Errorf("second line jobs: %+v, want the schedule and the push folded", lines[1].Jobs)
203 }
204}