internal/control/mr_test.go
339 lines · 12721 bytes
14 symbols in this file
mrTestCtxtwoMRTestRepomrShowJSONeventDataForTestMRCloseWithByTestMRCloseBySelfRefusedTestMRCloseByMissingRefusedTestMREditSupersededBySetAndClearTestMREditSupersededBySelfRefusedTestMREditSupersededByMissingRefusedTestMREditSupersededByOnOpenMRRefusedTestMRShowPluralizesMultiRowSectionsTestRequireContextsSetsAndClearsTestSettingsShowRequiredContexts
1package control
2
3import (
4 "bytes"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "slices"
9 "strconv"
10 "strings"
11 "testing"
12
13 "gitbay.org/gitbay/internal/protocol"
14 "gitbay.org/gitbay/internal/store"
15)
16
17// mrTestCtx runs control commands as owner against st, capturing output.
18func mrTestCtx(st *store.Store, owner store.User) (*Ctx, *bytes.Buffer, *bytes.Buffer) {
19 out, errOut := &bytes.Buffer{}, &bytes.Buffer{}
20 c := &Ctx{User: owner, Scope: "full", Store: st, Stdout: out, Stderr: errOut}
21 return c, out, errOut
22}
23
24// twoMRTestRepo is a repository with two open merge requests, both
25// authored by the returned owner, so authorOrWrite never gets in the way.
26func twoMRTestRepo(t *testing.T) (*store.Store, store.Repo, store.User) {
27 t.Helper()
28 st, repo, uid := newQueueTestRepo(t)
29 owner := store.User{ID: uid, Username: "alice"}
30 if _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature1", "main", "one", "", "abc111", "md", false); err != nil {
31 t.Fatal(err)
32 }
33 if _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature2", "main", "two", "", "abc222", "md", false); err != nil {
34 t.Fatal(err)
35 }
36 return st, repo, owner
37}
38
39func mrShowJSON(t *testing.T, st *store.Store, owner store.User, path string, n int64) mrOut {
40 t.Helper()
41 c, out, errOut := mrTestCtx(st, owner)
42 if code := Dispatch(c, []string{"mr", "show", path, strconv.FormatInt(n, 10), "--json"}); code != protocol.ExitOK {
43 t.Fatalf("mr show: exit %d, %s", code, errOut.String())
44 }
45 var env struct {
46 Data mrOut `json:"data"`
47 }
48 if err := json.Unmarshal(out.Bytes(), &env); err != nil {
49 t.Fatalf("mr show JSON: %v\n%s", err, out.String())
50 }
51 return env.Data
52}
53
54// eventDataFor pulls the most recent data_json for a kind, so a test can
55// check what mr close recorded without a store accessor built just for it.
56func eventDataFor(t *testing.T, st *store.Store, kind string) string {
57 t.Helper()
58 var data string
59 err := st.DB.QueryRow("SELECT data_json FROM events WHERE kind = ? ORDER BY id DESC LIMIT 1", kind).Scan(&data)
60 if err != nil {
61 t.Fatalf("event %s: %v", kind, err)
62 }
63 return data
64}
65
66// Closing a merge request can name the one that carries its change
67// forward; mr show and the mr.closed event both then carry it (#223).
68func TestMRCloseWithBy(t *testing.T) {
69 st, repo, owner := twoMRTestRepo(t)
70 c, _, errOut := mrTestCtx(st, owner)
71 if code := Dispatch(c, []string{"mr", "close", repo.Path(), "1", "--by", "2"}); code != protocol.ExitOK {
72 t.Fatalf("mr close: exit %d, %s", code, errOut.String())
73 }
74 got := mrShowJSON(t, st, owner, repo.Path(), 1)
75 if got.State != "closed" || got.SupersededBy != 2 {
76 t.Fatalf("mr show !1 = %+v, want closed superseded_by 2", got)
77 }
78 if data := eventDataFor(t, st, "mr.closed"); !strings.Contains(data, `"by":2`) {
79 t.Fatalf("mr.closed event = %s, want it to carry by:2", data)
80 }
81}
82
83// mr close --by refuses a merge request naming itself.
84func TestMRCloseBySelfRefused(t *testing.T) {
85 st, repo, owner := twoMRTestRepo(t)
86 c, _, errOut := mrTestCtx(st, owner)
87 code := Dispatch(c, []string{"mr", "close", repo.Path(), "1", "--by", "1"})
88 if code != protocol.ExitUsage {
89 t.Fatalf("exit = %d, want %d; stderr: %s", code, protocol.ExitUsage, errOut.String())
90 }
91 if !strings.Contains(errOut.String(), "cannot supersede itself") {
92 t.Fatalf("stderr = %q, want it to say a merge request cannot supersede itself", errOut.String())
93 }
94}
95
96// mr close --by refuses a merge request number that does not exist in
97// the repository.
98func TestMRCloseByMissingRefused(t *testing.T) {
99 st, repo, owner := twoMRTestRepo(t)
100 c, _, errOut := mrTestCtx(st, owner)
101 code := Dispatch(c, []string{"mr", "close", repo.Path(), "1", "--by", "99"})
102 if code != protocol.ExitNotFound {
103 t.Fatalf("exit = %d, want %d; stderr: %s", code, protocol.ExitNotFound, errOut.String())
104 }
105 if !strings.Contains(errOut.String(), "no merge request !99") {
106 t.Fatalf("stderr = %q, want it to name !99 as missing", errOut.String())
107 }
108}
109
110// mr edit --superseded-by sets and clears the field on a closed merge
111// request.
112func TestMREditSupersededBySetAndClear(t *testing.T) {
113 st, repo, owner := twoMRTestRepo(t)
114 c, _, errOut := mrTestCtx(st, owner)
115 if code := Dispatch(c, []string{"mr", "close", repo.Path(), "1"}); code != protocol.ExitOK {
116 t.Fatalf("mr close: exit %d, %s", code, errOut.String())
117 }
118 c, _, errOut = mrTestCtx(st, owner)
119 if code := Dispatch(c, []string{"mr", "edit", repo.Path(), "1", "--superseded-by", "2"}); code != protocol.ExitOK {
120 t.Fatalf("mr edit --superseded-by 2: exit %d, %s", code, errOut.String())
121 }
122 if got := mrShowJSON(t, st, owner, repo.Path(), 1); got.SupersededBy != 2 {
123 t.Fatalf("SupersededBy = %d, want 2", got.SupersededBy)
124 }
125 c, _, errOut = mrTestCtx(st, owner)
126 if code := Dispatch(c, []string{"mr", "edit", repo.Path(), "1", "--superseded-by", "none"}); code != protocol.ExitOK {
127 t.Fatalf("mr edit --superseded-by none: exit %d, %s", code, errOut.String())
128 }
129 if got := mrShowJSON(t, st, owner, repo.Path(), 1); got.SupersededBy != 0 {
130 t.Fatalf("SupersededBy after clear = %d, want 0", got.SupersededBy)
131 }
132}
133
134// mr edit --superseded-by refuses a self-reference the same way mr close
135// --by does.
136func TestMREditSupersededBySelfRefused(t *testing.T) {
137 st, repo, owner := twoMRTestRepo(t)
138 c, _, errOut := mrTestCtx(st, owner)
139 if code := Dispatch(c, []string{"mr", "close", repo.Path(), "1"}); code != protocol.ExitOK {
140 t.Fatalf("mr close: exit %d, %s", code, errOut.String())
141 }
142 c, _, errOut = mrTestCtx(st, owner)
143 code := Dispatch(c, []string{"mr", "edit", repo.Path(), "1", "--superseded-by", "1"})
144 if code != protocol.ExitUsage {
145 t.Fatalf("exit = %d, want %d; stderr: %s", code, protocol.ExitUsage, errOut.String())
146 }
147 if !strings.Contains(errOut.String(), "cannot supersede itself") {
148 t.Fatalf("stderr = %q, want it to say a merge request cannot supersede itself", errOut.String())
149 }
150}
151
152// mr edit --superseded-by refuses a merge request number that does not
153// exist in the repository.
154func TestMREditSupersededByMissingRefused(t *testing.T) {
155 st, repo, owner := twoMRTestRepo(t)
156 c, _, errOut := mrTestCtx(st, owner)
157 if code := Dispatch(c, []string{"mr", "close", repo.Path(), "1"}); code != protocol.ExitOK {
158 t.Fatalf("mr close: exit %d, %s", code, errOut.String())
159 }
160 c, _, errOut = mrTestCtx(st, owner)
161 code := Dispatch(c, []string{"mr", "edit", repo.Path(), "1", "--superseded-by", "99"})
162 if code != protocol.ExitNotFound {
163 t.Fatalf("exit = %d, want %d; stderr: %s", code, protocol.ExitNotFound, errOut.String())
164 }
165 if !strings.Contains(errOut.String(), "no merge request !99") {
166 t.Fatalf("stderr = %q, want it to name !99 as missing", errOut.String())
167 }
168}
169
170// mr edit --superseded-by refuses an open merge request: only a closed
171// one can be superseded.
172func TestMREditSupersededByOnOpenMRRefused(t *testing.T) {
173 st, repo, owner := twoMRTestRepo(t)
174 c, _, errOut := mrTestCtx(st, owner)
175 code := Dispatch(c, []string{"mr", "edit", repo.Path(), "1", "--superseded-by", "2"})
176 if code != protocol.ExitUsage {
177 t.Fatalf("exit = %d, want %d; stderr: %s", code, protocol.ExitUsage, errOut.String())
178 }
179 if !strings.Contains(errOut.String(), "only a closed merge request can be superseded") {
180 t.Fatalf("stderr = %q, want the closed-only refusal", errOut.String())
181 }
182}
183
184// mr show pluralizes multi-row section headings with counts.
185func TestMRShowPluralizesMultiRowSections(t *testing.T) {
186 st, repo, uid := newQueueTestRepo(t)
187 owner := store.User{ID: uid, Username: "alice"}
188
189 // Create git commits for the MR
190 git := gitRunner(t)
191 root := t.TempDir()
192
193 // Create a temporary repository to set up commits
194 src := filepath.Join(root, "src")
195 os.MkdirAll(src, 0o755)
196 git(root, "init", "-q", "-b", "main", "src")
197
198 // Create base commit on main
199 os.WriteFile(filepath.Join(src, "file.txt"), []byte("content"), 0o644)
200 git(src, "add", ".")
201 git(src, "commit", "-q", "-m", "initial")
202
203 // Create feature branch with 2 commits
204 git(src, "checkout", "-q", "-b", "feature")
205 os.WriteFile(filepath.Join(src, "file.txt"), []byte("content1"), 0o644)
206 git(src, "add", ".")
207 git(src, "commit", "-q", "-m", "commit1")
208
209 os.WriteFile(filepath.Join(src, "file.txt"), []byte("content2"), 0o644)
210 git(src, "add", ".")
211 git(src, "commit", "-q", "-m", "commit2")
212 headSHA := strings.TrimSpace(git(src, "rev-parse", "HEAD"))
213
214 // Clone as a bare repository to the gitbay path
215 dir := RepoDir(root, repo.OwnerName, repo.Name)
216 os.MkdirAll(filepath.Dir(dir), 0o755)
217 git(root, "clone", "-q", "--bare", "src", dir)
218
219 // Create the MR head ref in the bare repository
220 git(dir, "update-ref", "refs/merge-requests/1/head", headSHA)
221
222 // Create an MR
223 _, err := st.CreateMR(repo.ID, uid, repo.ID, "feature", "main", "Feature", "", headSHA, "md", false)
224 if err != nil {
225 t.Fatalf("CreateMR: %v", err)
226 }
227
228 // Add 2 checks
229 if err := st.SetCommitStatus(repo.ID, headSHA, "check1", "success", "", "", 0); err != nil {
230 t.Fatal(err)
231 }
232 if err := st.SetCommitStatus(repo.ID, headSHA, "check2", "success", "", "", 0); err != nil {
233 t.Fatal(err)
234 }
235
236 // Add 2 reviews
237 bob, err := st.CreateUser("bob", false)
238 if err != nil {
239 t.Fatal(err)
240 }
241 if err := st.AddMRReview(1, bob, "approve", headSHA); err != nil {
242 t.Fatal(err)
243 }
244 charlie, err := st.CreateUser("charlie", false)
245 if err != nil {
246 t.Fatal(err)
247 }
248 if err := st.AddMRReview(1, charlie, "approve", headSHA); err != nil {
249 t.Fatal(err)
250 }
251
252 // Call mr show in plain text mode
253 c, out, errOut := mrTestCtx(st, owner)
254 c.Cfg.Server.Root = root
255 if code := Dispatch(c, []string{"mr", "show", repo.Path(), "1"}); code != protocol.ExitOK {
256 t.Fatalf("mr show: exit %d, %s", code, errOut.String())
257 }
258
259 outStr := out.String()
260 for _, want := range []string{"commits (2):", "checks (2):", "reviews (2):"} {
261 if !strings.Contains(outStr, want) {
262 t.Errorf("missing %q in:\n%s", want, outStr)
263 }
264 }
265}
266
267// require-contexts stores a deduplicated list and turns require_checks
268// on with it; an empty list clears the contexts and leaves
269// require_checks as it was. A context with whitespace is refused (#258).
270func TestRequireContextsSetsAndClears(t *testing.T) {
271 st, repo, uid := newQueueTestRepo(t)
272 alice := store.User{ID: uid, Username: "alice"}
273 dispatch := func(args ...string) int {
274 t.Helper()
275 c, _, _ := mrTestCtx(st, alice)
276 return Dispatch(c, args)
277 }
278 contexts := func(names ...string) int {
279 t.Helper()
280 return dispatch(append([]string{"repo", "settings", "require-contexts", repo.Path()}, names...)...)
281 }
282 settings := func() store.RepoSettings {
283 t.Helper()
284 got, err := st.RepoByID(repo.ID)
285 if err != nil {
286 t.Fatal(err)
287 }
288 return got.Settings
289 }
290
291 if settings().RequireChecks {
292 t.Fatal("require_checks on in a new repository")
293 }
294 if code := contexts("lint", "ext/deploy", "lint"); code != protocol.ExitOK {
295 t.Fatalf("set: exit %d", code)
296 }
297 if s := settings(); !slices.Equal(s.RequiredContexts, []string{"lint", "ext/deploy"}) || !s.RequireChecks {
298 t.Fatalf("stored %v, require_checks %v; want [lint ext/deploy], on", s.RequiredContexts, s.RequireChecks)
299 }
300 if code := contexts("bad context"); code != protocol.ExitUsage {
301 t.Fatalf("a context with a space: exit %d", code)
302 }
303 if code := contexts(); code != protocol.ExitOK {
304 t.Fatalf("clear: exit %d", code)
305 }
306 if s := settings(); len(s.RequiredContexts) != 0 || !s.RequireChecks {
307 t.Fatalf("after clearing: contexts %v, require_checks %v; want none, still on", s.RequiredContexts, s.RequireChecks)
308 }
309 if code := dispatch("repo", "settings", "require-checks", repo.Path(), "off"); code != protocol.ExitOK {
310 t.Fatalf("require-checks off: exit %d", code)
311 }
312 if code := contexts(); code != protocol.ExitOK {
313 t.Fatalf("clear again: exit %d", code)
314 }
315 if settings().RequireChecks {
316 t.Fatal("clearing the list turned require_checks on")
317 }
318}
319
320// settings show prints the checks gate beside the contexts it waits for,
321// so a list that turned the gate on is visible where the gate is (#258).
322func TestSettingsShowRequiredContexts(t *testing.T) {
323 st, repo, uid := newQueueTestRepo(t)
324 alice := store.User{ID: uid, Username: "alice"}
325 c, _, _ := mrTestCtx(st, alice)
326 if code := Dispatch(c, []string{"repo", "settings", "require-contexts", repo.Path(), "ext/deploy", "lint"}); code != protocol.ExitOK {
327 t.Fatalf("require-contexts: exit %d", code)
328 }
329 c, out, _ := mrTestCtx(st, alice)
330 if code := Dispatch(c, []string{"repo", "settings", "show", repo.Path()}); code != protocol.ExitOK {
331 t.Fatalf("settings show: exit %d", code)
332 }
333 got := strings.Join(strings.Fields(out.String()), " ")
334 for _, want := range []string{"require checks true", "required contexts ext/deploy, lint"} {
335 if !strings.Contains(got, want) {
336 t.Errorf("settings show lacks %q:\n%s", want, out.String())
337 }
338 }
339}