1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain_test
4
5import (
6 "errors"
7 "strings"
8 "testing"
9
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12
13 "bnema.dev/ermite/internal/domain"
14)
15
16// textCases are the rules shared by every text; multi says whether line breaks
17// are allowed.
18func TestParseMRTitle(t *testing.T) {
19 tests := []struct {
20 name string
21 in string
22 reason string // "" when valid
23 }{
24 {"plain", "Add the login page", ""},
25 {"unicode", "Ajoute la page d’accueil — ça marche 🎉", ""},
26 {"max length in characters", strings.Repeat("é", domain.MaxTitleLen), ""},
27 {"too long", strings.Repeat("a", domain.MaxTitleLen+1), "longer than 120"},
28 {"empty", "", "is empty"},
29 {"blank", " \u00a0 ", "is empty"},
30 {"line break", "two\nlines", "one line"},
31 {"carriage return", "a\rb", "control character U+000D"},
32 {"tab", "a\tb", "control character U+0009"},
33 {"nul", "a\x00b", "control character U+0000"},
34 {"escape", "\x1b[31mred", "control character U+001B"},
35 {"delete", "a\x7fb", "control character U+007F"},
36 {"c1 control", "a\u0085b", "control character U+0085"},
37 {"invalid utf-8", "a\xffb", "not valid UTF-8"},
38 {"bidi override", "evil \u202e txt.exe", "U+202E"},
39 {"bidi embedding", "a\u202ab", "U+202A"},
40 {"bidi isolate", "a\u2066b", "U+2066"},
41 {"bidi pop isolate", "a\u2069b", "U+2069"},
42 {"line separator", "a\u2028b", "U+2028"},
43 {"paragraph separator", "a\u2029b", "U+2029"},
44 {"left-to-right mark", "a\u200eb", "U+200E"},
45 {"right-to-left mark", "a\u200fb", "U+200F"},
46 {"arabic letter mark", "a\u061cb", "U+061C"},
47 {"zero width joiner is plain text", "a\u200db", ""},
48 }
49 for _, tc := range tests {
50 t.Run(tc.name, func(t *testing.T) {
51 got, err := domain.ParseMRTitle(tc.in)
52 if tc.reason == "" {
53 require.NoError(t, err)
54 assert.Equal(t, tc.in, got.String())
55 assert.False(t, got.IsZero())
56 return
57 }
58 require.Error(t, err)
59 assert.ErrorIs(t, err, domain.ErrInvalidText)
60 assert.Contains(t, err.Error(), tc.reason)
61 assert.True(t, got.IsZero())
62 })
63 }
64}
65
66func TestParsePlainText(t *testing.T) {
67 tests := []struct {
68 name string
69 in string
70 reason string
71 }{
72 {"empty is valid", "", ""},
73 {"multi-line", "one\ntwo\n\nthree", ""},
74 {"trailing newline", "x\n", ""},
75 {"max length", strings.Repeat("a\n", domain.MaxTextLen/2), ""},
76 {"max length in characters not bytes", strings.Repeat("€", domain.MaxTextLen), ""},
77 {"too long", strings.Repeat("a", domain.MaxTextLen+1), "longer than 8000"},
78 {"carriage return", "a\r\nb", "control character U+000D"},
79 {"tab", "a\tb", "control character U+0009"},
80 {"escape sequence", "a\x1b]0;title\x07", "control character U+001B"},
81 {"bell", "a\x07", "control character U+0007"},
82 {"invalid utf-8", "ok\n\xc3\x28", "not valid UTF-8"},
83 {"bidi override", "x\u202ey", "U+202E"},
84 {"bidi left-to-right isolate", "x\u2066y", "U+2066"},
85 {"bidi first strong isolate", "x\u2068y", "U+2068"},
86 {"line separator", "x\u2028y", "U+2028"},
87 {"paragraph separator", "x\u2029y", "U+2029"},
88 {"left-to-right mark", "x\u200ey", "U+200E"},
89 {"right-to-left mark", "x\u200fy", "U+200F"},
90 {"arabic letter mark", "x\u061cy", "U+061C"},
91 {"zero width joiner is plain text", "x\u200dy", ""},
92 }
93 for _, tc := range tests {
94 t.Run(tc.name, func(t *testing.T) {
95 got, err := domain.ParsePlainText(tc.in)
96 if tc.reason == "" {
97 require.NoError(t, err)
98 assert.Equal(t, tc.in, got.String())
99 return
100 }
101 require.Error(t, err)
102 assert.ErrorIs(t, err, domain.ErrInvalidText)
103 assert.Contains(t, err.Error(), tc.reason)
104 assert.Empty(t, got.String())
105 })
106 }
107}
108
109func TestPlainTextIsBlank(t *testing.T) {
110 for in, blank := range map[string]bool{"": true, " \n\n ": true, "x": false, " x ": false} {
111 got, err := domain.ParsePlainText(in)
112 require.NoError(t, err)
113 assert.Equal(t, blank, got.IsBlank(), "%q", in)
114 }
115}
116
117func TestViolations(t *testing.T) {
118 var v domain.Violations
119 require.NoError(t, v.Err())
120
121 _, err := domain.ParseMRTitle("a\nb")
122 v.AddErr("title", err)
123 v.Add("source_branch", "does not exist")
124 v.AddErr("text", errors.New("boom"))
125
126 err = v.Err()
127 require.ErrorIs(t, err, domain.ErrInvalid)
128 var fe *domain.InvalidFieldsError
129 require.ErrorAs(t, err, &fe)
130 assert.Equal(t, []domain.FieldViolation{
131 {Field: "title", Reason: "must be one line"},
132 {Field: "source_branch", Reason: "does not exist"},
133 {Field: "text", Reason: "boom"},
134 }, fe.Fields)
135 assert.Equal(t, "invalid argument: title must be one line; source_branch does not exist; text boom", err.Error())
136}