internal/httpd/mrpage_test.go

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

200 lines · 7480 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}
 41
 42// A pruned head has no diff to show; the page must say the head is gone
 43// rather than that nothing changed, which is what an empty file list
 44// otherwise renders as.
 45func TestMRDiffViewNamesAPrunedHead(t *testing.T) {
 46	var sb strings.Builder
 47	if err := web.Render(&sb, "mr.html", mrPageData{
 48		repoPage: testRepoPage(), MR: testMR("merged"), View: "diff", HeadPruned: true,
 49	}); err != nil {
 50		t.Fatalf("render: %v", err)
 51	}
 52	out := sb.String()
 53	if !strings.Contains(out, "no longer in the repository") {
 54		t.Error("diff view of a pruned head does not say the head is gone")
 55	}
 56	if strings.Contains(out, "No changes between") {
 57		t.Error("diff view of a pruned head claims there were no changes")
 58	}
 59}
 60
 61func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string {
 62	rows := make([]reviewRow, 0, len(reviews))
 63	for _, r := range reviews {
 64		// The page test renders reviews that count; whether a given
 65		// reviewer's does is decided by access, which e2e covers.
 66		rows = append(rows, reviewRow{MRReview: r, Counts: true})
 67	}
 68	t.Helper()
 69	var sb strings.Builder
 70	if err := web.Render(&sb, "mr.html", mrPageData{
 71		repoPage: testRepoPage(), MR: m, View: "conversation",
 72		Reviews: rows, Checks: checks, Combined: "",
 73	}); err != nil {
 74		t.Fatalf("render: %v", err)
 75	}
 76	return sb.String()
 77}
 78
 79func testMR(state string) store.MR {
 80	return store.MR{Number: 42, Title: "org native rendering", Author: "cmc", State: state,
 81		SourcePath: "krz/hutch", SourceRef: "org-native-rendering", TargetRef: "main",
 82		HeadSHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b"}
 83}
 84
 85// The header states what happened to the MR: who opened, merged, or closed
 86// it, and when. A merge or close with no stamp (imports, pre-0029 merges)
 87// names the state without claiming a byline it cannot back up.
 88func TestMRHeaderByState(t *testing.T) {
 89	open := renderMR(t, testMR("open"), nil, nil)
 90	if !strings.Contains(open, "opened by") {
 91		t.Errorf("open MR does not say who opened it:\n%s", open)
 92	}
 93
 94	merged := testMR("merged")
 95	merged.MergedAt, merged.MergedBy = "2026-08-27T14:03:11.000Z", "cmc"
 96	out := renderMR(t, merged, nil, nil)
 97	for _, want := range []string{"merged", "krz/hutch:org-native-rendering", "2026-08-27 14:03"} {
 98		if !strings.Contains(out, want) {
 99			t.Errorf("merged header missing %q:\n%s", want, out)
100		}
101	}
102	if strings.Contains(out, "opened by") {
103		t.Errorf("merged MR still says it was opened:\n%s", out)
104	}
105
106	closed := testMR("closed")
107	closed.ClosedAt, closed.ClosedBy = "2026-08-27T14:03:11.000Z", "cmc"
108	out = renderMR(t, closed, nil, nil)
109	if !strings.Contains(out, "without merging") || strings.Contains(out, "opened by") {
110		t.Errorf("closed header:\n%s", out)
111	}
112
113	// Imports and pre-0029 merges carry no stamp; the wording drops the
114	// byline claim rather than naming nobody or inventing a time.
115	out = renderMR(t, testMR("merged"), nil, nil)
116	if strings.Contains(out, "opened by") || strings.Contains(out, " on 20") || strings.Contains(out, "merged by") {
117		t.Errorf("unstamped merged header:\n%s", out)
118	}
119	if !strings.Contains(out, "merged") {
120		t.Errorf("unstamped merged header drops the state entirely:\n%s", out)
121	}
122
123	out = renderMR(t, testMR("closed"), nil, nil)
124	if strings.Contains(out, "opened by") || strings.Contains(out, " on 20") || strings.Contains(out, "closed without merging by") {
125		t.Errorf("unstamped closed header:\n%s", out)
126	}
127	if !strings.Contains(out, "without merging") {
128		t.Errorf("unstamped closed header drops the state entirely:\n%s", out)
129	}
130}
131
132// Approvals and checks carry their times in the aside, so reading the MR
133// does not mean opening the build.
134func TestMRAsideTimestamps(t *testing.T) {
135	out := renderMR(t, testMR("open"),
136		[]store.MRReview{{Reviewer: "cmc", Verdict: "approve", CreatedAt: "2026-08-27T14:03:11.000Z"}},
137		[]store.Check{
138			{CommitStatus: store.CommitStatus{Context: "ci/test", State: "success",
139				UpdatedAt: "2026-08-27T14:05:00.000Z"}, Duration: 72 * time.Second, Build: 60},
140			{CommitStatus: store.CommitStatus{Context: "external/lint", State: "success",
141				UpdatedAt: "2026-08-27T14:06:00.000Z"}},
142		})
143	for _, want := range []string{"2026-08-27 14:03", "2026-08-27 14:05", "1m12s", "2026-08-27 14:06"} {
144		if !strings.Contains(out, want) {
145			t.Errorf("aside missing %q:\n%s", want, out)
146		}
147	}
148}
149
150// A job a path filter excluded has no build behind its status: the page
151// must show it as skipped rather than linking to a build that never ran
152// (#172).
153func TestMRChecksRenderSkippedWithoutBuildLink(t *testing.T) {
154	out := renderMR(t, testMR("open"), nil, []store.Check{
155		{CommitStatus: store.CommitStatus{Context: "ci/unit", State: "skipped",
156			Description: "every changed file matched paths-ignore", UpdatedAt: "2026-08-27T14:05:00.000Z"}},
157	})
158	row := ""
159	for _, line := range strings.Split(out, "\n") {
160		if strings.Contains(line, "ci/unit") {
161			row = line
162		}
163	}
164	if row == "" || !strings.Contains(row, ">skipped<") {
165		t.Fatalf("skipped check not rendered:\n%s", out)
166	}
167	if strings.Contains(row, "<a href") {
168		t.Errorf("skipped check with no build linked anyway: %s", row)
169	}
170}
171
172// The gates block says what the merge is waiting on before a merge is
173// refused (#199): every unmet gate, the approval count, the outstanding
174// owners, and whether a fast-forward is possible.
175func TestMRGatesRender(t *testing.T) {
176	render := func(g *control.GatesOut) string {
177		t.Helper()
178		var sb strings.Builder
179		if err := web.Render(&sb, "mr.html", mrPageData{
180			repoPage: testRepoPage(), MR: testMR("open"), View: "conversation", Gates: g,
181		}); err != nil {
182			t.Fatalf("render: %v", err)
183		}
184		return sb.String()
185	}
186	out := render(&control.GatesOut{ApprovalsRequired: 2, Approvals: []string{"bob"},
187		OwnersOutstanding: []control.OwnersOut{{Files: []string{"svc.go"}, Owners: []string{"carol"}}},
188		Unmet:             []string{"krz/hutch requires 2 fresh approval(s); !42 has 1", "CODEOWNERS approval missing for: svc.go (owned by carol)"}})
189	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"} {
190		if !strings.Contains(out, want) {
191			t.Errorf("gates block missing %q:\n%s", want, out)
192		}
193	}
194	if out := render(&control.GatesOut{FastForward: true}); !strings.Contains(out, "All gates met") || !strings.Contains(out, "fast-forward possible") {
195		t.Errorf("met gates not rendered:\n%s", out)
196	}
197	if out := render(nil); strings.Contains(out, "Merge gates") {
198		t.Errorf("gates block on a merge request without gates:\n%s", out)
199	}
200}