1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "context"
7 "time"
8 "testing"
9
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/mock"
12 "github.com/stretchr/testify/require"
13
14 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports"
16 "bnema.dev/ermite/internal/ports/mocks"
17)
18
19func repoWithModes(modes ...domain.MergeMode) ports.RepoView {
20 var v ports.RepoView
21 v.Settings.AllowedMergeModes = modes
22 return v
23}
24
25func watchOf(ev domain.Event) ports.WatchEvent { return ports.WatchEvent{Event: ev} }
26
27func TestMergeabilityView(t *testing.T) {
28 t.Parallel()
29 for _, tt := range []struct {
30 name string
31 m domain.Mergeability
32 label, detail string
33 ffOK, mergeOK bool
34 note string
35 paths []string
36 }{
37 {"up to date", domain.Mergeability{State: domain.MergeabilityUpToDate}, "Ready to merge", "", true, true, "", nil},
38 {"behind", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 2}, "Behind main by 2 commits", "merges without conflicts", false, true, "", nil},
39 {"behind by one", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "Behind main by 1 commit", "merges without conflicts", false, true, "", nil},
40 {"behind, capped", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1000, BehindCapped: true}, "Behind main by 1000+ commits", "merges without conflicts", false, true, "", nil},
41 {"conflict", domain.Mergeability{State: domain.MergeabilityConflict, ConflictPaths: []string{"a.go"}}, "Conflicts with main", "rebase needed", false, false, "", []string{"a.go"}},
42 {"conflict, truncated", domain.Mergeability{State: domain.MergeabilityConflict, ConflictPaths: []string{"a.go"}, ConflictPathsTruncated: true}, "Conflicts with main", "rebase needed", false, false, "more files conflict than are listed.", []string{"a.go"}},
43 {"no common history", domain.Mergeability{State: domain.MergeabilityConflict}, "Conflicts with main", "rebase needed", false, false, "no common history.", nil},
44 {"checking", domain.Mergeability{State: domain.MergeabilityChecking}, "Checking…", "", false, false, "", nil},
45 {"none", domain.Mergeability{}, "Checking…", "", false, false, "", nil},
46 } {
47 t.Run(tt.name, func(t *testing.T) {
48 t.Parallel()
49 v := mergeabilityView(tt.m, "main")
50 assert.Equal(t, tt.label, v.Label)
51 assert.Equal(t, tt.detail, v.Detail)
52 assert.Equal(t, tt.ffOK, v.FFOk)
53 assert.Equal(t, tt.mergeOK, v.MergeOk)
54 assert.Equal(t, tt.note, v.PathsNote)
55 assert.Equal(t, tt.paths, v.Paths)
56 assert.Equal(t, !tt.ffOK, v.FFReason != "", "a mode that is not possible says why")
57 assert.Equal(t, !tt.mergeOK, v.MergeReason != "")
58 })
59 }
60}
61
62func TestMergeBoxModes(t *testing.T) {
63 t.Parallel()
64 d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
65 s := &Server{}
66
67 behind := s.mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main"))
68 require.Len(t, behind.Modes, 3)
69 assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind")
70 assert.NotEmpty(t, behind.Modes[0].Reason)
71 assert.False(t, behind.Modes[1].Disabled)
72 assert.False(t, behind.Modes[2].Disabled)
73 assert.Equal(t, 1, behind.Checked(), "the first possible mode is checked")
74 assert.True(t, behind.CanMerge())
75 assert.True(t, behind.Signed)
76
77 conflict := s.mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main"))
78 assert.False(t, conflict.CanMerge())
79 assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled")
80 assert.Equal(t, 0, conflict.Checked())
81 assert.Contains(t, conflict.CLI.Lines[2], "--mode merge")
82 assert.NotContains(t, conflict.CLI.Lines[1], "--squash")
83}
84
85func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) {
86 t.Parallel()
87 d := &repoData{repo: repoWithModes(domain.MergeModeFastForward)}
88 b := (&Server{}).mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
89 assert.False(t, b.Signed, "no commit is created, nothing to explain")
90 assert.Len(t, b.Modes, 1)
91}
92
93func TestMergeBoxNamesTheSigningKey(t *testing.T) {
94 t.Parallel()
95 key := mocks.NewSigningKeyService(t)
96 key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}})
97 d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
98 b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{}, "main"))
99 assert.Equal(t, "SHA256:abc", b.Fingerprint)
100}
101
102func TestMergeErrorTravelsInTheContext(t *testing.T) {
103 t.Parallel()
104 assert.Empty(t, mergeErrorOf(context.Background()))
105 assert.Equal(t, "boom", mergeErrorOf(withMergeError(context.Background(), "boom")))
106}
107
108func TestMergeabilityLiveEvents(t *testing.T) {
109 t.Parallel()
110 assert.True(t, liveEvent(watchOf(domain.MergeRequestMergeability{Number: 1})))
111 svc := mocks.NewMergeRequestService(t)
112 svc.EXPECT().Mergeability(mock.Anything, mock.Anything, mock.Anything, int64(1)).
113 Return(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 3}, nil)
114 l := liveStream{s: &Server{cfg: Config{MergeRequests: svc}}, repo: "demo", view: "mr", n: "1", target: "main"}
115 ctx := context.WithValue(context.Background(), signedInKey{}, struct{}{})
116
117 fs, err := l.repoPage(ctx, watchOf(domain.MergeRequestMergeability{Number: 1}))
118 require.NoError(t, err)
119 require.Len(t, fs, 1)
120 assert.Equal(t, "mr-mergeability", fs[0].event)
121 assert.Contains(t, fs[0].html, "Behind main by 3 commits")
122
123 // Another request, or a page that is not one: nothing.
124 fs, err = l.repoPage(ctx, watchOf(domain.MergeRequestMergeability{Number: 2}))
125 require.NoError(t, err)
126 assert.Empty(t, fs)
127 // The box is the owner's.
128 fs, err = l.repoPage(context.Background(), watchOf(domain.MergeRequestMergeability{Number: 1}))
129 require.NoError(t, err)
130 assert.Empty(t, fs)
131}
132
133func TestMRPanelsAndMenus(t *testing.T) {
134 p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true,
135 ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}}
136 footer := renderToString(t, mrFooter(p))
137 assert.Contains(t, footer, `<footer class="mr-footer"><nav data-mr-tabs`)
138 assert.NotContains(t, footer, `<details`)
139 assert.NotContains(t, footer, `<summary`)
140 // No hidden attribute: the CSS shows the panel of the URL fragment until
141 // mr.js takes over, so the tabs work as anchors without scripts.
142 assert.Contains(t, footer, `id="mr-commits" data-mr-panel role="region"`)
143 assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel role="region"`)
144 assert.NotContains(t, footer, `data-mr-panel hidden`)
145 css, err := staticFS.ReadFile("static/css/60-mr.css")
146 require.NoError(t, err)
147 assert.Contains(t, string(css), `&:not([data-panels]) [data-mr-panel]:not(:target, :has(:target)) { display: none; }`)
148 assert.Contains(t, footer, `data-mr-tabs`)
149 assert.Contains(t, footer, `id="mr-commits"`)
150 assert.Contains(t, footer, `id="mr-checks"`)
151 assert.Contains(t, footer, `<table class="mr-commits">`)
152 assert.Contains(t, footer, "500+")
153 assert.Contains(t, footer, "more commits")
154 assert.Contains(t, footer, "1/2 failed")
155 rail := renderToString(t, mrRail(p))
156 assert.Contains(t, rail, "g f")
157 for _, text := range []string{"Checks", "Commits", "Settings", "mr-checks"} {
158 assert.NotContains(t, rail, text)
159 }
160 more := renderToString(t, moreMenu(p))
161 for _, text := range []string{"Merge settings", "squash, merge", "failing ones block", `id="mr-signing"`, "fingerprint", "ermite signing-key show"} {
162 assert.Contains(t, more, text)
163 }
164 merge := renderToString(t, mergeMenu(p))
165 assert.NotContains(t, merge, `<dl`)
166 assert.NotContains(t, merge, `mr-signing`)
167 assert.Contains(t, merge, "the source branch was deleted")
168 bar := renderToString(t, mrBar(MRPage{}))
169 assert.Contains(t, bar, `id="mr-ck"`)
170 assert.Contains(t, bar, `href="#mr-checks"`)
171 assert.Contains(t, bar, "Merge settings")
172 assert.NotContains(t, bar, ">Close</button>")
173}
174
175func TestMRReadPresentationCommitCount(t *testing.T) {
176 commits := func(n int) []domain.Commit { return make([]domain.Commit, n) }
177 for _, tc := range []struct {
178 name string
179 commits []domain.Commit
180 truncated bool
181 count string
182 note string
183 }{
184 {"empty", nil, false, "0", ""},
185 {"complete", commits(2), false, "2", ""},
186 {"truncated", commits(3), true, "3+", "the branch has more commits than the 3 listed."},
187 } {
188 t.Run(tc.name, func(t *testing.T) {
189 s := &Server{cfg: Config{Now: time.Now}}
190 p := &MRPage{}
191 s.mrReadPresentation(p, "demo", 1, ports.MergeRequestDetail{Commits: tc.commits, CommitsTruncated: tc.truncated}, false, false)
192 assert.Equal(t, tc.count, p.CommitCount)
193 assert.Equal(t, tc.note, p.CommitsNote)
194 assert.Len(t, p.Commits, len(tc.commits))
195 })
196 }
197}