1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package kvstore_test
4
5import (
6 "context"
7 "errors"
8 "strings"
9 "sync"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15 "google.golang.org/protobuf/proto"
16
17 kv "bnema.dev/ermite-kv"
18
19 storagepb "bnema.dev/ermite/gen/ermite/storage"
20 "bnema.dev/ermite/internal/adapters/kvstore"
21 "bnema.dev/ermite/internal/domain"
22)
23
24func newMR(t *testing.T, repo domain.RepoName, title string) domain.MergeRequest {
25 t.Helper()
26 ti, err := domain.ParseMRTitle(title)
27 require.NoError(t, err)
28 desc, err := domain.ParsePlainText("why\nand how")
29 require.NoError(t, err)
30 return domain.MergeRequest{
31 Repo: repo, Title: ti, Description: desc, Source: "feat/x", Target: "main",
32 State: domain.MRStateOpen, Author: "agent", CreatedAt: created, UpdatedAt: created,
33 }
34}
35
36func oid(c byte) domain.ObjectID {
37 id, err := domain.ParseObjectID(strings.Repeat(string(c), 40))
38 if err != nil {
39 panic(err)
40 }
41 return id
42}
43
44// repoWithMRs opens a store holding repository "proj".
45func repoWithMRs(t *testing.T) (*kvstore.Store, *kvstore.MergeRequests, domain.RepoName) {
46 t.Helper()
47 s := openStore(t, t.TempDir())
48 n := name(t, "proj")
49 require.NoError(t, s.Create(context.Background(), domain.Repo{Name: n, Visibility: domain.VisibilityPrivate, CreatedAt: created}))
50 return s, s.MergeRequests(), n
51}
52
53func TestMergeRequestNumbersAreSequentialPerRepository(t *testing.T) {
54 ctx := context.Background()
55 s, mrs, n := repoWithMRs(t)
56 other := name(t, "other")
57 require.NoError(t, s.Create(ctx, domain.Repo{Name: other, CreatedAt: created, Visibility: domain.VisibilityPublic}))
58
59 for want := int64(1); want <= 3; want++ {
60 got, err := mrs.Create(ctx, newMR(t, n, "t"))
61 require.NoError(t, err)
62 assert.Equal(t, want, got.Number)
63 }
64 got, err := mrs.Create(ctx, newMR(t, other, "t"))
65 require.NoError(t, err)
66 assert.Equal(t, int64(1), got.Number, "each repository counts on its own")
67
68 _, err = mrs.Create(ctx, newMR(t, name(t, "ghost"), "t"))
69 assert.ErrorIs(t, err, domain.ErrNotFound, "no merge request for a missing repository")
70}
71
72func TestMergeRequestRoundTrip(t *testing.T) {
73 ctx := context.Background()
74 _, mrs, n := repoWithMRs(t)
75 in := newMR(t, n, "Ajoute « ça »")
76 in.HeadID, in.BaseID, in.MergedCommitID = oid('a'), oid('b'), oid('c')
77 in.MergeMode = domain.MergeModeSquash
78 in.State = domain.MRStateMerged
79 in.CommentSeq = 4
80 created1, err := mrs.Create(ctx, in)
81 require.NoError(t, err)
82
83 got, err := mrs.Get(ctx, n, created1.Number)
84 require.NoError(t, err)
85 assert.NotZero(t, got.Version)
86 in.Number, in.Version = 1, got.Version
87 assert.Equal(t, in, got)
88
89 _, err = mrs.Get(ctx, n, 2)
90 assert.ErrorIs(t, err, domain.ErrNotFound)
91 _, err = mrs.Get(ctx, n, 0)
92 assert.ErrorIs(t, err, domain.ErrNotFound)
93 _, err = mrs.Get(ctx, n, -3)
94 assert.ErrorIs(t, err, domain.ErrNotFound)
95}
96
97func TestMergeRequestListIsNewestFirstAndPerRepository(t *testing.T) {
98 ctx := context.Background()
99 s, mrs, n := repoWithMRs(t)
100 // "proj-2" shares the prefix "proj": its keys must not leak into proj's.
101 sib := name(t, "proj-2")
102 require.NoError(t, s.Create(ctx, domain.Repo{Name: sib, CreatedAt: created, Visibility: domain.VisibilityPublic}))
103 for range 12 { // beyond 10: numbers sort numerically, not as text
104 _, err := mrs.Create(ctx, newMR(t, n, "t"))
105 require.NoError(t, err)
106 }
107 _, err := mrs.Create(ctx, newMR(t, sib, "sibling"))
108 require.NoError(t, err)
109
110 list, err := mrs.List(ctx, n)
111 require.NoError(t, err)
112 require.Len(t, list, 12)
113 for i, mr := range list {
114 assert.Equal(t, int64(12-i), mr.Number)
115 assert.Equal(t, n, mr.Repo)
116 }
117 list, err = mrs.List(ctx, name(t, "none"))
118 require.NoError(t, err)
119 assert.Empty(t, list)
120}
121
122func TestConcurrentCreateNeverSharesANumber(t *testing.T) {
123 ctx := context.Background()
124 _, mrs, n := repoWithMRs(t)
125 const workers = 8
126 var (
127 wg sync.WaitGroup
128 mu sync.Mutex
129 numbers []int64
130 )
131 for range workers {
132 wg.Go(func() {
133 got, err := mrs.Create(ctx, newMR(t, n, "t"))
134 if assert.NoError(t, err) {
135 mu.Lock()
136 numbers = append(numbers, got.Number)
137 mu.Unlock()
138 }
139 })
140 }
141 wg.Wait()
142 assert.ElementsMatch(t, []int64{1, 2, 3, 4, 5, 6, 7, 8}, numbers)
143}
144
145func TestMergeRequestUpdateNeedsTheVersionItWasReadAt(t *testing.T) {
146 ctx := context.Background()
147 _, mrs, n := repoWithMRs(t)
148 first, err := mrs.Create(ctx, newMR(t, n, "t"))
149 require.NoError(t, err)
150
151 a, err := mrs.Get(ctx, n, first.Number)
152 require.NoError(t, err)
153 b := a
154 a.State = domain.MRStateClosed
155 require.NoError(t, mrs.Update(ctx, a))
156
157 b.State = domain.MRStateMerged
158 assert.ErrorIs(t, mrs.Update(ctx, b), domain.ErrConflict, "b was read before a wrote")
159
160 got, err := mrs.Get(ctx, n, first.Number)
161 require.NoError(t, err)
162 assert.Equal(t, domain.MRStateClosed, got.State)
163
164 gone := got
165 gone.Number = 99
166 assert.ErrorIs(t, mrs.Update(ctx, gone), domain.ErrNotFound)
167 fresh := newMR(t, n, "never read")
168 fresh.Number = first.Number
169 assert.ErrorIs(t, mrs.Update(ctx, fresh), domain.ErrConflict, "an unread request has no version")
170}
171
172func newComment(t *testing.T, id int64, text string) domain.Comment {
173 t.Helper()
174 pt, err := domain.ParsePlainText(text)
175 require.NoError(t, err)
176 return domain.Comment{
177 ID: id, Author: "root", Path: "a.txt", Line: 3, Side: domain.SideNew,
178 CommitID: oid('d'), Text: pt, CreatedAt: created.Add(time.Duration(id) * time.Minute),
179 }
180}
181
182func TestComments(t *testing.T) {
183 ctx := context.Background()
184 _, mrs, n := repoWithMRs(t)
185 first, err := mrs.Create(ctx, newMR(t, n, "t"))
186 require.NoError(t, err)
187
188 // Twelve comments: ids sort numerically too.
189 for id := int64(1); id <= 12; id++ {
190 mr, err := mrs.Get(ctx, n, first.Number)
191 require.NoError(t, err)
192 mr.CommentSeq = id
193 c := newComment(t, id, "note")
194 if id == 2 {
195 c.Path, c.Line, c.Side = "", 0, domain.SideNone
196 }
197 require.NoError(t, mrs.SaveComment(ctx, mr, c))
198 }
199 list, err := mrs.ListComments(ctx, n, first.Number)
200 require.NoError(t, err)
201 require.Len(t, list, 12)
202 for i, c := range list {
203 assert.Equal(t, int64(i+1), c.ID, "oldest first")
204 }
205 assert.True(t, list[1].IsGeneral())
206 assert.Equal(t, domain.SideNone, list[1].Side)
207 assert.Equal(t, domain.SideNew, list[0].Side)
208
209 mr, err := mrs.Get(ctx, n, first.Number)
210 require.NoError(t, err)
211 assert.Equal(t, int64(12), mr.CommentSeq)
212
213 c, err := mrs.GetComment(ctx, n, first.Number, 3)
214 require.NoError(t, err)
215 assert.NotZero(t, c.Version)
216 assert.Equal(t, "note", c.Text.String())
217 assert.Equal(t, oid('d'), c.CommitID)
218 for _, id := range []int64{0, 13, -1} {
219 _, err = mrs.GetComment(ctx, n, first.Number, id)
220 assert.ErrorIs(t, err, domain.ErrNotFound)
221 }
222
223 // Another merge request's comments are not listed.
224 second, err := mrs.Create(ctx, newMR(t, n, "t2"))
225 require.NoError(t, err)
226 list, err = mrs.ListComments(ctx, n, second.Number)
227 require.NoError(t, err)
228 assert.Empty(t, list)
229}
230
231func TestSaveCommentPreconditions(t *testing.T) {
232 ctx := context.Background()
233 _, mrs, n := repoWithMRs(t)
234 first, err := mrs.Create(ctx, newMR(t, n, "t"))
235 require.NoError(t, err)
236 mr, err := mrs.Get(ctx, n, first.Number)
237 require.NoError(t, err)
238
239 mr.CommentSeq = 1
240 require.NoError(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "one")))
241
242 // The same id again, from the same stale read: the request moved on.
243 assert.ErrorIs(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "dup")), domain.ErrConflict)
244
245 // A fresh read of the request but a new comment with an existing id.
246 mr, err = mrs.Get(ctx, n, first.Number)
247 require.NoError(t, err)
248 assert.ErrorIs(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "dup")), domain.ErrConflict, "a new comment must not overwrite")
249
250 // Resolving needs the comment version.
251 c, err := mrs.GetComment(ctx, n, first.Number, 1)
252 require.NoError(t, err)
253 stale := c
254 c.Resolved = true
255 require.NoError(t, mrs.SaveComment(ctx, mr, c))
256 mr, err = mrs.Get(ctx, n, first.Number)
257 require.NoError(t, err)
258 stale.Resolved = false
259 assert.ErrorIs(t, mrs.SaveComment(ctx, mr, stale), domain.ErrConflict)
260
261 // An unread request cannot be saved with.
262 assert.ErrorIs(t, mrs.SaveComment(ctx, newMR(t, n, "x"), newComment(t, 2, "n")), domain.ErrConflict)
263}
264
265func TestReadPosition(t *testing.T) {
266 ctx := context.Background()
267 _, mrs, n := repoWithMRs(t)
268 got, err := mrs.GetRead(ctx, n, 1)
269 require.NoError(t, err)
270 assert.True(t, got.IsZero())
271
272 require.NoError(t, mrs.SetRead(ctx, n, 1, oid('a')))
273 require.NoError(t, mrs.SetRead(ctx, n, 1, oid('b')))
274 got, err = mrs.GetRead(ctx, n, 1)
275 require.NoError(t, err)
276 assert.Equal(t, oid('b'), got)
277 got, err = mrs.GetRead(ctx, n, 2)
278 require.NoError(t, err)
279 assert.True(t, got.IsZero())
280 assert.Error(t, mrs.SetRead(ctx, n, 1, domain.ObjectID{}))
281}
282
283// TestDeleteDropsTheMergeRequestsOfTheRepository: deleting a repository drops
284// its merge requests, comments, read positions and number sequence, and only
285// its own; a repository created again under the name starts at !1.
286func TestDeleteDropsTheMergeRequestsOfTheRepository(t *testing.T) {
287 ctx := context.Background()
288 s, mrs, n := repoWithMRs(t)
289 sib := name(t, "proj-2")
290 require.NoError(t, s.Create(ctx, domain.Repo{Name: sib, CreatedAt: created, Visibility: domain.VisibilityPublic}))
291
292 for _, repo := range []domain.RepoName{n, sib} {
293 mr, err := mrs.Create(ctx, newMR(t, repo, "t"))
294 require.NoError(t, err)
295 mr.CommentSeq = 1
296 require.NoError(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "note")))
297 require.NoError(t, mrs.SetRead(ctx, repo, mr.Number, oid('a')))
298 }
299
300 require.NoError(t, s.Delete(ctx, n))
301
302 list, err := mrs.List(ctx, n)
303 require.NoError(t, err)
304 assert.Empty(t, list)
305 comments, err := mrs.ListComments(ctx, n, 1)
306 require.NoError(t, err)
307 assert.Empty(t, comments)
308 read, err := mrs.GetRead(ctx, n, 1)
309 require.NoError(t, err)
310 assert.True(t, read.IsZero())
311
312 list, err = mrs.List(ctx, sib)
313 require.NoError(t, err)
314 require.Len(t, list, 1, "a repository whose name starts like the deleted one keeps its requests")
315 comments, err = mrs.ListComments(ctx, sib, 1)
316 require.NoError(t, err)
317 assert.Len(t, comments, 1)
318
319 require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
320 again, err := mrs.Create(ctx, newMR(t, n, "fresh"))
321 require.NoError(t, err)
322 assert.Equal(t, int64(1), again.Number)
323}
324
325// TestWritesAfterDeleteLeaveNothing: a comment or a read position written for
326// a repository that was deleted meanwhile is refused, so that a repository
327// created again under the name does not inherit it (GO-002).
328func TestWritesAfterDeleteLeaveNothing(t *testing.T) {
329 ctx := context.Background()
330 s, mrs, n := repoWithMRs(t)
331 mr, err := mrs.Create(ctx, newMR(t, n, "t"))
332 require.NoError(t, err)
333
334 require.NoError(t, s.Delete(ctx, n))
335
336 mr.CommentSeq = 1
337 assert.ErrorIs(t, mrs.SaveComment(ctx, mr, newComment(t, 1, "late")), domain.ErrNotFound)
338 assert.ErrorIs(t, mrs.SetRead(ctx, n, mr.Number, oid('a')), domain.ErrNotFound)
339 _, err = mrs.Create(ctx, newMR(t, n, "late"))
340 assert.ErrorIs(t, err, domain.ErrNotFound)
341
342 require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPublic}))
343 assertNoMergeRequestKeys(t, mrs, n)
344}
345
346func assertNoMergeRequestKeys(t *testing.T, mrs *kvstore.MergeRequests, n domain.RepoName) {
347 t.Helper()
348 ctx := context.Background()
349 list, err := mrs.List(ctx, n)
350 require.NoError(t, err)
351 assert.Empty(t, list, "merge requests")
352 comments, err := mrs.ListComments(ctx, n, 1)
353 require.NoError(t, err)
354 assert.Empty(t, comments, "comments")
355 read, err := mrs.GetRead(ctx, n, 1)
356 require.NoError(t, err)
357 assert.True(t, read.IsZero(), "read position")
358}
359
360// TestDeleteRacingWithWrites: writes racing with Delete either land before it
361// and are deleted, or are refused (not found after it, or a conflict when the
362// retries run out under this contention); nothing is left for a repository
363// created again under the name. Delete itself may report a conflict under the
364// same contention and is then called again.
365func TestDeleteRacingWithWrites(t *testing.T) {
366 ctx := context.Background()
367 for round := range 20 {
368 s, mrs, n := repoWithMRs(t)
369 mr, err := mrs.Create(ctx, newMR(t, n, "t"))
370 require.NoError(t, err)
371
372 var wg sync.WaitGroup
373 for w := range 4 {
374 wg.Add(1)
375 go func() {
376 defer wg.Done()
377 for i := range 30 {
378 if w%2 == 0 {
379 err := mrs.SetRead(ctx, n, mr.Number, oid('a'))
380 if err != nil {
381 assert.True(t, errors.Is(err, domain.ErrConflict) || errors.Is(err, domain.ErrNotFound), "round %d: %v", round, err)
382 return
383 }
384 continue
385 }
386 cur, err := mrs.Get(ctx, n, mr.Number)
387 if err != nil {
388 return
389 }
390 cur.CommentSeq = int64(w*1000 + i + 1)
391 err = mrs.SaveComment(ctx, cur, newComment(t, cur.CommentSeq, "note"))
392 if err != nil && !assert.True(t, errors.Is(err, domain.ErrConflict) || errors.Is(err, domain.ErrNotFound), "round %d: %v", round, err) {
393 return
394 }
395 }
396 }()
397 }
398 // Delete gives up with a conflict when the writers keep rewriting the
399 // repository key for deleteTries listings in a row; it is documented as
400 // safe to call again, as a caller does.
401 var delErr error
402 for range 20 {
403 if delErr = s.Delete(ctx, n); !errors.Is(delErr, domain.ErrConflict) {
404 break
405 }
406 }
407 wg.Wait()
408 require.NoError(t, delErr)
409
410 require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPublic}))
411 assertNoMergeRequestKeys(t, mrs, n)
412 again, err := mrs.Create(ctx, newMR(t, n, "fresh"))
413 require.NoError(t, err)
414 assert.Equal(t, int64(1), again.Number)
415 }
416}
417
418func TestDeleteManyMergeRequests(t *testing.T) {
419 ctx := context.Background()
420 s, mrs, n := repoWithMRs(t)
421 for range 1100 {
422 _, err := mrs.Create(ctx, newMR(t, n, "t"))
423 require.NoError(t, err)
424 }
425 require.NoError(t, s.Delete(ctx, n))
426 list, err := mrs.List(ctx, n)
427 require.NoError(t, err)
428 assert.Empty(t, list)
429 _, err = s.Get(ctx, n)
430 assert.ErrorIs(t, err, domain.ErrNotFound)
431}
432
433func TestRepoSettings(t *testing.T) {
434 ctx := context.Background()
435 s := openStore(t, t.TempDir())
436 n := name(t, "proj")
437
438 // Created without settings: they are stored explicitly, with the defaults.
439 require.NoError(t, s.Create(ctx, domain.Repo{Name: n, CreatedAt: created, Visibility: domain.VisibilityPrivate}))
440 got, err := s.Get(ctx, n)
441 require.NoError(t, err)
442 assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)
443
444 got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeModeSquash}, FailingChecksBlock: false, Issues: domain.IssuesOwner}
445 _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = got.Settings; return true, nil })
446 require.NoError(t, err)
447 again, err := s.Get(ctx, n)
448 require.NoError(t, err)
449 assert.Equal(t, got.Settings, again.Settings)
450
451 got.Settings = domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{"rebase"}}
452 _, err = s.Mutate(ctx, n, func(r *domain.Repo) (bool, error) { r.Settings = got.Settings; return true, nil })
453 assert.ErrorIs(t, err, domain.ErrInvalid)
454}
455
456// TestRepoWithoutStoredSettingsReadsTheDefaults: a repository stored before
457// merge requests existed has no settings field.
458func TestRepoWithoutStoredSettingsReadsTheDefaults(t *testing.T) {
459 ctx := context.Background()
460 dir := t.TempDir()
461 s, err := kvstore.Open(ctx, dir)
462 require.NoError(t, err)
463 require.NoError(t, s.Close(ctx))
464
465 // Write a legacy value straight into the database.
466 db, err := kv.Open(ctx, dir, kv.Options{})
467 require.NoError(t, err)
468 legacy, err := proto.Marshal(&storagepb.Repo{Name: "legacy", Visibility: storagepb.Visibility_VISIBILITY_PUBLIC})
469 require.NoError(t, err)
470 var b kv.Batch
471 b.Put([]byte("repo/legacy"), legacy)
472 _, err = db.Apply(ctx, &b)
473 require.NoError(t, err)
474 require.NoError(t, db.Close(ctx))
475
476 s = openStore(t, dir)
477 got, err := s.Get(ctx, name(t, "legacy"))
478 require.NoError(t, err)
479 assert.Equal(t, domain.DefaultRepoSettings(), got.Settings)
480 list, err := s.List(ctx)
481 require.NoError(t, err)
482 require.Len(t, list, 1)
483 assert.Equal(t, domain.DefaultRepoSettings(), list[0].Settings)
484}
485
486func TestSigningSeed(t *testing.T) {
487 ctx := context.Background()
488 dir := t.TempDir()
489 s := openStore(t, dir)
490 calls := 0
491 gen := func() ([]byte, error) {
492 calls++
493 return []byte(strings.Repeat(string(rune('a'+calls)), kvstore.SigningSeedLen)), nil
494 }
495
496 first, err := s.SigningSeed(ctx, gen, created)
497 require.NoError(t, err)
498 require.Len(t, first, kvstore.SigningSeedLen)
499 second, err := s.SigningSeed(ctx, gen, created)
500 require.NoError(t, err)
501 assert.Equal(t, first, second, "generated once")
502 assert.Equal(t, 1, calls)
503
504 // It survives a restart.
505 require.NoError(t, s.Close(ctx))
506 s2 := openStore(t, dir)
507 third, err := s2.SigningSeed(ctx, gen, created)
508 require.NoError(t, err)
509 assert.Equal(t, first, third)
510 assert.Equal(t, 1, calls)
511}
512
513func TestSigningSeedConcurrentFirstStart(t *testing.T) {
514 ctx := context.Background()
515 s := openStore(t, t.TempDir())
516 var (
517 wg sync.WaitGroup
518 mu sync.Mutex
519 seeds [][]byte
520 n byte
521 )
522 for range 6 {
523 wg.Go(func() {
524 seed, err := s.SigningSeed(ctx, func() ([]byte, error) {
525 mu.Lock()
526 defer mu.Unlock()
527 n++
528 return []byte(strings.Repeat(string(rune('A'+n)), kvstore.SigningSeedLen)), nil
529 }, created)
530 if assert.NoError(t, err) {
531 mu.Lock()
532 seeds = append(seeds, seed)
533 mu.Unlock()
534 }
535 })
536 }
537 wg.Wait()
538 for _, seed := range seeds {
539 assert.Equal(t, seeds[0], seed, "every caller gets the winner's seed")
540 }
541}
542
543func TestSigningSeedRejectsBadGenerators(t *testing.T) {
544 ctx := context.Background()
545 s := openStore(t, t.TempDir())
546 _, err := s.SigningSeed(ctx, func() ([]byte, error) { return []byte("short"), nil }, created)
547 require.Error(t, err)
548 assert.NotContains(t, err.Error(), "short")
549 _, err = s.SigningSeed(ctx, func() ([]byte, error) { return nil, assert.AnError }, created)
550 require.ErrorIs(t, err, assert.AnError)
551}
552
553func TestNoteFieldsRoundTrip(t *testing.T) {
554 ctx := context.Background()
555 _, store, name := repoWithMRs(t)
556 mr, err := store.Create(ctx, newMR(t, name, "notes"))
557 require.NoError(t, err)
558 for id := int64(1); id <= 2; id++ {
559 mr, err = store.Get(ctx, name, mr.Number)
560 require.NoError(t, err)
561 c := newComment(t, id, "note")
562 if id == 2 {
563 c.ReplyTo = 1
564 c.StartLine = 1
565 c.Verdict = domain.VerdictApprove
566 c.EditedAt = created
567 c.Deleted = true
568 }
569 mr.CommentSeq = id
570 require.NoError(t, store.SaveComment(ctx, mr, c))
571 got, err := store.GetComment(ctx, name, mr.Number, id)
572 require.NoError(t, err)
573 c.Version = got.Version
574 assert.Equal(t, c, got)
575 }
576}
577
578// TestStoredOldDefaultModesGainMerge: settings written before merge commits
579// (no merge_modes_v2) with exactly {fast-forward, squash} read with merge too;
580// the same set written by this build is an explicit choice and stays.
581func TestStoredOldDefaultModesGainMerge(t *testing.T) {
582 ctx := context.Background()
583 dir := t.TempDir()
584 s, err := kvstore.Open(ctx, dir)
585 require.NoError(t, err)
586 require.NoError(t, s.Close(ctx))
587
588 put := func(db *kv.DB, repo string, modes ...storagepb.MergeMode) {
589 val, err := proto.Marshal(&storagepb.Repo{Name: repo, Visibility: storagepb.Visibility_VISIBILITY_PUBLIC,
590 Settings: &storagepb.RepoSettings{AllowedMergeModes: modes, FailingChecksBlock: true}})
591 require.NoError(t, err)
592 var b kv.Batch
593 b.Put([]byte("repo/"+repo), val)
594 _, err = db.Apply(ctx, &b)
595 require.NoError(t, err)
596 }
597 db, err := kv.Open(ctx, dir, kv.Options{})
598 require.NoError(t, err)
599 put(db, "old", storagepb.MergeMode_MERGE_MODE_FAST_FORWARD, storagepb.MergeMode_MERGE_MODE_SQUASH)
600 put(db, "single", storagepb.MergeMode_MERGE_MODE_SQUASH)
601 require.NoError(t, db.Close(ctx))
602
603 s = openStore(t, dir)
604 old, err := s.Get(ctx, name(t, "old"))
605 require.NoError(t, err)
606 assert.Equal(t, domain.MergeModes(), old.Settings.AllowedMergeModes)
607 single, err := s.Get(ctx, name(t, "single"))
608 require.NoError(t, err)
609 assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, single.Settings.AllowedMergeModes)
610
611 // An explicit {ff, squash} written now is kept.
612 _, err = s.Mutate(ctx, old.Name, func(r *domain.Repo) (bool, error) {
613 r.Settings.AllowedMergeModes = []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}
614 return true, nil
615 })
616 require.NoError(t, err)
617 again, err := s.Get(ctx, name(t, "old"))
618 require.NoError(t, err)
619 assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, again.Settings.AllowedMergeModes)
620}
621
622func mergeability(mr domain.MergeRequest) domain.Mergeability {
623 return domain.Mergeability{
624 Repo: mr.Repo, Number: mr.Number, State: domain.MergeabilityConflict,
625 TargetID: oid('a'), HeadID: oid('b'), Behind: 3, BehindCapped: true,
626 ConflictPaths: []string{"a.txt", "b/c.txt"}, ConflictPathsTruncated: true, CheckedAt: created,
627 }
628}
629
630func TestPutMergeabilityCAS(t *testing.T) {
631 ctx := context.Background()
632 _, mrs, n := repoWithMRs(t)
633 mr, err := mrs.Create(ctx, newMR(t, n, "t"))
634 require.NoError(t, err)
635
636 _, err = mrs.GetMergeability(ctx, n, mr.Number)
637 assert.ErrorIs(t, err, domain.ErrNotFound)
638
639 m := mergeability(mr)
640 require.NoError(t, mrs.PutMergeability(ctx, m))
641 assert.ErrorIs(t, mrs.PutMergeability(ctx, m), domain.ErrConflict, "version 0 needs it absent")
642
643 got, err := mrs.GetMergeability(ctx, n, mr.Number)
644 require.NoError(t, err)
645 assert.NotZero(t, got.Version)
646 m.Version = got.Version
647 assert.Equal(t, m, got)
648
649 next := got
650 next.State = domain.MergeabilityUpToDate
651 require.NoError(t, mrs.PutMergeability(ctx, next))
652 assert.ErrorIs(t, mrs.PutMergeability(ctx, next), domain.ErrConflict, "stale version")
653
654 require.NoError(t, mrs.DeleteMergeability(ctx, n, mr.Number))
655 require.NoError(t, mrs.DeleteMergeability(ctx, n, mr.Number), "absent is fine")
656 _, err = mrs.GetMergeability(ctx, n, mr.Number)
657 assert.ErrorIs(t, err, domain.ErrNotFound)
658
659 missing := mergeability(mr)
660 missing.Number = 99
661 assert.ErrorIs(t, mrs.PutMergeability(ctx, missing), domain.ErrNotFound)
662
663 checking := mergeability(mr)
664 checking.State = domain.MergeabilityChecking
665 assert.ErrorIs(t, mrs.PutMergeability(ctx, checking), domain.ErrInvalid)
666}
667
668func TestMergeabilityRefusedAndDroppedWhenNotOpen(t *testing.T) {
669 for _, state := range []domain.MRState{domain.MRStateMerged, domain.MRStateClosed} {
670 ctx := context.Background()
671 _, mrs, n := repoWithMRs(t)
672 mr, err := mrs.Create(ctx, newMR(t, n, "t"))
673 require.NoError(t, err)
674 require.NoError(t, mrs.PutMergeability(ctx, mergeability(mr)))
675
676 cur, err := mrs.Get(ctx, n, mr.Number)
677 require.NoError(t, err)
678 cur.State = state
679 require.NoError(t, mrs.Update(ctx, cur))
680 _, err = mrs.GetMergeability(ctx, n, mr.Number)
681 assert.ErrorIs(t, err, domain.ErrNotFound, state)
682
683 assert.ErrorIs(t, mrs.PutMergeability(ctx, mergeability(mr)), domain.ErrNotFound, "a late worker never resurrects it: %s", state)
684 }
685}
686
687func TestDeleteWipesMergeability(t *testing.T) {
688 ctx := context.Background()
689 s, mrs, n := repoWithMRs(t)
690 mr, err := mrs.Create(ctx, newMR(t, n, "t"))
691 require.NoError(t, err)
692 require.NoError(t, mrs.PutMergeability(ctx, mergeability(mr)))
693 require.NoError(t, s.Delete(ctx, n))
694 _, err = mrs.GetMergeability(ctx, n, mr.Number)
695 assert.ErrorIs(t, err, domain.ErrNotFound)
696}