internal/httpd/mrpage_test.go

v1.37.0
gitbay/internal/httpd/mrpage_test.go history · blame · raw

321 lines · 11812 bytes

  1package httpd
  2
  3import (
  4	"html/template"
  5	"strings"
  6	"testing"
  7	"time"
  8
  9	"gitbay.org/gitbay/internal/control"
 10	"gitbay.org/gitbay/internal/gitutil"
 11	"gitbay.org/gitbay/internal/store"
 12	"gitbay.org/gitbay/internal/web"
 13)
 14
 15// mrPageData mirrors the anonymous struct the mr handler renders with.
 16type mrPageData struct {
 17	repoPage
 18	MR              store.MR
 19	View            string
 20	BodyHTML        template.HTML
 21	Checks          []store.Check
 22	Combined        string
 23	Comments        []renderedComment
 24	Reviews         []reviewRow
 25	DiffFiles       []diffFile
 26	Stat            diffStat
 27	Commits         []struct{}
 28	Branches        []gitutil.Ref
 29	CanEdit         bool
 30	CanWrite        bool
 31	Unresolved      int
 32	Revisions       []store.MRHead
 33	Notice          string
 34	DetachedThreads []diffThread
 35	Gates           *control.GatesOut
 36	SourceGone      bool
 37	HeadMerged      bool
 38	HeadPruned      bool
 39	Base            string
 40	Draft           *draft
 41}
 42
 43// A pruned head has no diff to show; the page must say the head is gone
 44// rather than that nothing changed, which is what an empty file list
 45// otherwise renders as.
 46func TestMRDiffViewNamesAPrunedHead(t *testing.T) {
 47	var sb strings.Builder
 48	if err := web.Render(&sb, "mr.html", mrPageData{
 49		repoPage: testRepoPage(), MR: testMR("merged"), View: "diff", HeadPruned: true,
 50	}); err != nil {
 51		t.Fatalf("render: %v", err)
 52	}
 53	out := sb.String()
 54	if !strings.Contains(out, "no longer in the repository") {
 55		t.Error("diff view of a pruned head does not say the head is gone")
 56	}
 57	if strings.Contains(out, "No changes between") {
 58		t.Error("diff view of a pruned head claims there were no changes")
 59	}
 60}
 61
 62// A heading precedes the comment thread, so a screen-reader user
 63// skimming by heading does not fall from the aside's groups straight
 64// into the first comment with no landmark (#271).
 65func TestMRPageHasDiscussionHeading(t *testing.T) {
 66	var sb strings.Builder
 67	if err := web.Render(&sb, "mr.html", mrPageData{
 68		repoPage: testRepoPage(), MR: testMR("open"), View: "conversation",
 69	}); err != nil {
 70		t.Fatalf("render: %v", err)
 71	}
 72	if !strings.Contains(sb.String(), "<h2>Discussion</h2>") {
 73		t.Error("no Discussion heading")
 74	}
 75}
 76
 77// The watch button names all three states it cycles through, including
 78// muted, which MR 1 made reachable (#271).
 79func TestRepoHeaderWatchButtonNamesMutedState(t *testing.T) {
 80	var sb strings.Builder
 81	rp := testRepoPage()
 82	rp.Viewer = "cmc" // the watch button only renders for a signed-in viewer
 83	rp.Watch = "muted"
 84	if err := web.Render(&sb, "mr.html", mrPageData{
 85		repoPage: rp, MR: testMR("open"), View: "conversation",
 86	}); err != nil {
 87		t.Fatalf("render: %v", err)
 88	}
 89	if !strings.Contains(sb.String(), "Muted") {
 90		t.Error(`watch button does not render "Muted" for a muted repo`)
 91	}
 92}
 93
 94func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string {
 95	rows := make([]reviewRow, 0, len(reviews))
 96	for _, r := range reviews {
 97		// The page test renders reviews that count; whether a given
 98		// reviewer's does is decided by access, which e2e covers.
 99		rows = append(rows, reviewRow{MRReview: r, Counts: true})
100	}
101	t.Helper()
102	var sb strings.Builder
103	if err := web.Render(&sb, "mr.html", mrPageData{
104		repoPage: testRepoPage(), MR: m, View: "conversation",
105		Reviews: rows, Checks: checks, Combined: "",
106	}); err != nil {
107		t.Fatalf("render: %v", err)
108	}
109	return sb.String()
110}
111
112func testMR(state string) store.MR {
113	return store.MR{Number: 42, Title: "org native rendering", Author: "cmc", State: state,
114		SourcePath: "krz/hutch", SourceRef: "org-native-rendering", TargetRef: "main",
115		HeadSHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b"}
116}
117
118// A finished merge request states an empty label list as a fact, not a
119// promise something is still coming (#270).
120func TestMRPageLabelsNoYetOnFinishedState(t *testing.T) {
121	var sb strings.Builder
122	if err := web.Render(&sb, "mr.html", mrPageData{
123		repoPage: testRepoPage(), MR: testMR("merged"), View: "conversation",
124	}); err != nil {
125		t.Fatalf("render: %v", err)
126	}
127	if !strings.Contains(sb.String(), "no labels") {
128		t.Error(`merged MR with no labels should read "no labels", not "none yet"`)
129	}
130}
131
132// An open MR can still gain labels, so its empty state keeps "none yet"
133// rather than the finished-item wording (#270).
134func TestMRPageLabelsNoneYetOnOpenState(t *testing.T) {
135	var sb strings.Builder
136	if err := web.Render(&sb, "mr.html", mrPageData{
137		repoPage: testRepoPage(), MR: testMR("open"), View: "conversation",
138	}); err != nil {
139		t.Fatalf("render: %v", err)
140	}
141	out := sb.String()
142	if !strings.Contains(out, "none yet") {
143		t.Error(`open MR with no labels should read "none yet"`)
144	}
145	if strings.Contains(out, "no labels") {
146		t.Error(`open MR with no labels should not read "no labels"`)
147	}
148}
149
150// A closed MR is finished the same way a merged one is (#270).
151func TestMRPageLabelsNoLabelsOnClosedState(t *testing.T) {
152	var sb strings.Builder
153	if err := web.Render(&sb, "mr.html", mrPageData{
154		repoPage: testRepoPage(), MR: testMR("closed"), View: "conversation",
155	}); err != nil {
156		t.Fatalf("render: %v", err)
157	}
158	if !strings.Contains(sb.String(), "no labels") {
159		t.Error(`closed MR with no labels should read "no labels", not "none yet"`)
160	}
161}
162
163func TestMRPageReviewersEmptyStateDropsNobody(t *testing.T) {
164	var sb strings.Builder
165	if err := web.Render(&sb, "mr.html", mrPageData{
166		repoPage: testRepoPage(), MR: testMR("open"), View: "conversation",
167	}); err != nil {
168		t.Fatalf("render: %v", err)
169	}
170	if strings.Contains(sb.String(), "nobody yet") {
171		t.Error(`reviewers empty state should read "no reviewers"`)
172	}
173	if !strings.Contains(sb.String(), "no reviewers") {
174		t.Error(`missing "no reviewers"`)
175	}
176}
177
178// The header states what happened to the MR: who opened, merged, or closed
179// it, and when. A merge or close with no stamp (imports, pre-0029 merges)
180// names the state without claiming a byline it cannot back up.
181func TestMRHeaderByState(t *testing.T) {
182	open := renderMR(t, testMR("open"), nil, nil)
183	if !strings.Contains(open, "opened by") {
184		t.Errorf("open MR does not say who opened it:\n%s", open)
185	}
186
187	merged := testMR("merged")
188	merged.MergedAt, merged.MergedBy = "2026-08-27T14:03:11.000Z", "cmc"
189	out := renderMR(t, merged, nil, nil)
190	for _, want := range []string{"merged", "krz/hutch:org-native-rendering", "2026-08-27 14:03"} {
191		if !strings.Contains(out, want) {
192			t.Errorf("merged header missing %q:\n%s", want, out)
193		}
194	}
195	if strings.Contains(out, "opened by") {
196		t.Errorf("merged MR still says it was opened:\n%s", out)
197	}
198
199	closed := testMR("closed")
200	closed.ClosedAt, closed.ClosedBy = "2026-08-27T14:03:11.000Z", "cmc"
201	out = renderMR(t, closed, nil, nil)
202	if !strings.Contains(out, "without merging") || strings.Contains(out, "opened by") {
203		t.Errorf("closed header:\n%s", out)
204	}
205
206	// Imports and pre-0029 merges carry no stamp; the wording drops the
207	// byline claim rather than naming nobody or inventing a time.
208	out = renderMR(t, testMR("merged"), nil, nil)
209	if strings.Contains(out, "opened by") || strings.Contains(out, " on 20") || strings.Contains(out, "merged by") {
210		t.Errorf("unstamped merged header:\n%s", out)
211	}
212	if !strings.Contains(out, "merged") {
213		t.Errorf("unstamped merged header drops the state entirely:\n%s", out)
214	}
215
216	out = renderMR(t, testMR("closed"), nil, nil)
217	if strings.Contains(out, "opened by") || strings.Contains(out, " on 20") || strings.Contains(out, "closed without merging by") {
218		t.Errorf("unstamped closed header:\n%s", out)
219	}
220	if !strings.Contains(out, "without merging") {
221		t.Errorf("unstamped closed header drops the state entirely:\n%s", out)
222	}
223}
224
225// Approvals and checks carry their times in the aside, so reading the MR
226// does not mean opening the build.
227func TestMRAsideTimestamps(t *testing.T) {
228	out := renderMR(t, testMR("open"),
229		[]store.MRReview{{Reviewer: "cmc", Verdict: "approve", CreatedAt: "2026-08-27T14:03:11.000Z"}},
230		[]store.Check{
231			{CommitStatus: store.CommitStatus{Context: "ci/test", State: "success",
232				UpdatedAt: "2026-08-27T14:05:00.000Z"}, Duration: 72 * time.Second, Build: 60},
233			{CommitStatus: store.CommitStatus{Context: "external/lint", State: "success",
234				UpdatedAt: "2026-08-27T14:06:00.000Z"}},
235		})
236	for _, want := range []string{"2026-08-27 14:03", "2026-08-27 14:05", "1m12s", "2026-08-27 14:06"} {
237		if !strings.Contains(out, want) {
238			t.Errorf("aside missing %q:\n%s", want, out)
239		}
240	}
241}
242
243// A job a path filter excluded has no build behind its status: the page
244// must show it as skipped rather than linking to a build that never ran
245// (#172).
246func TestMRChecksRenderSkippedWithoutBuildLink(t *testing.T) {
247	out := renderMR(t, testMR("open"), nil, []store.Check{
248		{CommitStatus: store.CommitStatus{Context: "ci/unit", State: "skipped",
249			Description: "every changed file matched paths-ignore", UpdatedAt: "2026-08-27T14:05:00.000Z"}},
250	})
251	row := ""
252	for _, line := range strings.Split(out, "\n") {
253		if strings.Contains(line, "ci/unit") {
254			row = line
255		}
256	}
257	if row == "" || !strings.Contains(row, ">skipped<") {
258		t.Fatalf("skipped check not rendered:\n%s", out)
259	}
260	if strings.Contains(row, "<a href") {
261		t.Errorf("skipped check with no build linked anyway: %s", row)
262	}
263}
264
265// The gates block says what the merge is waiting on before a merge is
266// refused (#199): every unmet gate, the approval count, the outstanding
267// owners, and whether a fast-forward is possible.
268func TestMRGatesRender(t *testing.T) {
269	render := func(g *control.GatesOut) string {
270		t.Helper()
271		var sb strings.Builder
272		if err := web.Render(&sb, "mr.html", mrPageData{
273			repoPage: testRepoPage(), MR: testMR("open"), View: "conversation", Gates: g,
274		}); err != nil {
275			t.Fatalf("render: %v", err)
276		}
277		return sb.String()
278	}
279	out := render(&control.GatesOut{ApprovalsRequired: 2, Approvals: []string{"bob"},
280		OwnersOutstanding: []control.OwnersOut{{Files: []string{"svc.go"}, Owners: []string{"carol"}}},
281		Unmet:             []string{"krz/hutch requires 2 fresh approval(s); !42 has 1", "CODEOWNERS approval missing for: svc.go (owned by carol)"}})
282	for _, want := range []string{"Merge gates", "requires 2 fresh approval(s)", "approvals: 1 of 2 (bob)", `waiting on <a href="/carol">carol</a>`, "not a fast-forward"} {
283		if !strings.Contains(out, want) {
284			t.Errorf("gates block missing %q:\n%s", want, out)
285		}
286	}
287	if out := render(&control.GatesOut{FastForward: true}); !strings.Contains(out, "All gates met") || !strings.Contains(out, "fast-forward possible") {
288		t.Errorf("met gates not rendered:\n%s", out)
289	}
290	if out := render(&control.GatesOut{Checks: "pending", ChecksMissing: []string{"ext/deploy"}}); !strings.Contains(out, "waiting on <code>ext/deploy</code>") {
291		t.Errorf("missing required context not rendered:\n%s", out)
292	}
293	if out := render(nil); strings.Contains(out, "Merge gates") {
294		t.Errorf("gates block on a merge request without gates:\n%s", out)
295	}
296}
297
298// Each revision after the first carries a link comparing it to the one
299// before, so a reviewer does not have to type mr range-diff by hand
300// (#269).
301func TestMRPageListsRevisionsWithCompareLinks(t *testing.T) {
302	var sb strings.Builder
303	revs := []store.MRHead{
304		{SHA: "aaaa1111", CreatedAt: "2026-09-23T10:00:00Z"},
305		{SHA: "bbbb2222", CreatedAt: "2026-09-24T10:00:00Z"},
306	}
307	if err := web.Render(&sb, "mr.html", mrPageData{
308		repoPage: testRepoPage(), MR: testMR("open"), View: "conversation", Revisions: revs,
309	}); err != nil {
310		t.Fatalf("render: %v", err)
311	}
312	out := sb.String()
313	for _, want := range []string{"aaaa1111", "bbbb2222", "compare to previous", "from=aaaa1111", "to=bbbb2222"} {
314		if !strings.Contains(out, want) {
315			t.Errorf("missing %q in:\n%s", want, out)
316		}
317	}
318	if strings.Contains(out, "gitbay mr range-diff") {
319		t.Error("still quotes the CLI command instead of linking the new page")
320	}
321}