1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "bnema.dev/ermite/internal/ports/mocks"
7 "bytes"
8 "context"
9 "github.com/stretchr/testify/mock"
10 "net/http"
11 "net/http/httptest"
12 "net/url"
13 "strings"
14 "testing"
15 "time"
16
17 "bnema.dev/ermite/internal/domain"
18 "bnema.dev/ermite/internal/ports"
19 "github.com/stretchr/testify/assert"
20 "github.com/stretchr/testify/require"
21)
22
23func TestParseOnRange(t *testing.T) {
24 t.Parallel()
25 for _, raw := range []string{"new:0-2:a", "new:2-2:a", "new:3-2:a", "new:1-x:a", "new:1-2:", "other:1-2:a"} {
26 assert.Nil(t, parseOn(raw), raw)
27 }
28 assert.Equal(t, &lineRef{side: "new", start: 10, line: 24, path: "dir:a.go"}, parseOn("new:10-24:dir:a.go"))
29 assert.Equal(t, &lineRef{side: "old", line: 10, path: "a"}, parseOn("old:10:a"))
30}
31
32func TestNoteThreads(t *testing.T) {
33 t.Parallel()
34 text, err := domain.ParsePlainText("**hello** ^2")
35 require.NoError(t, err)
36 s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
37 comments := []domain.Comment{
38 {ID: 1, Author: "alice", Text: text},
39 {ID: 2, Author: "bob", ReplyTo: 1, Text: text},
40 {ID: 3, Author: "alice", Deleted: true},
41 {ID: 4, Author: "alice", Deleted: true},
42 {ID: 5, Author: "bob", ReplyTo: 4, Text: text},
43 {ID: 6, Author: "alice", Text: text, Verdict: domain.VerdictApprove, Resolved: true},
44 }
45 p := MRPage{NoteAuthor: "alice"}
46 s.placeComments(t.Context(), &p, "demo", 1, comments, "", true, true, nil)
47 require.Len(t, p.General, 3)
48 assert.Equal(t, 4, p.NComments)
49 assert.Equal(t, 1, p.NUnresolved)
50 require.Len(t, p.General[0].Replies, 1)
51 assert.NotNil(t, p.General[0].Edit)
52 assert.Nil(t, p.General[0].Replies[0].Edit)
53 assert.Nil(t, p.General[0].Replies[0].Resolve)
54 assert.Contains(t, string(p.General[0].Body), "<strong>hello</strong>")
55 assert.Contains(t, string(p.General[0].Body), `href="#c2"`)
56 assert.True(t, p.General[1].Deleted)
57 var b bytes.Buffer
58 require.NoError(t, commentView(p.General[0], "").Render(context.Background(), &b))
59 assert.Contains(t, b.String(), `name="reply_to" value="1"`)
60 assert.Contains(t, b.String(), `data-js data-quote=`)
61 assert.Contains(t, b.String(), `/comments/1/edit`)
62 assert.NotContains(t, b.String(), `/comments/2/edit`)
63}
64
65func TestNoteLiveEvents(t *testing.T) {
66 t.Parallel()
67 l := liveStream{view: "mr", n: "1"}
68 events := []domain.Event{domain.CommentEdited{Number: 1}, domain.CommentDeleted{Number: 1}, domain.CommentResolved{Number: 1}}
69 for _, ev := range events {
70 we := ports.WatchEvent{Event: ev}
71 assert.True(t, liveEvent(we))
72 fs, err := l.repoPage(context.Background(), we)
73 require.NoError(t, err)
74 require.Len(t, fs, 1)
75 assert.Contains(t, fs[0].html, "reload")
76 }
77}
78
79func TestNotePosts(t *testing.T) {
80 t.Parallel()
81 svc := mocks.NewMergeRequestService(t)
82 repo := repoName(t, "demo")
83 s := &Server{cfg: Config{MergeRequests: svc}}
84 ctx := domain.ContextWithPrincipal(context.Background(), domain.Owner())
85 request := func(values url.Values) *http.Request {
86 r := httptest.NewRequest("POST", "/demo/merge-requests/1/comments", strings.NewReader(values.Encode())).WithContext(ctx)
87 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
88 r.SetPathValue("repo", "demo")
89 r.SetPathValue("n", "1")
90 r.SetPathValue("id", "2")
91 require.NoError(t, r.ParseForm())
92 return r
93 }
94 svc.EXPECT().AddComment(mock.Anything, mock.Anything, repo, int64(1), mock.MatchedBy(func(p ports.AddCommentParams) bool { return p.ReplyTo == 2 && p.Text == "reply" })).Return(domain.Comment{ID: 7}, nil).Once()
95 to, _, err := s.mrComment(ctx, request(url.Values{"reply_to": {"2"}, "text": {"reply"}}))
96 require.NoError(t, err)
97 assert.Equal(t, "/demo/merge-requests/1#c7", to)
98 svc.EXPECT().AddComment(mock.Anything, mock.Anything, repo, int64(1), mock.MatchedBy(func(p ports.AddCommentParams) bool {
99 return p.StartLine == 10 && p.Line == 24 && p.Path == "a.go" && p.Verdict == domain.VerdictApprove
100 })).Return(domain.Comment{ID: 8}, nil).Once()
101 to, _, err = s.mrComment(ctx, request(url.Values{"path": {"a.go"}, "line": {"24"}, "start_line": {"10"}, "side": {"new"}, "verdict": {"approve"}}))
102 require.NoError(t, err)
103 assert.Equal(t, "/demo/merge-requests/1#c8", to)
104 svc.EXPECT().EditComment(mock.Anything, mock.Anything, repo, int64(1), int64(2), "new\ntext").Return(domain.Comment{ID: 2}, nil).Once()
105 to, _, err = s.mrEditComment(ctx, request(url.Values{"text": {"new\r\ntext"}}))
106 require.NoError(t, err)
107 assert.Equal(t, "/demo/merge-requests/1#c2", to)
108 svc.EXPECT().DeleteComment(mock.Anything, mock.Anything, repo, int64(1), int64(2)).Return(domain.Comment{ID: 2}, nil).Once()
109 to, _, err = s.mrDeleteComment(ctx, request(nil))
110 require.NoError(t, err)
111 assert.Equal(t, "/demo/merge-requests/1#c2", to)
112}
113
114func TestDeletedNoteRendering(t *testing.T) {
115 t.Parallel()
116 text, err := domain.ParsePlainText("surviving reply")
117 require.NoError(t, err)
118 s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
119 p := MRPage{}
120 s.placeComments(t.Context(), &p, "demo", 1, []domain.Comment{
121 {ID: 1, Author: "reviewer", Deleted: true, Verdict: domain.VerdictApprove},
122 {ID: 2, Deleted: true},
123 {ID: 3, ReplyTo: 2, Deleted: true},
124 {ID: 4, Deleted: true},
125 {ID: 5, ReplyTo: 4, Text: text},
126 }, "", true, true, nil)
127 require.Len(t, p.General, 2)
128 assert.Equal(t, "1", p.General[0].ID)
129 assert.Equal(t, "4", p.General[1].ID)
130 var b bytes.Buffer
131 require.NoError(t, commentView(p.General[0], "").Render(context.Background(), &b))
132 assert.Contains(t, b.String(), `<span class="c-who">owner</span>`)
133 assert.Contains(t, b.String(), "approve")
134 assert.Contains(t, b.String(), "text deleted")
135 b.Reset()
136 require.NoError(t, commentView(p.General[1], "").Render(context.Background(), &b))
137 assert.Contains(t, b.String(), "surviving reply")
138 assert.NotContains(t, b.String(), "Quote")
139 assert.NotContains(t, b.String(), "data-quote")
140}
141
142func TestInlineNoteHeadingIDs(t *testing.T) {
143 t.Parallel()
144 // "S H" would get the id the split copy of "H" gets if the prefixes of the
145 // two copies overlapped.
146 text, err := domain.ParsePlainText("# H\n\n# S H\n\n[jump](#h)")
147 require.NoError(t, err)
148 s := &Server{cfg: Config{Now: time.Now}, md: newMarkdown()}
149 p := MRPage{Files: []DiffFile{{Key: "a", ID: "f1", Hunks: []DiffHunk{{Lines: []DiffLine{{Kind: "add", newN: 1}}}}}}}
150 s.placeComments(t.Context(), &p, "demo", 1, []domain.Comment{{ID: 12, Path: "a", Side: domain.SideNew, Line: 1, Text: text}}, "", true, true, nil)
151 notes := p.Files[0].Hunks[0].Lines[0].Notes
152 require.Len(t, notes, 1)
153 var b bytes.Buffer
154 require.NoError(t, commentView(notes[0], "").Render(context.Background(), &b))
155 require.NoError(t, commentView(notes[0], "-s").Render(context.Background(), &b))
156 html := b.String()
157 for _, id := range []string{"c12-h", "c12-s-h", "c12s-h", "c12s-s-h"} {
158 assert.Equal(t, 1, strings.Count(html, `id="`+id+`"`), id)
159 }
160 assert.Contains(t, html, `href="#c12-h"`)
161 assert.Contains(t, html, `href="#c12s-h"`)
162}