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 s := domain.DefaultRepoSettings()
25 assert.Equal(t, []domain.MergeMode{domain.MergeModeFastForward, domain.MergeModeSquash}, s.AllowedMergeModes)
26 assert.True(t, s.FailingChecksBlock)
27 assert.True(t, s.Allows(domain.MergeModeSquash))
28 assert.False(t, domain.RepoSettings{}.Allows(domain.MergeModeSquash))
29 assert.True(t, domain.RepoSettings{}.IsZero())
30 assert.False(t, s.IsZero())
31
32 // The default list is a copy: callers cannot alter the next default.
33 s.AllowedMergeModes[0] = domain.MergeModeSquash
34 assert.Equal(t, domain.MergeModeFastForward, domain.DefaultRepoSettings().AllowedMergeModes[0])
35}
36
37func TestNormalizeMergeModes(t *testing.T) {
38 got, err := domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash, domain.MergeModeFastForward, domain.MergeModeSquash})
39 require.NoError(t, err)
40 assert.Equal(t, domain.MergeModes(), got)
41
42 got, err = domain.NormalizeMergeModes([]domain.MergeMode{domain.MergeModeSquash})
43 require.NoError(t, err)
44 assert.Equal(t, []domain.MergeMode{domain.MergeModeSquash}, got)
45
46 _, err = domain.NormalizeMergeModes(nil)
47 assert.ErrorIs(t, err, domain.ErrInvalid)
48 _, err = domain.NormalizeMergeModes([]domain.MergeMode{"rebase"})
49 assert.ErrorIs(t, err, domain.ErrInvalid)
50}
51
52func TestParseMergeMode(t *testing.T) {
53 m, err := domain.ParseMergeMode("fast-forward")
54 require.NoError(t, err)
55 assert.Equal(t, domain.MergeModeFastForward, m)
56 _, err = domain.ParseMergeMode("merge")
57 assert.ErrorIs(t, err, domain.ErrInvalid)
58}
59
60func hunkDiff() domain.Diff {
61 return domain.Diff{Files: []domain.FileDiff{
62 {OldPath: "a.txt", NewPath: "a.txt", Status: domain.FileModified, Hunks: []domain.Hunk{{
63 Lines: []domain.Line{
64 {Kind: domain.LineContext, OldNumber: 4, NewNumber: 4},
65 {Kind: domain.LineDeletion, OldNumber: 5},
66 {Kind: domain.LineAddition, NewNumber: 5},
67 {Kind: domain.LineAddition, NewNumber: 6},
68 },
69 }}},
70 {OldPath: "gone.txt", Status: domain.FileDeleted, Hunks: []domain.Hunk{{
71 Lines: []domain.Line{{Kind: domain.LineDeletion, OldNumber: 1}},
72 }}},
73 {NewPath: "big.txt", Status: domain.FileAdded, Truncated: true},
74 {OldPath: "old.txt", NewPath: "new.txt", Status: domain.FileRenamed, Hunks: []domain.Hunk{{
75 Lines: []domain.Line{{Kind: domain.LineAddition, NewNumber: 2}},
76 }}},
77 }}
78}
79
80func TestDiffHasLine(t *testing.T) {
81 d := hunkDiff()
82 tests := []struct {
83 name string
84 path string
85 side domain.DiffSide
86 line int
87 want bool
88 }{
89 {"context on the new side", "a.txt", domain.SideNew, 4, true},
90 {"context on the old side", "a.txt", domain.SideOld, 4, true},
91 {"deletion on the old side", "a.txt", domain.SideOld, 5, true},
92 {"addition on the new side", "a.txt", domain.SideNew, 6, true},
93 {"addition is not on the old side", "a.txt", domain.SideOld, 6, false},
94 {"new line 5 is the addition, not the deletion", "a.txt", domain.SideNew, 5, true},
95 {"line outside the hunk", "a.txt", domain.SideNew, 99, false},
96 {"line zero", "a.txt", domain.SideNew, 0, false},
97 {"negative line", "a.txt", domain.SideNew, -1, false},
98 {"no side", "a.txt", domain.SideNone, 4, false},
99 {"unknown path", "nope.txt", domain.SideNew, 4, false},
100 {"empty path", "", domain.SideNew, 4, false},
101 {"deleted file by its old path", "gone.txt", domain.SideOld, 1, true},
102 {"deleted file has no new side", "gone.txt", domain.SideNew, 1, false},
103 {"truncated file has no lines", "big.txt", domain.SideNew, 1, false},
104 {"renamed file by its new path", "new.txt", domain.SideNew, 2, true},
105 {"renamed file by its old path", "old.txt", domain.SideNew, 2, false},
106 }
107 for _, tc := range tests {
108 t.Run(tc.name, func(t *testing.T) {
109 assert.Equal(t, tc.want, d.HasLine(tc.path, tc.side, tc.line))
110 })
111 }
112}
113
114func sig(name, email string, sec int64) domain.Signature {
115 return domain.Signature{Name: name, Email: email, Time: time.Unix(sec, 0).UTC()}
116}
117
118func TestNewSquashCommit(t *testing.T) {
119 title, err := domain.ParseMRTitle("Add search")
120 require.NoError(t, err)
121 mr := domain.MergeRequest{Number: 7, Title: title}
122 // Newest first, as Git lists them.
123 commits := []domain.Commit{
124 {Summary: "fix typo", Author: sig("Bob", "bob@example.org", 300)},
125 {Summary: "add tests", Author: sig("Ada", "ADA@example.org", 200)},
126 {Summary: "add search", Author: sig("Ada", "ada@example.org", 100)},
127 {Summary: "wip", Author: sig("Cy", "cy@example.org", 150)},
128 {Summary: "polish", Author: sig("Bob", "bob@example.org", 160)},
129 }
130 committer := domain.Identity{Name: "ermite", Email: "ermite@git.example.org"}
131 at := time.Unix(1_800_000_000, 0)
132
133 c := domain.NewSquashCommit(mr, oid(t, 'a'), oid(t, 'b'), commits, committer, at)
134
135 // The author is the author of the oldest commit (the last one listed).
136 assert.Equal(t, sig("Bob", "bob@example.org", 160), c.Author)
137 assert.Equal(t, domain.Signature{Name: "ermite", Email: "ermite@git.example.org", Time: at.UTC()}, c.Committer)
138 assert.Equal(t, "Add search (!7)\n"+
139 "\n"+
140 "- polish\n- wip\n- add search\n- add tests\n- fix typo\n"+
141 "\n"+
142 "Co-authored-by: Cy <cy@example.org>\n"+
143 "Co-authored-by: Ada <ada@example.org>\n", c.Message)
144
145 want := "tree " + strings.Repeat("a", 40) + "\n" +
146 "parent " + strings.Repeat("b", 40) + "\n" +
147 "author Bob <bob@example.org> 160 +0000\n" +
148 "committer ermite <ermite@git.example.org> 1800000000 +0000\n" +
149 "\n" + c.Message
150 assert.Equal(t, want, string(c.Payload()))
151}
152
153func TestSquashCommitSigned(t *testing.T) {
154 title, err := domain.ParseMRTitle("T")
155 require.NoError(t, err)
156 c := domain.NewSquashCommit(domain.MergeRequest{Number: 1, Title: title}, oid(t, 'a'), oid(t, 'b'),
157 []domain.Commit{{Summary: "s", Author: sig("A", "a@x.org", 1)}}, domain.Identity{Name: "e", Email: "e@x.org"}, time.Unix(2, 0))
158
159 got := string(c.Signed("-----BEGIN SSH SIGNATURE-----\nAAAA\nBBBB\n-----END SSH SIGNATURE-----\n"))
160 assert.Equal(t, "tree "+strings.Repeat("a", 40)+"\n"+
161 "parent "+strings.Repeat("b", 40)+"\n"+
162 "author A <a@x.org> 1 +0000\n"+
163 "committer e <e@x.org> 2 +0000\n"+
164 "gpgsig -----BEGIN SSH SIGNATURE-----\n"+
165 " AAAA\n"+
166 " BBBB\n"+
167 " -----END SSH SIGNATURE-----\n"+
168 "\n"+c.Message, got)
169}
170
171func TestSquashCommitCleansIdentitiesAndSummaries(t *testing.T) {
172 title, err := domain.ParseMRTitle("T")
173 require.NoError(t, err)
174 commits := []domain.Commit{
175 {Summary: "second", Author: sig("Eve <evil@x.org>\nCo-authored-by: Z", "eve@x.org", 2)},
176 {Summary: "first\x1b[0m", Author: sig("Ada", "ada@x.org", 1)},
177 }
178 c := domain.NewSquashCommit(domain.MergeRequest{Number: 2, Title: title}, oid(t, 'a'), oid(t, 'b'), commits,
179 domain.Identity{Name: "e", Email: "e@x.org"}, time.Unix(3, 0))
180 assert.Contains(t, c.Message, "- first [0m\n")
181 assert.Contains(t, c.Message, "\nCo-authored-by: Eve evil@x.orgCo-authored-by: Z <eve@x.org>\n")
182 assert.NotContains(t, c.Message, "\nCo-authored-by: Z")
183}
184
185func TestSigningKeyInfoAllowedSigners(t *testing.T) {
186 i := domain.SigningKeyInfo{
187 Key: domain.SigningPublicKey{AuthorizedKey: "ssh-ed25519 AAAA"},
188 Committer: domain.Identity{Name: "ermite", Email: "ermite@git.example.org"},
189 }
190 assert.Equal(t, "ermite@git.example.org ssh-ed25519 AAAA", i.AllowedSigners())
191}
192
193func TestParseBranchName(t *testing.T) {
194 for _, ok := range []string{"main", "feat/x", "v1.2", "a-b_c"} {
195 got, err := domain.ParseBranchName(ok)
196 require.NoError(t, err, ok)
197 assert.Equal(t, ok, got)
198 }
199 for _, bad := range []string{"", "refs/heads/main", "-x", "a..b", "a b", "x/", "a:b", "x.lock", "a\x00b"} {
200 _, err := domain.ParseBranchName(bad)
201 assert.ErrorIs(t, err, domain.ErrInvalidRevision, "%q", bad)
202 }
203}