internal/control/suggestion_test.go

main
gitbay/internal/control/suggestion_test.go history · blame · raw

565 lines · 22244 bytes

23 symbols in this file
  1package control
  2
  3import (
  4	"encoding/json"
  5	"strconv"
  6	"strings"
  7	"testing"
  8
  9	"gitbay.org/gitbay/internal/protocol"
 10	"gitbay.org/gitbay/internal/store"
 11)
 12
 13// suggestFixture is a queueFixture whose feature branch carries a
 14// multi-line file, lib.txt, for suggestions to anchor in.
 15func newSuggestFixture(t *testing.T, set func(*store.RepoSettings)) *queueFixture {
 16	t.Helper()
 17	f := newQueueFixture(t, set)
 18	f.write("lib.txt", "one\ntwo\nthree\nfour\nfive\n")
 19	f.write("dos.txt", "a\r\nb\r\nc\r\n")
 20	f.write("tail.txt", "x\nlast")
 21	f.git(f.src, "add", ".")
 22	f.git(f.src, "commit", "-q", "-m", "lib")
 23	f.moveHead()
 24	return f
 25}
 26
 27// moveHead pushes the fixture's feature branch to the bare repository
 28// and points the merge request at it, as post-receive would.
 29func (f *queueFixture) moveHead() {
 30	f.t.Helper()
 31	f.git(f.src, "push", "-q", "--force", f.dir, "feature")
 32	f.headSHA = strings.TrimSpace(f.git(f.src, "rev-parse", "HEAD"))
 33	f.git(f.dir, "update-ref", mrHeadRef(1), f.headSHA)
 34	if err := f.st.UpdateMRHead(f.mr().ID, f.headSHA, f.targetSH, false); err != nil {
 35		f.t.Fatal(err)
 36	}
 37}
 38
 39// suggest opens a thread on lib.txt start-end with a suggestion block
 40// holding lines, returning the thread id.
 41func (f *queueFixture) suggest(u store.User, path string, start, end int, lines ...string) string {
 42	f.t.Helper()
 43	body := "try this\n```suggestion\n" + strings.Join(lines, "\n")
 44	if len(lines) > 0 {
 45		body += "\n"
 46	}
 47	body += "```\n"
 48	var out, errOut strings.Builder
 49	c := &Ctx{User: u, Scope: "full", Store: f.st, Stdout: &out, Stderr: &errOut, JSON: true,
 50		Stdin: strings.NewReader(body)}
 51	c.Cfg.Server.Root = f.root
 52	c.Cfg.Limits.WriteRate = -1
 53	argv := []string{"mr", "diff-comment", f.repo.Path(), "1", "--path", path,
 54		"--start-line", strconv.Itoa(start), "--line", strconv.Itoa(end), "--file", "-"}
 55	if code := Dispatch(c, argv); code != protocol.ExitOK {
 56		f.t.Fatalf("diff-comment: exit %d, %s", code, errOut.String())
 57	}
 58	var env struct {
 59		Data struct {
 60			Thread int64 `json:"thread"`
 61		} `json:"data"`
 62	}
 63	if err := json.Unmarshal([]byte(out.String()), &env); err != nil {
 64		f.t.Fatal(err)
 65	}
 66	return strconv.Itoa(int(env.Data.Thread))
 67}
 68
 69type threadJSON struct {
 70	ID         int64          `json:"id"`
 71	StartLine  int64          `json:"start_line"`
 72	Line       int64          `json:"line"`
 73	Resolved   string         `json:"resolved_by"`
 74	Suggestion *SuggestionOut `json:"suggestion"`
 75}
 76
 77// unlimited lifts the per-account write limit, which these tests would
 78// otherwise spend for every test in the package that writes as uid 1.
 79func unlimited(c *Ctx) { c.Cfg.Limits.WriteRate = -1 }
 80
 81func (f *queueFixture) mustWrite(u store.User, argv ...string) string {
 82	f.t.Helper()
 83	code, out, errOut := f.runWith(u, unlimited, argv...)
 84	if code != protocol.ExitOK {
 85		f.t.Fatalf("%v: exit %d, %s", argv, code, errOut)
 86	}
 87	return out
 88}
 89
 90func (f *queueFixture) threads(u store.User) []threadJSON {
 91	f.t.Helper()
 92	out := f.mustRun(u, "mr", "threads", f.repo.Path(), "1", "--json")
 93	var env struct {
 94		Data []threadJSON `json:"data"`
 95	}
 96	if err := json.Unmarshal([]byte(out), &env); err != nil {
 97		f.t.Fatalf("threads JSON: %v\n%s", err, out)
 98	}
 99	return env.Data
100}
101
102func (f *queueFixture) suggestion(u store.User, thread string) *SuggestionOut {
103	f.t.Helper()
104	for _, th := range f.threads(u) {
105		if strconv.Itoa(int(th.ID)) == thread {
106			return th.Suggestion
107		}
108	}
109	f.t.Fatalf("no thread %s", thread)
110	return nil
111}
112
113// A suggestion over a range reads back from mr threads as structure:
114// the anchor, the commit and blob it was made against, the lines it
115// replaces and the replacement.
116func TestSuggestionInThreads(t *testing.T) {
117	f := newSuggestFixture(t, nil)
118	id := f.suggest(f.alice, "lib.txt", 2, 3, "TWO", "THREE", "extra")
119	s := f.suggestion(f.alice, id)
120	if s == nil {
121		t.Fatal("thread carries no suggestion")
122	}
123	blob := strings.TrimSpace(f.git(f.dir, "rev-parse", f.headSHA+":lib.txt"))
124	want := SuggestionOut{Path: "lib.txt", StartLine: 2, EndLine: 3, Commit: f.headSHA, Blob: blob,
125		Original: "two\nthree\n", Replacement: "TWO\nTHREE\nextra\n", Apply: "server"}
126	if *s != want {
127		t.Fatalf("suggestion = %+v\nwant %+v", *s, want)
128	}
129	text := f.mustRun(f.alice, "mr", "threads", f.repo.Path(), "1")
130	for _, w := range []string{"lib.txt:2-3", "- two", "+ THREE", "mr apply-suggestion alice/app 1 " + id} {
131		if !strings.Contains(text, w) {
132			t.Errorf("threads text lacks %q:\n%s", w, text)
133		}
134	}
135	if strings.Contains(text, "```suggestion") {
136		t.Errorf("threads text repeats the raw block:\n%s", text)
137	}
138}
139
140// The suggestion goes stale when the lines it replaces change, and not
141// when the file changes elsewhere.
142func TestSuggestionOutdated(t *testing.T) {
143	f := newSuggestFixture(t, nil)
144	id := f.suggest(f.alice, "lib.txt", 2, 2, "TWO")
145	f.write("lib.txt", "one\ntwo\nthree\nfour\nFIVE\n")
146	f.git(f.src, "commit", "-q", "-am", "elsewhere")
147	f.moveHead()
148	if s := f.suggestion(f.alice, id); s.Outdated {
149		t.Fatalf("a change below the range outdated the suggestion: %+v", s)
150	}
151	f.write("lib.txt", "zero\none\ntwo\nthree\nfour\nFIVE\n")
152	f.git(f.src, "commit", "-q", "-am", "shift")
153	f.moveHead()
154	if s := f.suggestion(f.alice, id); !s.Outdated || s.Reason != reasonChanged {
155		t.Fatalf("suggestion after its lines moved = %+v, want outdated", s)
156	}
157	f.git(f.src, "rm", "-q", "lib.txt")
158	f.git(f.src, "commit", "-q", "-m", "gone")
159	f.moveHead()
160	if s := f.suggestion(f.alice, id); !s.Outdated || s.Reason != reasonGone {
161		t.Fatalf("suggestion on a deleted file = %+v, want outdated", s)
162	}
163}
164
165// On a repository requiring signed commits the server does not commit a
166// suggestion, and the thread says the CLI applies it locally.
167func TestSuggestionSignedIsLocal(t *testing.T) {
168	f := newSuggestFixture(t, func(s *store.RepoSettings) { s.RequireSignedCommits = true })
169	id := f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
170	if s := f.suggestion(f.alice, id); s.Apply != "local" {
171		t.Fatalf("apply = %q, want local", s.Apply)
172	}
173}
174
175// What diff-comment refuses before storing a suggestion it could never
176// apply.
177func TestSuggestionRefusals(t *testing.T) {
178	f := newSuggestFixture(t, nil)
179	id := f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
180	run := func(body string, extra ...string) (int, string) {
181		var out, errOut strings.Builder
182		c := &Ctx{User: f.alice, Scope: "full", Store: f.st, Stdout: &out, Stderr: &errOut,
183			Stdin: strings.NewReader(body)}
184		c.Cfg.Server.Root = f.root
185		c.Cfg.Limits.WriteRate = -1
186		return Dispatch(c, append([]string{"mr", "diff-comment", f.repo.Path(), "1", "--file", "-"}, extra...)), errOut.String()
187	}
188	block := "```suggestion\nx\n```\n"
189	cases := []struct {
190		name string
191		body string
192		args []string
193		want string
194	}{
195		{"old side", block, []string{"--path", "lib.txt", "--line", "1", "--old"}, "drop --old"},
196		{"reply", block, []string{"--reply", id}, "its own thread"},
197		{"past the end", block, []string{"--path", "lib.txt", "--start-line", "5", "--line", "6"}, "no lines 5-6"},
198		{"unclosed", "```suggestion\nx\n", []string{"--path", "lib.txt", "--line", "1"}, "not closed"},
199		{"start after line", "plain", []string{"--path", "lib.txt", "--start-line", "3", "--line", "2"}, "no greater than --line"},
200	}
201	for _, c := range cases {
202		code, errOut := run(c.body, c.args...)
203		if code != protocol.ExitUsage || !strings.Contains(errOut, c.want) {
204			t.Errorf("%s: exit %d %q, want usage with %q", c.name, code, errOut, c.want)
205		}
206	}
207}
208
209// verified gives u a verified primary address, which a commit needs.
210func (f *queueFixture) verified(u store.User) {
211	f.t.Helper()
212	if err := f.st.AddEmail(u.ID, u.Username+"@example.test", "admin", true); err != nil {
213		f.t.Fatal(err)
214	}
215}
216
217// applyOK applies thread as u and returns the new commit, checking what
218// every successful apply must have done: one commit on the old head by
219// u, naming the merge request and thread, the merge request moved to
220// it, and the thread resolved.
221func (f *queueFixture) applyOK(u store.User, thread string) string {
222	f.t.Helper()
223	old := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/feature"))
224	out := f.mustWrite(u, "mr", "apply-suggestion", f.repo.Path(), "1", thread, "--json")
225	var env struct {
226		Data struct {
227			SHA string `json:"sha"`
228		} `json:"data"`
229	}
230	json.Unmarshal([]byte(out), &env)
231	sha := env.Data.SHA
232	if tip := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/feature")); tip != sha || sha == "" {
233		f.t.Fatalf("feature = %s, apply reported %q", tip, sha)
234	}
235	if parent := strings.TrimSpace(f.git(f.dir, "rev-parse", sha+"^")); parent != old {
236		f.t.Fatalf("parent = %s, want the old head %s", parent, old)
237	}
238	meta := f.git(f.dir, "log", "-1", "--format=%an <%ae>|%cn <%ce>|%B", sha)
239	who := u.Username + " <" + u.Username + "@example.test>"
240	if !strings.HasPrefix(meta, who+"|"+who+"|") || !strings.Contains(meta, "Thread "+thread+" on alice/app!1") {
241		f.t.Fatalf("commit = %q", meta)
242	}
243	if mr := f.mr(); mr.HeadSHA != sha {
244		f.t.Fatalf("MR head = %s, want %s", mr.HeadSHA, sha)
245	}
246	if head := strings.TrimSpace(f.git(f.dir, "rev-parse", mrHeadRef(1))); head != sha {
247		f.t.Fatalf("MR head ref = %s, want %s", head, sha)
248	}
249	for _, th := range f.threads(u) {
250		if strconv.Itoa(int(th.ID)) == thread && th.Resolved != u.Username {
251			f.t.Fatalf("thread %s resolved by %q, want %s", thread, th.Resolved, u.Username)
252		}
253	}
254	return sha
255}
256
257func (f *queueFixture) file(sha, path string) string {
258	f.t.Helper()
259	return f.git(f.dir, "show", sha+":"+path)
260}
261
262// Each shape of range: several lines to more, deletion, the last line of
263// a file with no final newline, and a CRLF file.
264func TestApplySuggestion(t *testing.T) {
265	f := newSuggestFixture(t, nil)
266	f.verified(f.alice)
267	cases := []struct {
268		path       string
269		start, end int
270		lines      []string
271		want       string
272	}{
273		{"lib.txt", 2, 3, []string{"TWO", "THREE", "3.5"}, "one\nTWO\nTHREE\n3.5\nfour\nfive\n"},
274		{"lib.txt", 5, 6, nil, "one\nTWO\nTHREE\n3.5\n"},
275		{"tail.txt", 2, 2, []string{"LAST", "more"}, "x\nLAST\nmore"},
276		{"dos.txt", 2, 2, []string{"B", "B2"}, "a\r\nB\r\nB2\r\nc\r\n"},
277	}
278	for _, c := range cases {
279		id := f.suggest(f.alice, c.path, c.start, c.end, c.lines...)
280		sha := f.applyOK(f.alice, id)
281		if got := f.file(sha, c.path); got != c.want {
282			t.Errorf("%s %d-%d: file = %q, want %q", c.path, c.start, c.end, got, c.want)
283		}
284	}
285}
286
287// A suggestion whose lines changed, or whose file is gone, is refused;
288// so is one on a repository requiring signed commits, with the command
289// that applies it locally.
290func TestApplySuggestionRefusals(t *testing.T) {
291	f := newSuggestFixture(t, nil)
292	f.verified(f.alice)
293	stale := f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
294	gone := f.suggest(f.alice, "tail.txt", 1, 1, "X")
295	plain := f.suggest(f.alice, "lib.txt", 2, 2, "two")
296	f.write("lib.txt", "uno\ntwo\nthree\nfour\nfive\n")
297	f.git(f.src, "rm", "-q", "tail.txt")
298	f.git(f.src, "commit", "-q", "-am", "moved on")
299	f.moveHead()
300	cases := []struct {
301		thread string
302		code   int
303		want   string
304	}{
305		{stale, protocol.ExitUsage, "outdated: " + reasonChanged},
306		{gone, protocol.ExitUsage, reasonGone},
307		{plain, protocol.ExitUsage, "changes nothing"},
308		{"999", protocol.ExitNotFound, "no thread 999"},
309	}
310	for _, c := range cases {
311		code, _, errOut := f.runWith(f.alice, unlimited, "mr", "apply-suggestion", f.repo.Path(), "1", c.thread)
312		if code != c.code || !strings.Contains(errOut, c.want) {
313			t.Errorf("thread %s: exit %d %q, want %d with %q", c.thread, code, errOut, c.code, c.want)
314		}
315	}
316
317	if _, err := f.st.UpdateRepoSettings(f.repo.ID, func(s *store.RepoSettings) { s.RequireSignedCommits = true }); err != nil {
318		t.Fatal(err)
319	}
320	ok := f.suggest(f.alice, "lib.txt", 2, 2, "TWO")
321	code, _, errOut := f.runWith(f.alice, unlimited, "mr", "apply-suggestion", f.repo.Path(), "1", ok)
322	if code != protocol.ExitDenied || !strings.Contains(errOut, "gitbay mr apply-suggestion alice/app 1 "+ok) {
323		t.Fatalf("signed repo: exit %d %q", code, errOut)
324	}
325}
326
327// The update is held to the pre-receive ref policy a push is: a source
328// branch that is protected under require-mr refuses it.
329func TestApplySuggestionHonoursRefPolicy(t *testing.T) {
330	f := newSuggestFixture(t, func(s *store.RepoSettings) {
331		s.ProtectedBranches = []string{"feature"}
332		s.RequireMR = true
333	})
334	f.verified(f.alice)
335	id := f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
336	before := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/feature"))
337	code, _, errOut := f.runWith(f.alice, unlimited, "mr", "apply-suggestion", f.repo.Path(), "1", id)
338	if code != protocol.ExitDenied || !strings.Contains(errOut, "merge requests only") {
339		t.Fatalf("exit %d %q, want the require-mr refusal", code, errOut)
340	}
341	if after := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/feature")); after != before {
342		t.Fatal("refused apply moved the branch")
343	}
344}
345
346// Only the source branch's writers apply: a reader cannot. A thread in
347// an unsubmitted review is not applied, and to anyone but its author it
348// does not exist.
349func TestApplySuggestionNeedsWrite(t *testing.T) {
350	f := newSuggestFixture(t, nil)
351	carol := f.user("carol", "read")
352	f.verified(carol)
353	id := f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
354	code, _, errOut := f.runWith(carol, unlimited, "mr", "apply-suggestion", f.repo.Path(), "1", id)
355	if code != protocol.ExitDenied || !strings.Contains(errOut, "only its writers") {
356		t.Fatalf("reader: exit %d %q", code, errOut)
357	}
358
359	var out, stderr strings.Builder
360	c := &Ctx{User: f.alice, Scope: "full", Store: f.st, Stdout: &out, Stderr: &stderr, JSON: true,
361		Stdin: strings.NewReader("```suggestion\nONE\n```\n")}
362	c.Cfg.Server.Root = f.root
363	c.Cfg.Limits.WriteRate = -1
364	if code := Dispatch(c, []string{"mr", "diff-comment", f.repo.Path(), "1", "--path", "lib.txt", "--line", "1", "--pending", "--file", "-"}); code != protocol.ExitOK {
365		t.Fatalf("pending diff-comment: %s", stderr.String())
366	}
367	var env struct {
368		Data struct {
369			Thread int64 `json:"thread"`
370		} `json:"data"`
371	}
372	json.Unmarshal([]byte(out.String()), &env)
373	pending := strconv.Itoa(int(env.Data.Thread))
374	if code, _, errOut := f.runWith(f.alice, unlimited, "mr", "apply-suggestion", f.repo.Path(), "1", pending); code != protocol.ExitUsage || !strings.Contains(errOut, "unsubmitted") {
375		t.Errorf("own pending thread: exit %d %q", code, errOut)
376	}
377	if code, _, _ := f.runWith(carol, unlimited, "mr", "apply-suggestion", f.repo.Path(), "1", pending); code != protocol.ExitNotFound {
378		t.Errorf("someone else's pending thread: exit %d, want not found", code)
379	}
380}
381
382// Applying is a push by the applier, so a queued merge sees it: a
383// writer's apply keeps the queue, and resolving the thread it came from
384// lets the queued merge land on the new head.
385func TestApplySuggestionReachesQueuedMerge(t *testing.T) {
386	f := newSuggestFixture(t, func(s *store.RepoSettings) { s.RequireResolved = true })
387	f.verified(f.alice)
388	id := f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
389	f.mustWrite(f.alice, "mr", "merge", f.repo.Path(), "1", "--when-ready")
390	f.wantQueued("threads resolved")
391	sha := f.applyOK(f.alice, id)
392	f.wantMergedBy("alice")
393	if main := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/main")); main != sha {
394		t.Fatalf("main = %s, want the applied commit %s", main, sha)
395	}
396}
397
398// On a merge request from a fork the source branch is the fork's, so its
399// writers apply and the target's do not. The fork writer's apply is a
400// push by someone who cannot merge into the target, which dequeues a
401// merge queued there.
402func TestApplySuggestionFork(t *testing.T) {
403	f := newSuggestFixture(t, nil)
404	bobID, err := f.st.CreateUser("bob", false)
405	if err != nil {
406		t.Fatal(err)
407	}
408	bob, _ := f.st.UserByID(bobID)
409	f.verified(f.alice)
410	f.verified(bob)
411	forkID, err := f.st.CreateRepo("user", bobID, "app", "public")
412	if err != nil {
413		t.Fatal(err)
414	}
415	fork, _ := f.st.RepoByID(forkID)
416	forkDir := RepoDir(f.root, fork.OwnerName, fork.Name)
417	f.git(f.root, "clone", "-q", "--bare", f.src, forkDir)
418	f.git(f.dir, "update-ref", mrHeadRef(2), f.headSHA)
419	if _, err := f.st.CreateMR(f.repo.ID, bobID, forkID, "feature", "main", "forked", "", f.headSHA, "md", false); err != nil {
420		t.Fatal(err)
421	}
422	var out, errOut strings.Builder
423	c := &Ctx{User: f.alice, Scope: "full", Store: f.st, Stdout: &out, Stderr: &errOut, JSON: true,
424		Stdin: strings.NewReader("```suggestion\nONE\n```\n")}
425	c.Cfg.Server.Root = f.root
426	c.Cfg.Limits.WriteRate = -1
427	if code := Dispatch(c, []string{"mr", "diff-comment", f.repo.Path(), "2", "--path", "lib.txt", "--line", "1", "--file", "-"}); code != protocol.ExitOK {
428		t.Fatalf("diff-comment: %s", errOut.String())
429	}
430	var env struct {
431		Data struct {
432			Thread int64 `json:"thread"`
433		} `json:"data"`
434	}
435	json.Unmarshal([]byte(out.String()), &env)
436	thread := strconv.Itoa(int(env.Data.Thread))
437
438	code, _, stderr := f.runWith(f.alice, unlimited, "mr", "apply-suggestion", f.repo.Path(), "2", thread)
439	if code != protocol.ExitDenied || !strings.Contains(stderr, "bob/app:feature") {
440		t.Fatalf("target owner on a fork's branch: exit %d %q", code, stderr)
441	}
442
443	if _, err := f.st.UpdateRepoSettings(f.repo.ID, func(s *store.RepoSettings) { s.RequireApprovals = 1 }); err != nil {
444		t.Fatal(err)
445	}
446	f.mustWrite(f.alice, "mr", "merge", f.repo.Path(), "2", "--when-ready", "--strategy", "merge")
447	f.mustWrite(bob, "mr", "apply-suggestion", f.repo.Path(), "2", thread)
448	tip := strings.TrimSpace(f.git(forkDir, "rev-parse", "refs/heads/feature"))
449	if got := f.git(forkDir, "show", tip+":lib.txt"); !strings.HasPrefix(got, "ONE\ntwo\n") {
450		t.Fatalf("fork's lib.txt = %q", got)
451	}
452	mr, _ := f.st.MRByNumber(f.repo.ID, 2)
453	if mr.HeadSHA != tip {
454		t.Fatalf("MR head = %s, want the fork's new tip %s", mr.HeadSHA, tip)
455	}
456	if mr.QueuedAt != "" || mr.State != "open" {
457		t.Fatalf("queued merge after the fork writer's apply: state %s queued %q", mr.State, mr.QueuedAt)
458	}
459	cs, _ := f.st.ListMRComments(mr.ID)
460	said := false
461	for _, c := range cs {
462		said = said || (c.Kind == "system" && strings.Contains(c.Body, "bob pushed and cannot merge"))
463	}
464	if !said {
465		t.Fatalf("timeline does not say why the merge was dequeued: %+v", cs)
466	}
467	// carol writes to the fork and is neither the thread's author, the
468	// merge request's, nor a writer of the target: her apply lands and
469	// leaves the thread open, saying so.
470	carolID, _ := f.st.CreateUser("carol", false)
471	carol, _ := f.st.UserByID(carolID)
472	f.verified(carol)
473	if err := f.st.GrantAccess(forkID, carolID, "write"); err != nil {
474		t.Fatal(err)
475	}
476	out.Reset()
477	errOut.Reset()
478	c.Stdin = strings.NewReader("```suggestion\nTWO\n```\n")
479	if code := Dispatch(c, []string{"mr", "diff-comment", f.repo.Path(), "2", "--path", "lib.txt", "--line", "2", "--file", "-"}); code != protocol.ExitOK {
480		t.Fatalf("diff-comment: %s", errOut.String())
481	}
482	json.Unmarshal([]byte(out.String()), &env)
483	second := strconv.Itoa(int(env.Data.Thread))
484	stdout := f.mustWrite(carol, "mr", "apply-suggestion", f.repo.Path(), "2", second, "--json")
485	if !strings.Contains(stdout, `"resolved":false`) || !strings.Contains(stdout, "still open") {
486		t.Fatalf("carol's apply = %s, want it applied and the thread left open", stdout)
487	}
488	if n, _ := f.st.UnresolvedThreadCount(mr.ID); n != 1 {
489		t.Fatalf("unresolved threads = %d, want carol's left open", n)
490	}
491}
492
493// Reading suggestions costs git processes per file and commit, not per
494// thread: six suggestions on one file read it as one does, whether or
495// not the blobs fit the cache.
496func TestSuggestionsProcessCountPerFile(t *testing.T) {
497	f := newSuggestFixture(t, nil)
498	spawnedWith := func(cacheCap int64) int {
499		t.Helper()
500		comments, err := f.st.ListDiffComments(f.mr().ID, f.alice.ID)
501		if err != nil {
502			t.Fatal(err)
503		}
504		files := newAnchoredFiles(f.dir)
505		files.cacheCap = cacheCap
506		defer files.close()
507		got := suggestionsWith(files, func() bool { return false }, f.mr(), comments)
508		for _, s := range got {
509			if s.Outdated {
510				t.Fatalf("suggestion outdated: %+v", s)
511			}
512		}
513		if files.cached > cacheCap {
514			t.Fatalf("cached %d bytes past a cap of %d", files.cached, cacheCap)
515		}
516		return files.spawned
517	}
518	spawned := func() int { return spawnedWith(anchoredCacheCap) }
519	f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
520	one := spawned()
521	for i := 2; i <= 5; i++ {
522		f.suggest(f.alice, "lib.txt", i, i, "X")
523	}
524	f.suggest(f.alice, "lib.txt", 1, 2, "Y")
525	if six := spawned(); six != one || one > 2 {
526		t.Fatalf("git processes: %d for one suggestion, %d for six on the same file", one, six)
527	}
528	// With no room to cache, blobs are read again through the same
529	// process, and every suggestion still reads right.
530	if none := spawnedWith(0); none != one {
531		t.Fatalf("git processes with no cache: %d, want %d", none, one)
532	}
533}
534
535// A server-side write stops at the owner's storage quota as a push does:
536// both apply-suggestion and repo commit-file.
537func TestServerWritesHonourStorageQuota(t *testing.T) {
538	f := newSuggestFixture(t, nil)
539	f.verified(f.alice)
540	id := f.suggest(f.alice, "lib.txt", 1, 1, "ONE")
541	full := func(c *Ctx) {
542		c.Cfg.Limits.WriteRate = -1
543		c.Cfg.Limits.MaxBytesPerUser = 1
544	}
545	before := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/feature"))
546	code, _, errOut := f.runWith(f.alice, full, "mr", "apply-suggestion", f.repo.Path(), "1", id)
547	if code != protocol.ExitDenied || !strings.Contains(errOut, "storage quota is used up") {
548		t.Errorf("apply-suggestion over quota: exit %d %q", code, errOut)
549	}
550	code, _, errOut = f.runWith(f.alice, func(c *Ctx) { full(c); c.Stdin = strings.NewReader("x\n") },
551		"repo", "commit-file", f.repo.Path(), "new.txt", "--ref", "feature", "--file", "-")
552	if code != protocol.ExitDenied || !strings.Contains(errOut, "storage quota is used up") {
553		t.Errorf("commit-file over quota: exit %d %q", code, errOut)
554	}
555	if after := strings.TrimSpace(f.git(f.dir, "rev-parse", "refs/heads/feature")); after != before {
556		t.Fatal("a refused write moved the branch")
557	}
558	// Under the quota both go through.
559	f.mustWrite(f.alice, "mr", "apply-suggestion", f.repo.Path(), "1", id)
560	code, _, errOut = f.runWith(f.alice, func(c *Ctx) { unlimited(c); c.Stdin = strings.NewReader("x\n") },
561		"repo", "commit-file", f.repo.Path(), "new.txt", "--ref", "feature", "--file", "-")
562	if code != protocol.ExitOK {
563		t.Errorf("commit-file under quota: exit %d %q", code, errOut)
564	}
565}