// SPDX-License-Identifier: AGPL-3.0-or-later package usecase_test import ( "context" "errors" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "bnema.dev/ermite/internal/domain" "bnema.dev/ermite/internal/ports" "bnema.dev/ermite/internal/ports/mocks" "bnema.dev/ermite/internal/usecase" ) type mrUnit struct { meta *mocks.RepoMetaStore store *mocks.MergeRequestStore repos *mocks.GitRepos git *mocks.MergeGit mrs *usecase.MergeRequests repo domain.Repo } func newMRUnit(t *testing.T) *mrUnit { t.Helper() u := &mrUnit{ meta: mocks.NewRepoMetaStore(t), store: mocks.NewMergeRequestStore(t), repos: mocks.NewGitRepos(t), git: mocks.NewMergeGit(t), repo: domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPrivate, Settings: domain.DefaultRepoSettings()}, } clock := mocks.NewClock(t) clock.EXPECT().Now().Return(now).Maybe() sched := mocks.NewMaintenanceScheduler(t) sched.EXPECT().Schedule(mock.Anything, mock.Anything).Return().Maybe() u.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{ Meta: u.meta, Store: u.store, Repos: u.repos, Git: u.git, Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: sched, }) return u } // GO-004: a failure to read Git is an error for the log, not a field violation // that would show its text to the caller. func TestCreateReturnsGitFailuresAsErrors(t *testing.T) { t.Parallel() boom := errors.New("fatal: /srv/ermite/repos/proj.git: permission denied") params := ports.CreateMergeRequestParams{Title: "t", Source: "feat", Target: "main"} t.Run("reading a branch", func(t *testing.T) { t.Parallel() u := newMRUnit(t) u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil) u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "feat").Return(domain.ObjectID{}, boom) _, err := u.mrs.Create(context.Background(), domain.Root(), u.repo.Name, params) require.ErrorIs(t, err, boom) var fe *domain.InvalidFieldsError assert.False(t, errors.As(err, &fe), "not a field violation: %v", err) assert.NotErrorIs(t, err, domain.ErrInvalid) }) t.Run("reading the default branch", func(t *testing.T) { t.Parallel() u := newMRUnit(t) u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil) u.repos.EXPECT().DefaultBranch(mock.Anything, u.repo.Name).Return("", false, boom) _, err := u.mrs.Create(context.Background(), domain.Root(), u.repo.Name, ports.CreateMergeRequestParams{Title: "t", Source: "feat"}) require.ErrorIs(t, err, boom) var fe *domain.InvalidFieldsError assert.False(t, errors.As(err, &fe), "not a field violation: %v", err) }) t.Run("a branch that does not exist stays a violation", func(t *testing.T) { t.Parallel() u := newMRUnit(t) u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil) u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "feat").Return(domain.ObjectID{}, domain.ErrNotFound) u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "main").Return(someID(t), nil) _, err := u.mrs.Create(context.Background(), domain.Root(), u.repo.Name, params) assert.Contains(t, fieldsOf(t, err), "source_branch") }) } func someID(t *testing.T) domain.ObjectID { t.Helper() return oidFrom(t, oidA) } // GO-003: the settings are read again under the merge lock; a failure of that // read is returned, and a repository deleted meanwhile is not found. func TestMergeReturnsTheFailureToReadTheSettings(t *testing.T) { t.Parallel() boom := errors.New("kv: closed") for label, tc := range map[string]struct{ err, want error }{ "read failure": {boom, boom}, "deleted meanwhile": {domain.ErrNotFound, domain.ErrNotFound}, } { t.Run(label, func(t *testing.T) { t.Parallel() u := newMRUnit(t) u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Once() u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(domain.Repo{}, tc.err).Once() u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{ Repo: u.repo.Name, Number: 1, State: domain.MRStateOpen, Source: "feat", Target: "main", }, nil) _, err := u.mrs.Merge(context.Background(), domain.Root(), u.repo.Name, 1, ports.MergeParams{HeadID: someID(t)}) require.ErrorIs(t, err, tc.want) if tc.want == boom { assert.NotErrorIs(t, err, domain.ErrNotFound) } }) } } func TestNoteMutationRetries(t *testing.T) { for _, action := range []string{"edit", "delete", "resolve"} { t.Run(action, func(t *testing.T) { u := newMRUnit(t) u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil) mr := domain.MergeRequest{Repo: u.repo.Name, Number: 1, State: domain.MRStateOpen} text, err := domain.ParsePlainText("before") require.NoError(t, err) c := domain.Comment{ID: 1, Author: "root", Text: text} freshMR := mr freshMR.Version = 2 fresh := c fresh.Version = 3 fresh.CreatedAt = now.Add(-time.Hour) if action != "resolve" { fresh.Resolved = true } want := fresh switch action { case "edit": want.Text, err = domain.ParsePlainText("after") require.NoError(t, err) want.EditedAt = now.UTC() case "delete": want.Text, err = domain.ParsePlainText("") require.NoError(t, err) want.Deleted = true want.EditedAt = now.UTC() case "resolve": want.Resolved = true } u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(mr, nil).Once() u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(freshMR, nil).Once() u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(c, nil).Once() u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(fresh, nil).Once() u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once() u.store.EXPECT().SaveComment(mock.Anything, mock.MatchedBy(func(saved domain.MergeRequest) bool { return saved.Version == freshMR.Version }), mock.MatchedBy(func(saved domain.Comment) bool { return saved == want })).Return(nil).Once() bus := mocks.NewEventBus(t) bus.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(e domain.Event) bool { switch event := e.(type) { case domain.CommentEdited: return action == "edit" && event.Comment == want case domain.CommentDeleted: return action == "delete" && event.Comment == want case domain.CommentResolved: return action == "resolve" && event.Comment == want } return false })).Return().Once() clock := mocks.NewClock(t) clock.EXPECT().Now().Return(now) svc := usecase.NewMergeRequests(usecase.MergeRequestsConfig{Meta: u.meta, Store: u.store, Bus: bus, Clock: clock}) var got domain.Comment switch action { case "edit": got, err = svc.EditComment(context.Background(), domain.Root(), u.repo.Name, 1, 1, "after") case "delete": got, err = svc.DeleteComment(context.Background(), domain.Root(), u.repo.Name, 1, 1) case "resolve": got, err = svc.ResolveComment(context.Background(), domain.Root(), u.repo.Name, 1, 1, true) } require.NoError(t, err) assert.Equal(t, want, got) }) } } // A reply whose root is deleted while the save is retried is rejected. func TestAddNoteRechecksReplyRootOnConflict(t *testing.T) { u := newMRUnit(t) u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil) u.git.EXPECT().BranchHead(mock.Anything, mock.Anything, mock.Anything).Return(domain.ObjectID{}, domain.ErrNotFound) u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{State: domain.MRStateOpen}, nil).Twice() u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(domain.Comment{ID: 1}, nil).Once() u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(domain.Comment{ID: 1, Deleted: true}, nil).Once() u.store.EXPECT().SaveComment(mock.Anything, mock.Anything, mock.Anything).Return(domain.ErrConflict).Once() _, err := u.mrs.AddComment(context.Background(), domain.Root(), u.repo.Name, 1, ports.AddCommentParams{ReplyTo: 1, Text: "x"}) require.ErrorIs(t, err, domain.ErrInvalid) } func TestAddNoteValidation(t *testing.T) { for _, tc := range []struct { name string params ports.AddCommentParams target domain.Comment missing bool }{ {name: "range without path", params: ports.AddCommentParams{StartLine: 1, Text: "x"}}, {name: "equal ends", params: ports.AddCommentParams{Path: "a", StartLine: 3, Line: 3, Side: domain.SideNew, Text: "x"}}, {name: "reversed ends", params: ports.AddCommentParams{Path: "a", StartLine: 4, Line: 3, Side: domain.SideNew, Text: "x"}}, {name: "reply anchor", params: ports.AddCommentParams{ReplyTo: 1, Path: "a", Line: 1, Side: domain.SideNew, Text: "x"}}, {name: "reply verdict", params: ports.AddCommentParams{ReplyTo: 1, Verdict: domain.VerdictApprove}}, {name: "reply to reply", params: ports.AddCommentParams{ReplyTo: 1, Text: "x"}, target: domain.Comment{ReplyTo: 2}}, {name: "deleted root", params: ports.AddCommentParams{ReplyTo: 1, Text: "x"}, target: domain.Comment{Deleted: true}}, {name: "missing root", params: ports.AddCommentParams{ReplyTo: 1, Text: "x"}, missing: true}, } { t.Run(tc.name, func(t *testing.T) { u := newMRUnit(t) u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil) u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{State: domain.MRStateOpen}, nil) if tc.name == "reply to reply" || tc.name == "deleted root" || tc.missing { var err error if tc.missing { err = domain.ErrNotFound } u.store.EXPECT().GetComment(mock.Anything, u.repo.Name, int64(1), int64(1)).Return(tc.target, err) } _, err := u.mrs.AddComment(context.Background(), domain.Root(), u.repo.Name, 1, tc.params) require.ErrorIs(t, err, domain.ErrInvalid) }) } } func TestCompare(t *testing.T) { for _, tc := range []struct { name string ancestor, more, zero bool ancestorErr, rangeErr error }{ {name: "linear", ancestor: true}, {name: "bounded", ancestor: true, more: true}, {name: "unrelated histories"}, {name: "zero target forwards ancestry error", zero: true, ancestorErr: domain.ErrInvalid}, {name: "ancestor error", ancestorErr: domain.ErrInvalid}, {name: "range error", ancestor: true, rangeErr: domain.ErrInvalid}, } { t.Run(tc.name, func(t *testing.T) { u := newMRUnit(t) old, tip := someID(t), oidFrom(t, "2222222222222222222222222222222222222222") if tc.zero { old = domain.ObjectID{} } u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil) u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, old, tip).Return(tc.ancestor, tc.ancestorErr) commits := []domain.Commit{{ID: tip}} if tc.ancestorErr == nil { u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, old, tip, ports.MaxCompareCommits).Return(commits, tc.more, tc.rangeErr) } got, err := u.mrs.Compare(context.Background(), domain.Root(), u.repo.Name, old, tip) if tc.ancestorErr != nil { require.ErrorIs(t, err, tc.ancestorErr) return } if tc.rangeErr != nil { require.ErrorIs(t, err, tc.rangeErr) return } require.NoError(t, err) assert.Equal(t, ports.CompareResult{Ahead: commits, Behind: !tc.ancestor, Exact: !tc.more}, got) }) } } func TestPushSummary(t *testing.T) { for _, tc := range []struct { name string ancestor, more bool ancestorErr, rangeErr error }{ {name: "linear", ancestor: true}, {name: "bounded", ancestor: true, more: true}, {name: "unrelated histories"}, {name: "ancestor error", ancestorErr: domain.ErrInvalid}, {name: "range error", ancestor: true, rangeErr: domain.ErrInvalid}, } { t.Run(tc.name, func(t *testing.T) { u := newMRUnit(t) old, tip := someID(t), oidFrom(t, "2222222222222222222222222222222222222222") u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil) u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, old, tip).Return(tc.ancestor, tc.ancestorErr) if tc.ancestor && tc.ancestorErr == nil { u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, old, tip, ports.MaxPushSummaryCommits).Return([]domain.Commit{{ID: tip}}, tc.more, tc.rangeErr) } got, err := u.mrs.PushSummary(context.Background(), domain.Root(), u.repo.Name, old, tip) if tc.ancestorErr != nil { require.ErrorIs(t, err, tc.ancestorErr) return } if tc.rangeErr != nil { require.ErrorIs(t, err, tc.rangeErr) return } require.NoError(t, err) want := ports.PushSummary{Rewritten: !tc.ancestor} if tc.ancestor { want.Count, want.More = 1, tc.more } assert.Equal(t, want, got) }) } } func TestReadTrackingSharedPosition(t *testing.T) { for _, tc := range []struct { name string principal domain.Principal readIndex int }{ {"owner unread", domain.Owner(), 1}, {"owner already read", domain.Owner(), 0}, {"owner rewritten", domain.Owner(), -1}, {"anonymous", domain.Anonymous(), 1}, {"root token", domain.Root(), 1}, } { t.Run(tc.name, func(t *testing.T) { u := newMRUnit(t) u.repo.Visibility = domain.VisibilityPublic head := someID(t) old := oidFrom(t, "2222222222222222222222222222222222222222") read := old if tc.readIndex == 0 { read = head } mr := domain.MergeRequest{Repo: u.repo.Name, Number: 1, State: domain.MRStateClosed, HeadID: head} u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil) u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(mr, nil) u.store.EXPECT().ListComments(mock.Anything, u.repo.Name, int64(1)).Return(nil, nil) u.store.EXPECT().GetRead(mock.Anything, u.repo.Name, int64(1)).Return(read, nil) u.git.EXPECT().HasCommit(mock.Anything, u.repo.Name, head).Return(true, nil) commits := []domain.Commit{{ID: head}} if tc.readIndex >= 0 { commits = append(commits, domain.Commit{ID: old}) } u.git.EXPECT().ListRange(mock.Anything, u.repo.Name, domain.ObjectID{}, head, usecase.MaxMergeRequestCommits).Return(commits, false, nil) detail, err := u.mrs.Get(context.Background(), tc.principal, u.repo.Name, 1, ports.GetMergeRequestOptions{}) require.NoError(t, err) expected := 0 assert.Equal(t, read, detail.LastReadID) assert.Equal(t, read != head, detail.HeadUnread) if tc.readIndex > 0 { expected = tc.readIndex } assert.Equal(t, expected, detail.NewCommits) }) } } func TestHistoryAuthorizationAndEqualTips(t *testing.T) { for _, allowed := range []bool{false, true} { t.Run(map[bool]string{false: "private", true: "equal"}[allowed], func(t *testing.T) { u := newMRUnit(t) if allowed { u.repo.Visibility = domain.VisibilityPublic } u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil).Twice() id := someID(t) comparison, err := u.mrs.Compare(context.Background(), domain.Anonymous(), u.repo.Name, id, id) if allowed { require.NoError(t, err) assert.True(t, comparison.Exact) } else { require.ErrorIs(t, err, domain.ErrNotFound) } summary, err := u.mrs.PushSummary(context.Background(), domain.Anonymous(), u.repo.Name, id, id) if allowed { require.NoError(t, err) assert.Zero(t, summary) } else { require.ErrorIs(t, err, domain.ErrNotFound) } }) } }