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