1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "fmt"
7 "os"
8 "path/filepath"
9 "testing"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13
14 "bnema.dev/ermite/internal/domain"
15 "bnema.dev/ermite/internal/ports"
16)
17
18// manyFiles commits n one-line files m/f000... in one commit and
19// returns the commit id.
20func (r *mrRig) manyFiles(n int) domain.ObjectID {
21 r.t.Helper()
22 for i := range n {
23 full := filepath.Join(r.work, "m", fmt.Sprintf("f%03d", i))
24 require.NoError(r.t, os.MkdirAll(filepath.Dir(full), 0o755))
25 require.NoError(r.t, os.WriteFile(full, []byte(fmt.Sprintf("line %d\n", i)), 0o644))
26 }
27 r.sh("add", "m")
28 r.sh("commit", "-q", "-m", "many files")
29 return r.head()
30}
31
32const manyFilesCount = 320
33
34// bigMR opens a request that changes manyFilesCount files.
35func bigMR(t *testing.T) (*mrRig, domain.MergeRequest) {
36 t.Helper()
37 r := newMRRig(t)
38 r.seed()
39 r.sh("checkout", "-q", "-B", "feat", "main")
40 r.manyFiles(manyFilesCount)
41 r.push("feat")
42 return r, r.open("feat")
43}
44
45func TestGetMergeRequestFilesWindow(t *testing.T) {
46 r, mr := bigMR(t)
47 n := mr.Number
48
49 t.Run("the default diff keeps the contract limit", func(t *testing.T) {
50 d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{})
51 require.NoError(t, err)
52 assert.Len(t, d.Diff.Files, 300)
53 assert.True(t, d.Diff.Truncated)
54 assert.Equal(t, manyFilesCount, d.Stats.FilesChanged)
55 })
56
57 t.Run("every file is listed, the hunks are those of the window", func(t *testing.T) {
58 d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 305, To: 310}})
59 require.NoError(t, err)
60 require.Len(t, d.Diff.Files, manyFilesCount)
61 assert.Equal(t, manyFilesCount, d.Stats.FilesChanged)
62 assert.False(t, d.Diff.Truncated)
63 for i, f := range d.Diff.Files {
64 assert.Equal(t, fmt.Sprintf("m/f%03d", i), f.NewPath)
65 assert.Equal(t, 1, f.Additions)
66 assert.Equal(t, domain.OmitNone, f.Omitted)
67 if i >= 305 && i < 310 {
68 require.Len(t, f.Hunks, 1, f.NewPath)
69 assert.Equal(t, fmt.Sprintf("line %d", i), f.Hunks[0].Lines[0].Text)
70 } else {
71 assert.Empty(t, f.Hunks, f.NewPath)
72 }
73 }
74 })
75
76 t.Run("an empty window past the end lists the files only", func(t *testing.T) {
77 d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 1000}})
78 require.NoError(t, err)
79 require.Len(t, d.Diff.Files, manyFilesCount)
80 for _, f := range d.Diff.Files {
81 assert.Empty(t, f.Hunks)
82 }
83 })
84
85 t.Run("an open window reads through the end", func(t *testing.T) {
86 d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &ports.FileWindow{From: 315}})
87 require.NoError(t, err)
88 require.Len(t, d.Diff.Files, manyFilesCount)
89 assert.Empty(t, d.Diff.Files[314].Hunks)
90 for _, f := range d.Diff.Files[315:] {
91 assert.Len(t, f.Hunks, 1)
92 }
93 })
94
95 t.Run("an invalid window is refused", func(t *testing.T) {
96 for _, w := range []ports.FileWindow{{From: -1}, {To: -1}, {From: 5, To: 3}} {
97 _, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Files: &w})
98 require.ErrorIs(t, err, domain.ErrInvalid, "%+v", w)
99 }
100 })
101
102 t.Run("a comment on a line of a file past the 300th is accepted", func(t *testing.T) {
103 c, err := r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: "m/f315", Line: 1, Side: domain.SideNew, Text: "late file"})
104 require.NoError(t, err)
105 assert.Equal(t, "m/f315", c.Path)
106 _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: "m/f319", Line: 1, Side: domain.SideNew, Text: "last file"})
107 require.NoError(t, err)
108
109 _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: "m/f315", Line: 2, Side: domain.SideNew, Text: "no such line"})
110 require.ErrorIs(t, err, domain.ErrInvalid)
111 _, err = r.mrs.AddComment(r.ctx, mrW, r.repo, n, ports.AddCommentParams{Path: "m/f999", Line: 1, Side: domain.SideNew, Text: "no such file"})
112 require.ErrorIs(t, err, domain.ErrInvalid)
113 })
114}
115
116func TestGetMergeRequestBaseAndHeadChecks(t *testing.T) {
117 r := newMRRig(t)
118 base := r.seed()
119 head1 := r.feature("feat")
120 mr := r.open("feat")
121 n := mr.Number
122 other := oidFrom(t, "1111111111111111111111111111111111111111")
123
124 t.Run("matching or unset base and head", func(t *testing.T) {
125 for _, o := range []ports.GetMergeRequestOptions{
126 {}, {Base: base}, {Head: head1}, {Base: base, Head: head1}, {Base: base, Head: head1, Files: &ports.FileWindow{To: 1}},
127 } {
128 d, err := r.mrs.Get(r.ctx, reader, r.repo, n, o)
129 require.NoError(t, err, "%+v", o)
130 assert.Equal(t, base, d.DiffBaseID)
131 }
132 })
133
134 t.Run("another base or head is a conflict", func(t *testing.T) {
135 for _, o := range []ports.GetMergeRequestOptions{
136 {Base: other}, {Head: other}, {Base: base, Head: other}, {Base: other, Head: head1},
137 {Base: head1}, // the head is not the base
138 {Base: other, Files: &ports.FileWindow{From: 1, To: 2}},
139 } {
140 _, err := r.mrs.Get(r.ctx, reader, r.repo, n, o)
141 require.ErrorIs(t, err, domain.ErrConflict, "%+v", o)
142 }
143 })
144
145 t.Run("the head moved", func(t *testing.T) {
146 r.commit(author, "d.txt", "dee\n", "add d")
147 head2 := r.head()
148 r.push("feat")
149 _, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Base: base, Head: head1})
150 require.ErrorIs(t, err, domain.ErrConflict)
151 d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Base: base, Head: head2})
152 require.NoError(t, err)
153 assert.Equal(t, head2, d.MergeRequest.HeadID)
154 })
155
156 t.Run("the base is the one after since-last-read", func(t *testing.T) {
157 head2 := r.branchHead("feat")
158 _, err := r.mrs.MarkRead(r.ctx, mrW, r.repo, n, head1)
159 require.NoError(t, err)
160 // The whole diff (base) is no longer what since-last-read shows (head1).
161 _, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true, Base: base, Head: head2})
162 require.ErrorIs(t, err, domain.ErrConflict)
163 d, err := r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{SinceLastRead: true, Base: head1, Head: head2})
164 require.NoError(t, err)
165 assert.Equal(t, head1, d.DiffBaseID)
166 require.Len(t, d.Diff.Files, 1)
167 assert.Equal(t, "d.txt", d.Diff.Files[0].NewPath)
168 // Without since-last-read the base is still the merge base.
169 _, err = r.mrs.Get(r.ctx, reader, r.repo, n, ports.GetMergeRequestOptions{Base: head1, Head: head2})
170 require.ErrorIs(t, err, domain.ErrConflict)
171 })
172}
173
174func TestGetMergeRequestDiffOnly(t *testing.T) {
175 r := newMRRig(t)
176 base := r.seed()
177 r.feature("feat")
178 mr := r.open("feat")
179
180 full, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{})
181 require.NoError(t, err)
182 require.Len(t, full.Commits, 2)
183
184 d, err := r.mrs.Get(r.ctx, reader, r.repo, mr.Number, ports.GetMergeRequestOptions{DiffOnly: true})
185 require.NoError(t, err)
186 assert.Empty(t, d.Commits)
187 assert.False(t, d.CommitsTruncated)
188 assert.Equal(t, base, d.DiffBaseID)
189 assert.Equal(t, full.Diff, d.Diff)
190 assert.Equal(t, full.Stats, d.Stats)
191 assert.Equal(t, full.Comments, d.Comments)
192}