1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain_test
4
5import (
6 "strings"
7 "testing"
8 "time"
9
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12
13 "bnema.dev/ermite/internal/domain"
14)
15
16func oid(t *testing.T, c byte) domain.ObjectID {
17 t.Helper()
18 id, err := domain.ParseObjectID(strings.Repeat(string(c), domain.ObjectIDLen))
19 require.NoError(t, err)
20 return id
21}
22
23func TestDefaultRepoSettings(t *testing.T) {
24 t.Parallel()
25 s := domain.DefaultRepoSettings()
26 assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, s.AllowedMergeModes)
27 assert.True(t, s.FailingChecksBlock)
28 assert.True(t, s.Allows(domain.MergeModeSquash))
29 assert.False(t, domain.RepoSettings{}.Allows(domain.MergeModeSquash))
30 assert.True(t, domain.RepoSettings{}.IsZero())
31 assert.False(t, s.IsZero())
32
33 // The default list is a copy: callers cannot alter the next default.
34 s.AllowedMergeModes[0] = domain.MergeModeSquash
35 assert.Equal(t, domain.MergeModeFastForward, domain.DefaultRepoSettings().AllowedMergeModes[0])
36}
37
38func TestNormalizeMergeModes(t *testing.T) {
39 t.Parallel()
40 got, err := domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash, domain.MergeModeFastForward, domain.MergeModeSquash})
41 require.NoError(t, err)
42 assert.Equal(t, domain.MergeModes(), got)
43
44 got, err = domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash})
45 require.NoError(t, err)
46 assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, got)
47
48 _, err = domain.NormalizeMergeModes(nil)
49 assert.ErrorIs(t, err, domain.ErrInvalid)
50 _, err = domain.NormalizeMergeModes([]domain.MergeMode{"rebase"})
51 assert.ErrorIs(t, err, domain.ErrInvalid)
52}
53
54func TestParseMergeMode(t *testing.T) {
55 t.Parallel()
56 m, err := domain.ParseMergeMode("fast-forward")
57 require.NoError(t, err)
58 assert.Equal(t, domain.MergeModeFastForward, m)
59 _, err = domain.ParseMergeMode("merge")
60 assert.ErrorIs(t, err, domain.ErrInvalid)
61}
62
63func hunkDiff() domain.Diff {
64 return domain.Diff{Files: []domain.FileDiff{
65 {OldPath: "a.txt", NewPath: "a.txt", Status: domain.FileModified, Hunks: []domain.Hunk{{
66 Lines: []domain.Line{
67 {Kind: domain.LineContext, OldNumber: 4, NewNumber: 4},
68 {Kind: domain.LineDeletion, OldNumber: 5},
69 {Kind: domain.LineAddition, NewNumber: 5},
70 {Kind: domain.LineAddition, NewNumber: 6},
71 },
72 }}},
73 {OldPath: "gone.txt", Status: domain.FileDeleted, Hunks: []domain.Hunk{{
74 Lines: []domain.Line{{Kind: domain.LineDeletion, OldNumber: 1}},
75 }}},
76 {NewPath: "big.txt", Status: domain.FileAdded, Truncated: true},
77 {OldPath: "old.txt", NewPath: "new.txt", Status: domain.FileRenamed, Hunks: []domain.Hunk{{
78 Lines: []domain.Line{{Kind: domain.LineAddition, NewNumber: 2}},
79 }}},
80 }}
81}
82
83func TestDiffHasLine(t *testing.T) {
84 t.Parallel()
85 d := hunkDiff()
86 tests := []struct {
87 name string
88 path string
89 side domain.DiffSide
90 line int
91 want bool
92 }{
93 {"context on the new side", "a.txt", domain.SideNew, 4, true},
94 {"context on the old side", "a.txt", domain.SideOld, 4, true},
95 {"deletion on the old side", "a.txt", domain.SideOld, 5, true},
96 {"addition on the new side", "a.txt", domain.SideNew, 6, true},
97 {"addition is not on the old side", "a.txt", domain.SideOld, 6, false},
98 {"new line 5 is the addition, not the deletion", "a.txt", domain.SideNew, 5, true},
99 {"line outside the hunk", "a.txt", domain.SideNew, 99, false},
100 {"line zero", "a.txt", domain.SideNew, 0, false},
101 {"negative line", "a.txt", domain.SideNew, -1, false},
102 {"no side", "a.txt", domain.SideNone, 4, false},
103 {"unknown path", "nope.txt", domain.SideNew, 4, false},
104 {"empty path", "", domain.SideNew, 4, false},
105 {"deleted file by its old path", "gone.txt", domain.SideOld, 1, true},
106 {"deleted file has no new side", "gone.txt", domain.SideNew, 1, false},
107 {"truncated file has no lines", "big.txt", domain.SideNew, 1, false},
108 {"renamed file by its new path", "new.txt", domain.SideNew, 2, true},
109 {"renamed file by its old path", "old.txt", domain.SideNew, 2, false},
110 }
111 for _, tc := range tests {
112 t.Run(tc.name, func(t *testing.T) {
113 t.Parallel()
114 assert.Equal(t, tc.want, d.HasLine(tc.path, tc.side, tc.line))
115 })
116 }
117}
118
119func sig(name, email string, sec int64) domain.Signature {
120 return domain.Signature{Name: name, Email: email, Time: time.Unix(sec, 0).UTC()}
121}
122
123func TestNewSquashCommit(t *testing.T) {
124 t.Parallel()
125 title, err := domain.ParseMRTitle("Add search")
126 require.NoError(t, err)
127 mr := domain.MergeRequest{Number: 7, Title: title}
128 // Newest first, as Git lists them.
129 commits := []domain.Commit{
130 {Summary: "fix typo", Author: sig("Bob", "bob@example.org", 300)},
131 {Summary: "add tests", Author: sig("Ada", "ADA@example.org", 200)},
132 {Summary: "add search", Author: sig("Ada", "ada@example.org", 100)},
133 {Summary: "wip", Author: sig("Cy", "cy@example.org", 150)},
134 {Summary: "polish", Author: sig("Bob", "bob@example.org", 160)},
135 }
136 committer := domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}
137 at := time.Unix(1_800_000_000, 0)
138
139 c := domain.NewSquashCommit(mr, oid(t, 'a'), oid(t, 'b'), commits, committer, at)
140
141 // The author is the author of the oldest commit (the last one listed).
142 assert.Equal(t, sig("Bob", "bob@example.org", 160), c.Author)
143 assert.Equal(t, domain.Signature{Name: "ermite", Email: "ermite@git.example.org", Time: at.UTC()}, c.Committer)
144 assert.Equal(t, "Add search (!7)\n"+
145 "\n"+
146 "- polish\n- wip\n- add search\n- add tests\n- fix typo\n"+
147 "\n"+
148 "Co-authored-by: Cy <cy@example.org>\n"+
149 "Co-authored-by: Ada <ada@example.org>\n", c.Message)
150
151 want := "tree " + strings.Repeat("a", 40) + "\n" +
152 "parent " + strings.Repeat("b", 40) + "\n" +
153 "author Bob <bob@example.org> 160 +0000\n" +
154 "committer ermite <ermite@git.example.org> 1800000000 +0000\n" +
155 "\n" + c.Message
156 assert.Equal(t, want, string(c.Payload()))
157}
158
159func TestSquashCommitSigned(t *testing.T) {
160 t.Parallel()
161 title, err := domain.ParseMRTitle("T")
162 require.NoError(t, err)
163 c := domain.NewSquashCommit(domain.MergeRequest{Number: 1, Title: title}, oid(t, 'a'), oid(t, 'b'),
164 []domain.Commit{{Summary: "s", Author: sig("A", "a@x.org", 1)}}, domain.Identity{Name: "e", Email: "e@x.org"}, time.Unix(2, 0))
165
166 got := string(c.Signed("-----BEGIN SSH SIGNATURE-----\nAAAA\nBBBB\n-----END SSH SIGNATURE-----\n"))
167 assert.Equal(t, "tree "+strings.Repeat("a", 40)+"\n"+
168 "parent "+strings.Repeat("b", 40)+"\n"+
169 "author A <a@x.org> 1 +0000\n"+
170 "committer e <e@x.org> 2 +0000\n"+
171 "gpgsig -----BEGIN SSH SIGNATURE-----\n"+
172 " AAAA\n"+
173 " BBBB\n"+
174 " -----END SSH SIGNATURE-----\n"+
175 "\n"+c.Message, got)
176}
177
178func TestSquashCommitCleansIdentitiesAndSummaries(t *testing.T) {
179 t.Parallel()
180 title, err := domain.ParseMRTitle("T")
181 require.NoError(t, err)
182 commits := []domain.Commit{
183 {Summary: "second", Author: sig("Eve <evil@x.org>\nCo-authored-by: Z", "eve@x.org", 2)},
184 {Summary: "first\x1b[0m", Author: sig("Ada", "ada@x.org", 1)},
185 }
186 c := domain.NewSquashCommit(domain.MergeRequest{Number: 2, Title: title}, oid(t, 'a'), oid(t, 'b'), commits,
187 domain.Identity{Name: "e", Email: "e@x.org"}, time.Unix(3, 0))
188 assert.Contains(t, c.Message, "- first [0m\n")
189 assert.Contains(t, c.Message, "\nCo-authored-by: Eve evil@x.orgCo-authored-by: Z <eve@x.org>\n")
190 assert.NotContains(t, c.Message, "\nCo-authored-by: Z")
191}
192
193func TestSigningKeyInfoAllowedSigners(t *testing.T) {
194 t.Parallel()
195 i := domain.SigningKeyInfo{
196 Key: domain.SigningPublicKey{AuthorizedKey: "ssh-ed25519 AAAA"},
197 Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"},
198 }
199 assert.Equal(t, "ermite@git.example.org ssh-ed25519 AAAA", i.AllowedSigners())
200}
201
202func TestParseBranchName(t *testing.T) {
203 t.Parallel()
204 for _, ok := range []string{"main", "feat/x", "v1.2", "a-b_c"} {
205 got, err := domain.ParseBranchName(ok)
206 require.NoError(t, err, ok)
207 assert.Equal(t, ok, got)
208 }
209 for _, bad := range []string{"", "refs/heads/main", "-x", "a..b", "a b", "x/", "a:b", "x.lock", "a\x00b"} {
210 _, err := domain.ParseBranchName(bad)
211 assert.ErrorIs(t, err, domain.ErrInvalidRevision, "%q", bad)
212 }
213}
214
215// A title that starts with "WIP:" or "[WIP]", in any case, is a draft; ready
216// drops the prefix and the spaces after it.
217func TestMergeRequestDraft(t *testing.T) {
218 for _, tc := range []struct {
219 title, ready string
220 draft bool
221 }{
222 {"WIP: feat: drag and drop", "feat: drag and drop", true},
223 {"wip:fix", "fix", true},
224 {" [WIP] tidy", "tidy", true},
225 {"WIP: [wip] both", "both", true}, // every prefix goes
226 {"feat: wip: later", "", false},
227 {"WIPE the cache", "", false},
228 {"WIP:", "", true}, // a draft, but nothing would be left
229 } {
230 title, err := domain.ParseMRTitle(tc.title)
231 require.NoError(t, err)
232 assert.Equal(t, tc.draft, domain.MergeRequest{Title: title}.IsDraft(), tc.title)
233 ready, ok := domain.ReadyTitle(title)
234 assert.Equal(t, tc.ready != "", ok, tc.title)
235 assert.Equal(t, tc.ready, ready.String(), tc.title)
236 }
237}
238
239func TestNoteVerdicts(t *testing.T) {
240 head, old := oid(t, 'a'), oid(t, 'b')
241 for _, tc := range []struct {
242 text string
243 verdict domain.Verdict
244 }{{"", domain.VerdictNone}, {"approve", domain.VerdictApprove}, {"changes_requested", domain.VerdictChangesRequested}} {
245 v, err := domain.ParseVerdict(tc.text)
246 require.NoError(t, err)
247 assert.Equal(t, tc.verdict, v)
248 assert.Equal(t, tc.text, v.String())
249 }
250 _, err := domain.ParseVerdict("unknown")
251 require.ErrorIs(t, err, domain.ErrInvalid)
252 comments := []domain.Comment{{ID: 3, Author: "a", Verdict: domain.VerdictApprove, CommitID: old, Deleted: true}, {ID: 1, Author: "a", Verdict: domain.VerdictChangesRequested}, {ID: 2, Author: "b", Verdict: domain.VerdictChangesRequested, CommitID: old}, {ID: 4, Author: "a"}}
253 for _, h := range []domain.ObjectID{head, {}} {
254 got := domain.CurrentVerdicts(comments, h)
255 require.Len(t, got, 2)
256 assert.Equal(t, int64(2), got[0].CommentID)
257 assert.False(t, got[0].Stale)
258 assert.Equal(t, int64(3), got[1].CommentID)
259 assert.True(t, got[1].Stale)
260 }
261 comments[0].CommitID = head
262 assert.False(t, domain.CurrentVerdicts(comments, head)[1].Stale)
263 assert.True(t, (domain.Comment{ReplyTo: 1}).IsReply())
264 assert.True(t, (domain.Comment{StartLine: 1}).IsRange())
265}
266
267func TestReviewScopeOwnerRights(t *testing.T) {
268 var old []domain.Scope
269 for _, s := range domain.AllScopes() {
270 if s != domain.ScopeMRReview {
271 old = append(old, s)
272 }
273 }
274 assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: old}).HasOwnerRights())
275 assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()}).HasOwnerRights())
276 s, err := domain.ParseScope("mr:review")
277 require.NoError(t, err)
278 assert.Equal(t, domain.ScopeMRReview, s)
279 for _, tc := range []struct {
280 p domain.Principal
281 want string
282 }{{domain.Root(), "root"}, {domain.Owner(), "owner"}, {domain.Anonymous(), "owner"}, {domain.TokenPrincipal(domain.Token{Label: "bot"}), "bot"}} {
283 assert.Equal(t, tc.want, domain.AuthorName(tc.p))
284 }
285}