internal/control/mr_test.go

bd5cf5d7d1f34fa780660fd7562b9ffd9746ee27
gitbay/internal/control/mr_test.go history · blame · raw

339 lines · 12721 bytes

  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}