internal/control/feedline_test.go

bd49b87fce895e9f0a7588152548fb6e1821ac7d
gitbay/internal/control/feedline_test.go history · blame · raw

260 lines · 10267 bytes

  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}