internal/httpd/mrpage_test.go
289 lines · 10680 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
62func renderMR(t *testing.T, m store.MR, reviews []store.MRReview, checks []store.Check) string {
63 rows := make([]reviewRow, 0, len(reviews))
64 for _, r := range reviews {
65 // The page test renders reviews that count; whether a given
66 // reviewer's does is decided by access, which e2e covers.
67 rows = append(rows, reviewRow{MRReview: r, Counts: true})
68 }
69 t.Helper()
70 var sb strings.Builder
71 if err := web.Render(&sb, "mr.html", mrPageData{
72 repoPage: testRepoPage(), MR: m, View: "conversation",
73 Reviews: rows, Checks: checks, Combined: "",
74 }); err != nil {
75 t.Fatalf("render: %v", err)
76 }
77 return sb.String()
78}
79
80func testMR(state string) store.MR {
81 return store.MR{Number: 42, Title: "org native rendering", Author: "cmc", State: state,
82 SourcePath: "krz/hutch", SourceRef: "org-native-rendering", TargetRef: "main",
83 HeadSHA: "ff6271a9d4570cd46f169091637a9d2e40ad5c2b"}
84}
85
86// A finished merge request states an empty label list as a fact, not a
87// promise something is still coming (#270).
88func TestMRPageLabelsNoYetOnFinishedState(t *testing.T) {
89 var sb strings.Builder
90 if err := web.Render(&sb, "mr.html", mrPageData{
91 repoPage: testRepoPage(), MR: testMR("merged"), View: "conversation",
92 }); err != nil {
93 t.Fatalf("render: %v", err)
94 }
95 if !strings.Contains(sb.String(), "no labels") {
96 t.Error(`merged MR with no labels should read "no labels", not "none yet"`)
97 }
98}
99
100// An open MR can still gain labels, so its empty state keeps "none yet"
101// rather than the finished-item wording (#270).
102func TestMRPageLabelsNoneYetOnOpenState(t *testing.T) {
103 var sb strings.Builder
104 if err := web.Render(&sb, "mr.html", mrPageData{
105 repoPage: testRepoPage(), MR: testMR("open"), View: "conversation",
106 }); err != nil {
107 t.Fatalf("render: %v", err)
108 }
109 out := sb.String()
110 if !strings.Contains(out, "none yet") {
111 t.Error(`open MR with no labels should read "none yet"`)
112 }
113 if strings.Contains(out, "no labels") {
114 t.Error(`open MR with no labels should not read "no labels"`)
115 }
116}
117
118// A closed MR is finished the same way a merged one is (#270).
119func TestMRPageLabelsNoLabelsOnClosedState(t *testing.T) {
120 var sb strings.Builder
121 if err := web.Render(&sb, "mr.html", mrPageData{
122 repoPage: testRepoPage(), MR: testMR("closed"), View: "conversation",
123 }); err != nil {
124 t.Fatalf("render: %v", err)
125 }
126 if !strings.Contains(sb.String(), "no labels") {
127 t.Error(`closed MR with no labels should read "no labels", not "none yet"`)
128 }
129}
130
131func TestMRPageReviewersEmptyStateDropsNobody(t *testing.T) {
132 var sb strings.Builder
133 if err := web.Render(&sb, "mr.html", mrPageData{
134 repoPage: testRepoPage(), MR: testMR("open"), View: "conversation",
135 }); err != nil {
136 t.Fatalf("render: %v", err)
137 }
138 if strings.Contains(sb.String(), "nobody yet") {
139 t.Error(`reviewers empty state should read "no reviewers"`)
140 }
141 if !strings.Contains(sb.String(), "no reviewers") {
142 t.Error(`missing "no reviewers"`)
143 }
144}
145
146// The header states what happened to the MR: who opened, merged, or closed
147// it, and when. A merge or close with no stamp (imports, pre-0029 merges)
148// names the state without claiming a byline it cannot back up.
149func TestMRHeaderByState(t *testing.T) {
150 open := renderMR(t, testMR("open"), nil, nil)
151 if !strings.Contains(open, "opened by") {
152 t.Errorf("open MR does not say who opened it:\n%s", open)
153 }
154
155 merged := testMR("merged")
156 merged.MergedAt, merged.MergedBy = "2026-08-27T14:03:11.000Z", "cmc"
157 out := renderMR(t, merged, nil, nil)
158 for _, want := range []string{"merged", "krz/hutch:org-native-rendering", "2026-08-27 14:03"} {
159 if !strings.Contains(out, want) {
160 t.Errorf("merged header missing %q:\n%s", want, out)
161 }
162 }
163 if strings.Contains(out, "opened by") {
164 t.Errorf("merged MR still says it was opened:\n%s", out)
165 }
166
167 closed := testMR("closed")
168 closed.ClosedAt, closed.ClosedBy = "2026-08-27T14:03:11.000Z", "cmc"
169 out = renderMR(t, closed, nil, nil)
170 if !strings.Contains(out, "without merging") || strings.Contains(out, "opened by") {
171 t.Errorf("closed header:\n%s", out)
172 }
173
174 // Imports and pre-0029 merges carry no stamp; the wording drops the
175 // byline claim rather than naming nobody or inventing a time.
176 out = renderMR(t, testMR("merged"), nil, nil)
177 if strings.Contains(out, "opened by") || strings.Contains(out, " on 20") || strings.Contains(out, "merged by") {
178 t.Errorf("unstamped merged header:\n%s", out)
179 }
180 if !strings.Contains(out, "merged") {
181 t.Errorf("unstamped merged header drops the state entirely:\n%s", out)
182 }
183
184 out = renderMR(t, testMR("closed"), nil, nil)
185 if strings.Contains(out, "opened by") || strings.Contains(out, " on 20") || strings.Contains(out, "closed without merging by") {
186 t.Errorf("unstamped closed header:\n%s", out)
187 }
188 if !strings.Contains(out, "without merging") {
189 t.Errorf("unstamped closed header drops the state entirely:\n%s", out)
190 }
191}
192
193// Approvals and checks carry their times in the aside, so reading the MR
194// does not mean opening the build.
195func TestMRAsideTimestamps(t *testing.T) {
196 out := renderMR(t, testMR("open"),
197 []store.MRReview{{Reviewer: "cmc", Verdict: "approve", CreatedAt: "2026-08-27T14:03:11.000Z"}},
198 []store.Check{
199 {CommitStatus: store.CommitStatus{Context: "ci/test", State: "success",
200 UpdatedAt: "2026-08-27T14:05:00.000Z"}, Duration: 72 * time.Second, Build: 60},
201 {CommitStatus: store.CommitStatus{Context: "external/lint", State: "success",
202 UpdatedAt: "2026-08-27T14:06:00.000Z"}},
203 })
204 for _, want := range []string{"2026-08-27 14:03", "2026-08-27 14:05", "1m12s", "2026-08-27 14:06"} {
205 if !strings.Contains(out, want) {
206 t.Errorf("aside missing %q:\n%s", want, out)
207 }
208 }
209}
210
211// A job a path filter excluded has no build behind its status: the page
212// must show it as skipped rather than linking to a build that never ran
213// (#172).
214func TestMRChecksRenderSkippedWithoutBuildLink(t *testing.T) {
215 out := renderMR(t, testMR("open"), nil, []store.Check{
216 {CommitStatus: store.CommitStatus{Context: "ci/unit", State: "skipped",
217 Description: "every changed file matched paths-ignore", UpdatedAt: "2026-08-27T14:05:00.000Z"}},
218 })
219 row := ""
220 for _, line := range strings.Split(out, "\n") {
221 if strings.Contains(line, "ci/unit") {
222 row = line
223 }
224 }
225 if row == "" || !strings.Contains(row, ">skipped<") {
226 t.Fatalf("skipped check not rendered:\n%s", out)
227 }
228 if strings.Contains(row, "<a href") {
229 t.Errorf("skipped check with no build linked anyway: %s", row)
230 }
231}
232
233// The gates block says what the merge is waiting on before a merge is
234// refused (#199): every unmet gate, the approval count, the outstanding
235// owners, and whether a fast-forward is possible.
236func TestMRGatesRender(t *testing.T) {
237 render := func(g *control.GatesOut) string {
238 t.Helper()
239 var sb strings.Builder
240 if err := web.Render(&sb, "mr.html", mrPageData{
241 repoPage: testRepoPage(), MR: testMR("open"), View: "conversation", Gates: g,
242 }); err != nil {
243 t.Fatalf("render: %v", err)
244 }
245 return sb.String()
246 }
247 out := render(&control.GatesOut{ApprovalsRequired: 2, Approvals: []string{"bob"},
248 OwnersOutstanding: []control.OwnersOut{{Files: []string{"svc.go"}, Owners: []string{"carol"}}},
249 Unmet: []string{"krz/hutch requires 2 fresh approval(s); !42 has 1", "CODEOWNERS approval missing for: svc.go (owned by carol)"}})
250 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"} {
251 if !strings.Contains(out, want) {
252 t.Errorf("gates block missing %q:\n%s", want, out)
253 }
254 }
255 if out := render(&control.GatesOut{FastForward: true}); !strings.Contains(out, "All gates met") || !strings.Contains(out, "fast-forward possible") {
256 t.Errorf("met gates not rendered:\n%s", out)
257 }
258 if out := render(&control.GatesOut{Checks: "pending", ChecksMissing: []string{"ext/deploy"}}); !strings.Contains(out, "waiting on <code>ext/deploy</code>") {
259 t.Errorf("missing required context not rendered:\n%s", out)
260 }
261 if out := render(nil); strings.Contains(out, "Merge gates") {
262 t.Errorf("gates block on a merge request without gates:\n%s", out)
263 }
264}
265
266// Each revision after the first carries a link comparing it to the one
267// before, so a reviewer does not have to type mr range-diff by hand
268// (#269).
269func TestMRPageListsRevisionsWithCompareLinks(t *testing.T) {
270 var sb strings.Builder
271 revs := []store.MRHead{
272 {SHA: "aaaa1111", CreatedAt: "2026-09-23T10:00:00Z"},
273 {SHA: "bbbb2222", CreatedAt: "2026-09-24T10:00:00Z"},
274 }
275 if err := web.Render(&sb, "mr.html", mrPageData{
276 repoPage: testRepoPage(), MR: testMR("open"), View: "conversation", Revisions: revs,
277 }); err != nil {
278 t.Fatalf("render: %v", err)
279 }
280 out := sb.String()
281 for _, want := range []string{"aaaa1111", "bbbb2222", "compare to previous", "from=aaaa1111", "to=bbbb2222"} {
282 if !strings.Contains(out, want) {
283 t.Errorf("missing %q in:\n%s", want, out)
284 }
285 }
286 if strings.Contains(out, "gitbay mr range-diff") {
287 t.Error("still quotes the CLI command instead of linking the new page")
288 }
289}