internal/httpd/mrpage_test.go
324 lines · 11901 bytes
15 symbols in this file
mrPageDataTestMRDiffViewNamesAPrunedHeadTestMRPageHasDiscussionHeadingTestRepoHeaderWatchButtonNamesMutedStaterenderMRtestMRTestMRPageLabelsNoYetOnFinishedStateTestMRPageLabelsNoneYetOnOpenStateTestMRPageLabelsNoLabelsOnClosedStateTestMRPageReviewersEmptyStateDropsNobodyTestMRHeaderByStateTestMRAsideTimestampsTestMRChecksRenderSkippedWithoutBuildLinkTestMRGatesRenderTestMRPageListsRevisionsWithCompareLinks
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}