internal/httpd/mrpage_test.go

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

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