Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · metadata/issues_test.go

Line attribution on main · 423 line(s)

c5902451
Keenan Nemetz
16d ago
1 package metadata
37c01e5a
Keenan Nemetz
17d ago
2
3 import (
4 "strings"
5 "testing"
6 "time"
67dd8854
Keenan Nemetz
17d ago
7
d37d301f
Keenan Nemetz
16h ago
8 "rodent.software/vole/forge/identity"
37c01e5a
Keenan Nemetz
17d ago
9 )
10
11 const (
12 idA = "01M5JR2JK29KKMAA1T9MDNTPBP"
13 idB = "01M5JR2JK29KKMAA1T9MDNTPBQ"
14 idC = "01M5JR2JK29KKMAA1T9MDNTPBR"
15 )
16
17 func validContents() map[string]string {
18 return map[string]string{
307cbc2a
Keenan Nemetz
17d ago
19 "issues/" + idA + "/issue.md": "---\n" +
20 `title: "It broke"` + "\n" +
21 "state: open\n" +
22 "author: keenan\n" +
23 "created: 2026-10-22T21:00:00Z\n" +
24 "labels:\n" +
25 " - bug\n" +
26 " - needs-repro\n" +
27 "---\n" +
28 "Steps to reproduce.\n",
37c01e5a
Keenan Nemetz
17d ago
29 "issues/" + idA + "/comments/" + idB + ".md": "---\nauthor: alice\ncreated: 2026-10-22T21:01:00Z\n---\nMe too.\n",
30 "issues/" + idA + "/comments/" + idC + ".md": "---\nauthor: bob\ncreated: 2026-10-22T21:02:00Z\n---\nFixed in main.\n",
31 }
32 }
33
34 func TestParseValid(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
35 t.Parallel()
c5902451
Keenan Nemetz
16d ago
36 list, err := ParseIssues(validContents())
37c01e5a
Keenan Nemetz
17d ago
37 if err != nil {
38 t.Fatalf("unexpected error: %v", err)
39 }
40 if len(list) != 1 {
41 t.Fatalf("issues = %d, want 1", len(list))
42 }
43 iss := list[0]
44 if iss.Title != "It broke" || iss.State != "open" || iss.Author != "keenan" {
45 t.Fatalf("bad issue: %+v", iss)
46 }
47 if len(iss.Labels) != 2 || iss.Labels[0] != "bug" {
48 t.Fatalf("labels = %v", iss.Labels)
49 }
50 if iss.Description != "Steps to reproduce.\n" {
51 t.Fatalf("description = %q", iss.Description)
52 }
53 if len(iss.Comments) != 2 {
54 t.Fatalf("comments = %d, want 2", len(iss.Comments))
55 }
56 // Sorted by id, which is time-ordered.
57 if iss.Comments[0].Author != "alice" || iss.Comments[1].Author != "bob" {
58 t.Fatalf("comment order: %s then %s", iss.Comments[0].Author, iss.Comments[1].Author)
59 }
60 if strings.TrimSpace(iss.Comments[0].Body) != "Me too." {
61 t.Fatalf("comment body = %q", iss.Comments[0].Body)
62 }
63 }
64
65 func TestParseIgnoresUnrelatedFiles(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
66 t.Parallel()
37c01e5a
Keenan Nemetz
17d ago
67 c := validContents()
68 c["README.md"] = "notes about the issues branch\n"
c5902451
Keenan Nemetz
16d ago
69 if _, err := ParseIssues(c); err != nil {
37c01e5a
Keenan Nemetz
17d ago
70 t.Fatalf("unrelated files should be ignored: %v", err)
71 }
72 }
73
307cbc2a
Keenan Nemetz
17d ago
74 // TestRenderParseRoundTrip is the format's contract: whatever the writer emits,
75 // the reader reads back. The titles are the ones that break naive YAML — an
76 // unquoted `#` truncates, a leading `-` reads as a sequence, `null` reads as
77 // nothing — which is why free text is always quoted.
78 func TestRenderParseRoundTrip(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
79 t.Parallel()
307cbc2a
Keenan Nemetz
17d ago
80 titles := []string{
81 "crash on startup",
82 "crash on a<b> & c",
83 "fix #12 crash",
84 "panic: nil map",
85 "- not a list",
86 "*anchor",
87 "null",
88 "~",
89 "123",
90 `it's "quoted"`,
91 `path C:\temp`,
92 "café ☕ breaks",
93 strings.Repeat("long title ", 17) + "end",
94 }
95 for _, title := range titles {
96 iss := &Issue{
97 ID: idA,
98 Title: title,
99 State: "open",
100 Author: "keenan",
101 Created: time.Date(2026, 10, 22, 21, 0, 0, 0, time.UTC),
102 Labels: []string{"bug", "null"},
103 Description: "It broke.\n",
104 }
105 raw, err := renderIssue(iss)
106 if err != nil {
107 t.Fatalf("%q: render: %v", title, err)
108 }
c5902451
Keenan Nemetz
16d ago
109 got, err := ParseIssues(map[string]string{"issues/" + idA + "/issue.md": string(raw)})
307cbc2a
Keenan Nemetz
17d ago
110 if err != nil {
111 t.Fatalf("%q: parse:\n%s\nerror: %v", title, raw, err)
112 }
113 if got[0].Title != title {
114 t.Fatalf("title came back as %q, want %q:\n%s", got[0].Title, title, raw)
115 }
116 if got[0].Description != "It broke.\n" {
117 t.Fatalf("%q: description = %q", title, got[0].Description)
118 }
119 if len(got[0].Labels) != 2 || got[0].Labels[1] != "null" {
120 t.Fatalf("%q: labels = %v", title, got[0].Labels)
121 }
122 }
123 }
124
67dd8854
Keenan Nemetz
17d ago
125 // TestAssigneesAreAccountNames: an assignee names a person, so the value has to
126 // be something the roster could issue. Anything else — a capital, a dot, a
127 // leading @ — is a typo that would render as a filter link to nobody.
128 func TestAssigneesAreAccountNames(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
129 t.Parallel()
67dd8854
Keenan Nemetz
17d ago
130 // Build the files the way a writer would, so the validator is what refuses
131 // rather than YAML refusing to parse the line first.
132 mk := func(assignees ...string) map[string]string {
c5902451
Keenan Nemetz
16d ago
133 d := NewDoc()
67dd8854
Keenan Nemetz
17d ago
134 d.Text("title", "assigned")
135 d.Word("state", "open")
136 d.Word("author", "keenan")
137 d.Word("created", "2026-10-22T21:00:00Z")
138 d.List("assignees", assignees)
139 raw, err := d.Bytes("body\n")
140 if err != nil {
141 t.Fatal(err)
142 }
143 c := validContents()
144 c["issues/"+idA+"/issue.md"] = string(raw)
145 return c
146 }
147
c5902451
Keenan Nemetz
16d ago
148 list, err := ParseIssues(mk("wilbur", "keenan"))
67dd8854
Keenan Nemetz
17d ago
149 if err != nil {
150 t.Fatalf("valid assignees refused: %v", err)
151 }
152 if got := list[0].Assignees; len(got) != 2 || got[0] != "keenan" || got[1] != "wilbur" {
153 t.Fatalf("assignees = %v, want sorted [keenan wilbur]", got)
154 }
155
156 for _, bad := range []string{"Wilbur", "wil.bur", "@wilbur", "w", strings.Repeat("a", 33), "wil bur"} {
c5902451
Keenan Nemetz
16d ago
157 _, err := ParseIssues(mk(bad))
67dd8854
Keenan Nemetz
17d ago
158 ve, ok := err.(*ValidationError)
159 if !ok {
160 t.Fatalf("%q was accepted: %v", bad, err)
161 }
162 if !strings.Contains(ve.Problems[0], "invalid assignee") || !strings.Contains(ve.Problems[0], bad) {
163 t.Fatalf("%q: unhelpful refusal: %v", bad, ve.Problems)
164 }
165 }
166 }
167
168 // TestAssigneeRuleMatchesAccountNames keeps the format's copy of the roster's
169 // name rule honest. A name the roster would accept but an issue would reject
170 // (or the reverse) is a bug that only shows up as a rejected push.
171 func TestAssigneeRuleMatchesAccountNames(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
172 t.Parallel()
67dd8854
Keenan Nemetz
17d ago
173 for _, s := range []string{"keenan", "wilbur-2", "a_b", "x1", "@keenan", "Keenan", "a.b",
174 "a", strings.Repeat("a", 32), strings.Repeat("a", 33), "with space"} {
175 want := identity.ValidateName(s) == nil
176 if got := assigneeRE.MatchString(s); got != want {
177 t.Fatalf("%q: issue format says %v, roster says %v", s, got, want)
178 }
179 }
180 }
181
182 // TestAssigneeRoundTrip: what the writer emits is what the reader reads, and the
183 // list is written sorted so an assignment is one added line.
184 func TestAssigneeRoundTrip(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
185 t.Parallel()
67dd8854
Keenan Nemetz
17d ago
186 iss := &Issue{
187 ID: idA,
188 Title: "assigned",
189 State: "open",
190 Author: "keenan",
191 Created: time.Date(2026, 10, 22, 21, 0, 0, 0, time.UTC),
192 Assignees: []string{"wilbur", "keenan"},
193 Description: "body\n",
194 }
195 raw, err := renderIssue(iss)
196 if err != nil {
197 t.Fatal(err)
198 }
199 if !strings.Contains(string(raw), "assignees:\n - keenan\n - wilbur\n") {
200 t.Fatalf("assignees not written sorted, one per line:\n%s", raw)
201 }
c5902451
Keenan Nemetz
16d ago
202 got, err := ParseIssues(map[string]string{"issues/" + idA + "/issue.md": string(raw)})
67dd8854
Keenan Nemetz
17d ago
203 if err != nil {
204 t.Fatalf("parse: %v\n%s", raw, err)
205 }
206 if len(got[0].Assignees) != 2 || got[0].Assignees[0] != "keenan" || got[0].Assignees[1] != "wilbur" {
207 t.Fatalf("assignees came back as %v", got[0].Assignees)
208 }
209 }
210
9cc2f087
Keenan Nemetz
17d ago
211 // TestAnchorRoundTrips: an anchored comment is four extra fields on the same file
212 // shape, and the writer and reader have to agree about all of them.
213 func TestAnchorRoundTrips(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
214 t.Parallel()
9cc2f087
Keenan Nemetz
17d ago
215 c := &Comment{
216 ID: idB,
217 Author: "keenan",
218 Created: time.Date(2026, 10, 23, 9, 0, 0, 0, time.UTC),
219 Body: "this line is wrong\n",
220 Anchor: &Anchor{
c5902451
Keenan Nemetz
16d ago
221 Path: "hook/hook.go",
9cc2f087
Keenan Nemetz
17d ago
222 Side: "new",
223 Line: 412,
224 Blob: "0123456789abcdef0123456789abcdef01234567",
225 },
226 }
227 raw, err := RenderComment(c)
228 if err != nil {
229 t.Fatal(err)
230 }
231 got, prob := ParseComment(idB, string(raw), "issues/"+idA+"/comments/"+idB+".md")
232 if prob != "" {
233 t.Fatalf("parse: %s\n%s", prob, raw)
234 }
235 if got.Anchor == nil {
236 t.Fatalf("the anchor was lost:\n%s", raw)
237 }
238 if *got.Anchor != *c.Anchor {
239 t.Fatalf("anchor = %+v, want %+v", *got.Anchor, *c.Anchor)
240 }
241 // A comment with no anchor stays a comment with no anchor.
242 plain, err := RenderComment(&Comment{ID: idC, Author: "keenan", Created: c.Created, Body: "hi\n"})
243 if err != nil {
244 t.Fatal(err)
245 }
246 if strings.Contains(string(plain), "path:") {
247 t.Fatalf("an anchorless comment grew an anchor:\n%s", plain)
248 }
249 }
250
251 // TestAnchorMustBeWhole: half an anchor, or one pointing outside the repository,
252 // is a mistake that would render as a link to nowhere.
253 func TestAnchorMustBeWhole(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
254 t.Parallel()
9cc2f087
Keenan Nemetz
17d ago
255 const blob = "0123456789abcdef0123456789abcdef01234567"
256 cases := []struct{ name, head string }{
257 {"line alone", "author: keenan\ncreated: 2026-10-23T09:00:00Z\nline: 4\n"},
258 {"path without line", "author: keenan\ncreated: 2026-10-23T09:00:00Z\npath: a.go\nside: new\n"},
259 {"bad side", "author: keenan\ncreated: 2026-10-23T09:00:00Z\npath: a.go\nside: middle\nline: 4\nblob: " + blob + "\n"},
260 {"line zero", "author: keenan\ncreated: 2026-10-23T09:00:00Z\npath: a.go\nside: new\nline: 0\nblob: " + blob + "\n"},
261 {"short blob", "author: keenan\ncreated: 2026-10-23T09:00:00Z\npath: a.go\nside: new\nline: 4\nblob: abc123\n"},
262 {"absolute path", "author: keenan\ncreated: 2026-10-23T09:00:00Z\npath: /etc/passwd\nside: new\nline: 4\nblob: " + blob + "\n"},
263 {"escaping path", "author: keenan\ncreated: 2026-10-23T09:00:00Z\npath: ../secret\nside: new\nline: 4\nblob: " + blob + "\n"},
264 }
265 for _, c := range cases {
b492b557
Keenan Nemetz
3d ago
266 _, prob := ParseComment(idB, "---\n"+c.head+"---\nbody\n", "changes/"+idA+"/comments/"+idB+".md")
9cc2f087
Keenan Nemetz
17d ago
267 if prob == "" {
268 t.Fatalf("%s was accepted", c.name)
269 }
270 if !strings.Contains(prob, "comments/"+idB+".md") {
271 t.Fatalf("%s: refusal does not name the file: %s", c.name, prob)
272 }
273 }
274 }
275
79c88d72
Keenan Nemetz
17d ago
276 // TestAuthorOfReadsAnyMetadataFile: the hook asks this before letting anybody
277 // change a comment, so it has to read an author out of a file whose other fields
278 // belong to a different entity — an issue file's title, state and labels are not
279 // the reader's business.
280 func TestAuthorOfReadsAnyMetadataFile(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
281 t.Parallel()
79c88d72
Keenan Nemetz
17d ago
282 comment := "---\nauthor: wilbur\ncreated: 2026-10-22T21:01:00Z\n---\nwords\n"
283 if got, err := AuthorOf([]byte(comment)); err != nil || got != "wilbur" {
284 t.Fatalf("comment author = %q, %v", got, err)
285 }
286 issue := "---\ntitle: \"bug\"\nstate: open\nauthor: keenan\ncreated: 2026-10-22T21:00:00Z\n" +
287 "labels:\n - bug\nassignees:\n - wilbur\n---\nbody\n"
288 if got, err := AuthorOf([]byte(issue)); err != nil || got != "keenan" {
289 t.Fatalf("issue author = %q, %v", got, err)
290 }
291 if _, err := AuthorOf([]byte("---\ncreated: 2026-10-22T21:00:00Z\n---\nbody\n")); err == nil {
292 t.Fatal("a file with no author was accepted")
293 }
294 if _, err := AuthorOf([]byte("no frontmatter\n")); err == nil {
295 t.Fatal("a file with no frontmatter was accepted")
296 }
297 }
298
299 // TestCommentPathMatchesTheReader: the writer and the validator have to agree
300 // about where a comment lives, or an edit commits to a path nobody reads.
301 func TestCommentPathMatchesTheReader(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
302 t.Parallel()
c5902451
Keenan Nemetz
16d ago
303 path := IssueCommentPath(idA, idB)
79c88d72
Keenan Nemetz
17d ago
304 if path != "issues/"+idA+"/comments/"+idB+".md" {
305 t.Fatalf("path = %q", path)
306 }
c5902451
Keenan Nemetz
16d ago
307 if !IsIssueCommentPath(path) {
79c88d72
Keenan Nemetz
17d ago
308 t.Fatal("a comment path was not recognised")
309 }
c5902451
Keenan Nemetz
16d ago
310 if IsIssueCommentPath("issues/" + idA + "/issue.md") {
79c88d72
Keenan Nemetz
17d ago
311 t.Fatal("an issue file was mistaken for a comment")
312 }
b492b557
Keenan Nemetz
3d ago
313 if IsIssueCommentPath("changes/" + idA + "/change.md") {
79c88d72
Keenan Nemetz
17d ago
314 t.Fatal("a request file was mistaken for a comment")
315 }
316 }
317
307cbc2a
Keenan Nemetz
17d ago
318 // TestUnquotedTitleIsRefused: a hand-edit that drops the quotes on a title with
319 // " # " would otherwise store a shorter title and say nothing.
320 func TestUnquotedTitleIsRefused(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
321 t.Parallel()
307cbc2a
Keenan Nemetz
17d ago
322 c := validContents()
323 c["issues/"+idA+"/issue.md"] = "---\ntitle: fix #12 crash\nstate: open\nauthor: keenan\n" +
324 "created: 2026-10-22T21:00:00Z\n---\nbody\n"
c5902451
Keenan Nemetz
16d ago
325 _, err := ParseIssues(c)
307cbc2a
Keenan Nemetz
17d ago
326 ve, ok := err.(*ValidationError)
327 if !ok {
328 t.Fatalf("error is %T, want *ValidationError: %v", err, err)
329 }
330 if !strings.Contains(err.Error(), "must be double-quoted") {
331 t.Fatalf("the refusal does not explain the quotes: %v", ve.Problems)
332 }
333 }
334
335 // TestTitleOnOneLine: the writer refuses control characters, so the reader must
336 // too — otherwise a tree the server accepts is a tree nobody can edit again.
337 func TestTitleOnOneLine(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
338 t.Parallel()
307cbc2a
Keenan Nemetz
17d ago
339 c := validContents()
340 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"two\\nlines\"\nstate: open\nauthor: keenan\n" +
341 "created: 2026-10-22T21:00:00Z\n---\nbody\n"
c5902451
Keenan Nemetz
16d ago
342 if _, err := ParseIssues(c); err == nil {
307cbc2a
Keenan Nemetz
17d ago
343 t.Fatal("a title with a newline was accepted")
344 }
345 }
346
37c01e5a
Keenan Nemetz
17d ago
347 func TestValidationFailures(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
348 t.Parallel()
37c01e5a
Keenan Nemetz
17d ago
349 for name, mutate := range map[string]func(map[string]string){
350 "bad ulid directory": func(c map[string]string) {
307cbc2a
Keenan Nemetz
17d ago
351 c["issues/not-a-ulid/issue.md"] = "---\ntitle: \"x\"\nstate: open\nauthor: k\ncreated: 2026-10-22T21:00:00Z\n---\n"
37c01e5a
Keenan Nemetz
17d ago
352 },
307cbc2a
Keenan Nemetz
17d ago
353 "missing issue file": func(c map[string]string) {
354 delete(c, "issues/"+idA+"/issue.md")
37c01e5a
Keenan Nemetz
17d ago
355 },
356 "empty title": func(c map[string]string) {
307cbc2a
Keenan Nemetz
17d ago
357 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"\"\nstate: open\nauthor: k\ncreated: 2026-10-22T21:00:00Z\n---\n"
37c01e5a
Keenan Nemetz
17d ago
358 },
359 "long title": func(c map[string]string) {
307cbc2a
Keenan Nemetz
17d ago
360 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"" + strings.Repeat("x", 300) + "\"\nstate: open\nauthor: k\ncreated: 2026-10-22T21:00:00Z\n---\n"
37c01e5a
Keenan Nemetz
17d ago
361 },
362 "bad state": func(c map[string]string) {
307cbc2a
Keenan Nemetz
17d ago
363 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"t\"\nstate: wontfix\nauthor: k\ncreated: 2026-10-22T21:00:00Z\n---\n"
37c01e5a
Keenan Nemetz
17d ago
364 },
365 "missing author": func(c map[string]string) {
307cbc2a
Keenan Nemetz
17d ago
366 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"t\"\nstate: open\ncreated: 2026-10-22T21:00:00Z\n---\n"
37c01e5a
Keenan Nemetz
17d ago
367 },
368 "bad date": func(c map[string]string) {
307cbc2a
Keenan Nemetz
17d ago
369 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"t\"\nstate: open\nauthor: k\ncreated: yesterday\n---\n"
37c01e5a
Keenan Nemetz
17d ago
370 },
307cbc2a
Keenan Nemetz
17d ago
371 "unknown frontmatter field": func(c map[string]string) {
372 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"t\"\nstate: open\nauthor: k\ncreated: 2026-10-22T21:00:00Z\nsneaky: true\n---\n"
37c01e5a
Keenan Nemetz
17d ago
373 },
374 "bad label": func(c map[string]string) {
307cbc2a
Keenan Nemetz
17d ago
375 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"t\"\nstate: open\nauthor: k\ncreated: 2026-10-22T21:00:00Z\nlabels:\n - \"Has Spaces\"\n---\n"
376 },
377 "no frontmatter": func(c map[string]string) {
378 c["issues/"+idA+"/issue.md"] = "just text\n"
379 },
380 "unterminated frontmatter": func(c map[string]string) {
381 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"t\"\nstate: open\nauthor: k\ncreated: 2026-10-22T21:00:00Z\nbody\n"
37c01e5a
Keenan Nemetz
17d ago
382 },
383 "comment without frontmatter": func(c map[string]string) {
384 c["issues/"+idA+"/comments/"+idB+".md"] = "just text\n"
385 },
386 "comment unknown field": func(c map[string]string) {
307cbc2a
Keenan Nemetz
17d ago
387 c["issues/"+idA+"/comments/"+idB+".md"] = "---\nauthor: a\ncreated: 2026-10-22T21:00:00Z\nedited: \"yes\"\n---\nx\n"
37c01e5a
Keenan Nemetz
17d ago
388 },
389 "comment unterminated": func(c map[string]string) {
390 c["issues/"+idA+"/comments/"+idB+".md"] = "---\nauthor: a\ncreated: 2026-10-22T21:00:00Z\nx\n"
391 },
392 "stray file in issue dir": func(c map[string]string) {
393 c["issues/"+idA+"/notes.txt"] = "nope"
394 },
395 "nested too deep": func(c map[string]string) {
396 c["issues/"+idA+"/comments/x/y.md"] = "nope"
397 },
398 } {
399 t.Run(name, func(t *testing.T) {
400 c := validContents()
401 mutate(c)
c5902451
Keenan Nemetz
16d ago
402 if _, err := ParseIssues(c); err == nil {
37c01e5a
Keenan Nemetz
17d ago
403 t.Fatal("expected a validation error")
404 } else if _, ok := err.(*ValidationError); !ok {
405 t.Fatalf("error is %T, want *ValidationError: %v", err, err)
406 }
407 })
408 }
409 }
410
411 func TestAllProblemsReported(t *testing.T) {
44530e74
Keenan Nemetz
16d ago
412 t.Parallel()
37c01e5a
Keenan Nemetz
17d ago
413 c := validContents()
307cbc2a
Keenan Nemetz
17d ago
414 c["issues/"+idA+"/issue.md"] = "---\ntitle: \"\"\nstate: nope\nauthor: \"\"\ncreated: \"\"\n---\n"
c5902451
Keenan Nemetz
16d ago
415 _, err := ParseIssues(c)
37c01e5a
Keenan Nemetz
17d ago
416 ve, ok := err.(*ValidationError)
417 if !ok {
418 t.Fatalf("error is %T: %v", err, err)
419 }
420 if len(ve.Problems) < 4 {
421 t.Fatalf("want several problems, got %d: %v", len(ve.Problems), ve.Problems)
422 }
423 }