internal/control/suggestion_test.go
565 lines · 22244 bytes
23 symbols in this file
newSuggestFixturequeueFixture.moveHeadqueueFixture.suggestthreadJSONunlimitedqueueFixture.mustWritequeueFixture.threadsqueueFixture.suggestionTestSuggestionInThreadsTestSuggestionOutdatedTestSuggestionSignedIsLocalTestSuggestionRefusalsqueueFixture.verifiedqueueFixture.applyOKqueueFixture.fileTestApplySuggestionTestApplySuggestionRefusalsTestApplySuggestionHonoursRefPolicyTestApplySuggestionNeedsWriteTestApplySuggestionReachesQueuedMergeTestApplySuggestionForkTestSuggestionsProcessCountPerFileTestServerWritesHonourStorageQuota
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}