internal/httpd/feed_test.go

419f6dfdc5489a0c6374e36dd1ebbfca68040056
gitbay/internal/httpd/feed_test.go history · blame · raw

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}