OCaml library for JSONfeed parsing and creation
1(** Tests for jsonfeed library *)
2
3open Jsonfeed
4
5(* Author tests *)
6
7let test_author_create_with_name () =
8 let author = Author.create ~name:"Jane Doe" () in
9 Alcotest.(check (option string)) "name" (Some "Jane Doe") (Author.name author);
10 Alcotest.(check (option string)) "url" None (Author.url author);
11 Alcotest.(check (option string)) "avatar" None (Author.avatar author);
12 Alcotest.(check bool) "is_valid" true (Author.is_valid author)
13
14let test_author_create_with_url () =
15 let author = Author.create ~url:"https://example.com" () in
16 Alcotest.(check (option string)) "name" None (Author.name author);
17 Alcotest.(check (option string))
18 "url" (Some "https://example.com") (Author.url author);
19 Alcotest.(check bool) "is_valid" true (Author.is_valid author)
20
21let test_author_create_with_all_fields () =
22 let author =
23 Author.create ~name:"Jane Doe" ~url:"https://example.com"
24 ~avatar:"https://example.com/avatar.png" ()
25 in
26 Alcotest.(check (option string)) "name" (Some "Jane Doe") (Author.name author);
27 Alcotest.(check (option string))
28 "url" (Some "https://example.com") (Author.url author);
29 Alcotest.(check (option string))
30 "avatar" (Some "https://example.com/avatar.png") (Author.avatar author);
31 Alcotest.(check bool) "is_valid" true (Author.is_valid author)
32
33let test_author_create_no_fields_fails () =
34 Alcotest.check_raises "no fields"
35 (Invalid_argument
36 "Author.create: at least one field (name, url, or avatar) must be \
37 provided") (fun () -> ignore (Author.create ()))
38
39let test_author_equal () =
40 let a1 = Author.create ~name:"Jane Doe" () in
41 let a2 = Author.create ~name:"Jane Doe" () in
42 let a3 = Author.create ~name:"John Smith" () in
43 Alcotest.(check bool) "equal same" true (Author.equal a1 a2);
44 Alcotest.(check bool) "equal different" false (Author.equal a1 a3)
45
46let test_author_pp () =
47 let author = Author.create ~name:"Jane Doe" ~url:"https://example.com" () in
48 let s = Format.asprintf "%a" Author.pp author in
49 Alcotest.(check string)
50 "pp with name and url" "Jane Doe <https://example.com>" s
51
52let author_tests =
53 [
54 ("create with name", `Quick, test_author_create_with_name);
55 ("create with url", `Quick, test_author_create_with_url);
56 ("create with all fields", `Quick, test_author_create_with_all_fields);
57 ("create with no fields fails", `Quick, test_author_create_no_fields_fails);
58 ("equal", `Quick, test_author_equal);
59 ("pp", `Quick, test_author_pp);
60 ]
61
62(* Attachment tests *)
63
64let test_attachment_create_minimal () =
65 let att =
66 Attachment.create ~url:"https://example.com/file.mp3"
67 ~mime_type:"audio/mpeg" ()
68 in
69 Alcotest.(check string)
70 "url" "https://example.com/file.mp3" (Attachment.url att);
71 Alcotest.(check string) "mime_type" "audio/mpeg" (Attachment.mime_type att);
72 Alcotest.(check (option string)) "title" None (Attachment.title att);
73 Alcotest.(check (option int64))
74 "size_in_bytes" None
75 (Attachment.size_in_bytes att);
76 Alcotest.(check (option int))
77 "duration_in_seconds" None
78 (Attachment.duration_in_seconds att)
79
80let test_attachment_create_complete () =
81 let att =
82 Attachment.create ~url:"https://example.com/episode.mp3"
83 ~mime_type:"audio/mpeg" ~title:"Episode 1" ~size_in_bytes:15_728_640L
84 ~duration_in_seconds:1800 ()
85 in
86 Alcotest.(check string)
87 "url" "https://example.com/episode.mp3" (Attachment.url att);
88 Alcotest.(check string) "mime_type" "audio/mpeg" (Attachment.mime_type att);
89 Alcotest.(check (option string))
90 "title" (Some "Episode 1") (Attachment.title att);
91 Alcotest.(check (option int64))
92 "size_in_bytes" (Some 15_728_640L)
93 (Attachment.size_in_bytes att);
94 Alcotest.(check (option int))
95 "duration_in_seconds" (Some 1800)
96 (Attachment.duration_in_seconds att)
97
98let test_attachment_equal () =
99 let a1 =
100 Attachment.create ~url:"https://example.com/file.mp3"
101 ~mime_type:"audio/mpeg" ()
102 in
103 let a2 =
104 Attachment.create ~url:"https://example.com/file.mp3"
105 ~mime_type:"audio/mpeg" ()
106 in
107 let a3 =
108 Attachment.create ~url:"https://example.com/other.mp3"
109 ~mime_type:"audio/mpeg" ()
110 in
111 Alcotest.(check bool) "equal same" true (Attachment.equal a1 a2);
112 Alcotest.(check bool) "equal different" false (Attachment.equal a1 a3)
113
114let attachment_tests =
115 [
116 ("create minimal", `Quick, test_attachment_create_minimal);
117 ("create complete", `Quick, test_attachment_create_complete);
118 ("equal", `Quick, test_attachment_equal);
119 ]
120
121(* Hub tests *)
122
123let test_hub_create () =
124 let hub = Hub.create ~type_:"WebSub" ~url:"https://example.com/hub" () in
125 Alcotest.(check string) "type_" "WebSub" (Hub.type_ hub);
126 Alcotest.(check string) "url" "https://example.com/hub" (Hub.url hub)
127
128let test_hub_equal () =
129 let h1 = Hub.create ~type_:"WebSub" ~url:"https://example.com/hub" () in
130 let h2 = Hub.create ~type_:"WebSub" ~url:"https://example.com/hub" () in
131 let h3 = Hub.create ~type_:"rssCloud" ~url:"https://example.com/hub" () in
132 Alcotest.(check bool) "equal same" true (Hub.equal h1 h2);
133 Alcotest.(check bool) "equal different" false (Hub.equal h1 h3)
134
135let hub_tests =
136 [ ("create", `Quick, test_hub_create); ("equal", `Quick, test_hub_equal) ]
137
138(* Item tests *)
139
140let test_item_create_html () =
141 let item =
142 Item.create ~id:"https://example.com/1" ~content:(`Html "<p>Hello</p>") ()
143 in
144 Alcotest.(check string) "id" "https://example.com/1" (Item.id item);
145 Alcotest.(check (option string))
146 "content_html" (Some "<p>Hello</p>") (Item.content_html item);
147 Alcotest.(check (option string)) "content_text" None (Item.content_text item)
148
149let test_item_create_text () =
150 let item =
151 Item.create ~id:"https://example.com/2" ~content:(`Text "Hello world") ()
152 in
153 Alcotest.(check string) "id" "https://example.com/2" (Item.id item);
154 Alcotest.(check (option string)) "content_html" None (Item.content_html item);
155 Alcotest.(check (option string))
156 "content_text" (Some "Hello world") (Item.content_text item)
157
158let test_item_create_both () =
159 let item =
160 Item.create ~id:"https://example.com/3"
161 ~content:(`Both ("<p>Hello</p>", "Hello"))
162 ()
163 in
164 Alcotest.(check string) "id" "https://example.com/3" (Item.id item);
165 Alcotest.(check (option string))
166 "content_html" (Some "<p>Hello</p>") (Item.content_html item);
167 Alcotest.(check (option string))
168 "content_text" (Some "Hello") (Item.content_text item)
169
170let test_item_with_metadata () =
171 let item =
172 Item.create ~id:"https://example.com/4" ~content:(`Html "<p>Test</p>")
173 ~title:"Test Post" ~url:"https://example.com/posts/4"
174 ~tags:[ "test"; "example" ] ()
175 in
176 Alcotest.(check (option string)) "title" (Some "Test Post") (Item.title item);
177 Alcotest.(check (option string))
178 "url" (Some "https://example.com/posts/4") (Item.url item);
179 Alcotest.(check (option (list string)))
180 "tags"
181 (Some [ "test"; "example" ])
182 (Item.tags item)
183
184let test_item_equal () =
185 let i1 = Item.create ~id:"https://example.com/1" ~content:(`Text "test") () in
186 let i2 =
187 Item.create ~id:"https://example.com/1" ~content:(`Html "<p>test</p>") ()
188 in
189 let i3 = Item.create ~id:"https://example.com/2" ~content:(`Text "test") () in
190 Alcotest.(check bool) "equal same id" true (Item.equal i1 i2);
191 Alcotest.(check bool) "equal different id" false (Item.equal i1 i3)
192
193let item_tests =
194 [
195 ("create with HTML content", `Quick, test_item_create_html);
196 ("create with text content", `Quick, test_item_create_text);
197 ("create with both contents", `Quick, test_item_create_both);
198 ("create with metadata", `Quick, test_item_with_metadata);
199 ("equal", `Quick, test_item_equal);
200 ]
201
202(* Jsonfeed tests *)
203
204let test_feed_create_minimal () =
205 let feed = Jsonfeed.create ~title:"Test Feed" ~items:[] () in
206 Alcotest.(check string) "title" "Test Feed" (Jsonfeed.title feed);
207 Alcotest.(check string)
208 "version" "https://jsonfeed.org/version/1.1" (Jsonfeed.version feed);
209 Alcotest.(check int) "items length" 0 (List.length (Jsonfeed.items feed))
210
211let test_feed_create_with_items () =
212 let item =
213 Item.create ~id:"https://example.com/1" ~content:(`Text "Hello") ()
214 in
215 let feed = Jsonfeed.create ~title:"Test Feed" ~items:[ item ] () in
216 Alcotest.(check int) "items length" 1 (List.length (Jsonfeed.items feed))
217
218let test_feed_validate_valid () =
219 let feed = Jsonfeed.create ~title:"Test" ~items:[] () in
220 match Jsonfeed.validate feed with
221 | Ok () -> ()
222 | Error errors ->
223 Alcotest.fail
224 (Printf.sprintf "Validation should succeed: %s"
225 (String.concat "; " errors))
226
227let test_feed_validate_empty_title () =
228 let feed = Jsonfeed.create ~title:"" ~items:[] () in
229 match Jsonfeed.validate feed with
230 | Ok () -> Alcotest.fail "Should fail validation"
231 | Error errors ->
232 Alcotest.(check bool)
233 "has error" true
234 (List.exists (fun s -> String.starts_with ~prefix:"title" s) errors)
235
236let contains_substring s sub =
237 try
238 let _ = Str.search_forward (Str.regexp_string sub) s 0 in
239 true
240 with Not_found -> false
241
242let test_feed_to_string () =
243 let feed = Jsonfeed.create ~title:"Test Feed" ~items:[] () in
244 match Jsonfeed.to_string feed with
245 | Ok json ->
246 Alcotest.(check bool)
247 "contains version" true
248 (contains_substring json "version");
249 Alcotest.(check bool)
250 "contains title" true
251 (contains_substring json "Test Feed")
252 | Error e ->
253 Alcotest.fail
254 (Printf.sprintf "Serialization failed: %s" (Jsont.Error.to_string e))
255
256let test_feed_parse_minimal () =
257 let json =
258 {|{
259 "version": "https://jsonfeed.org/version/1.1",
260 "title": "Test Feed",
261 "items": []
262 }|}
263 in
264 match Jsonfeed.of_string json with
265 | Ok feed ->
266 Alcotest.(check string) "title" "Test Feed" (Jsonfeed.title feed);
267 Alcotest.(check int) "items" 0 (List.length (Jsonfeed.items feed))
268 | Error err ->
269 Alcotest.fail
270 (Printf.sprintf "Parse failed: %s" (Jsont.Error.to_string err))
271
272let test_feed_parse_with_item () =
273 let json =
274 {|{
275 "version": "https://jsonfeed.org/version/1.1",
276 "title": "Test Feed",
277 "items": [
278 {
279 "id": "https://example.com/1",
280 "content_html": "<p>Hello</p>"
281 }
282 ]
283 }|}
284 in
285 match Jsonfeed.of_string json with
286 | Ok feed -> (
287 let items = Jsonfeed.items feed in
288 Alcotest.(check int) "items count" 1 (List.length items);
289 match items with
290 | [ item ] ->
291 Alcotest.(check string)
292 "item id" "https://example.com/1" (Item.id item);
293 Alcotest.(check (option string))
294 "content_html" (Some "<p>Hello</p>") (Item.content_html item)
295 | _ -> Alcotest.fail "Expected 1 item")
296 | Error err ->
297 Alcotest.fail
298 (Printf.sprintf "Parse failed: %s" (Jsont.Error.to_string err))
299
300let test_feed_roundtrip () =
301 let author = Author.create ~name:"Test Author" () in
302 let item =
303 Item.create ~id:"https://example.com/1" ~title:"Test Item"
304 ~content:(`Html "<p>Hello, world!</p>")
305 ~date_published:
306 (Jsonfeed.Rfc3339.parse "2024-11-01T10:00:00Z" |> Option.get)
307 ~tags:[ "test"; "example" ] ()
308 in
309
310 let feed1 =
311 Jsonfeed.create ~title:"Test Feed" ~home_page_url:"https://example.com"
312 ~authors:[ author ] ~items:[ item ] ()
313 in
314
315 (* Serialize and parse *)
316 match Jsonfeed.to_string feed1 with
317 | Error e ->
318 Alcotest.fail
319 (Printf.sprintf "Serialization failed: %s" (Jsont.Error.to_string e))
320 | Ok json -> (
321 match Jsonfeed.of_string json with
322 | Ok feed2 ->
323 Alcotest.(check string)
324 "title" (Jsonfeed.title feed1) (Jsonfeed.title feed2);
325 Alcotest.(check (option string))
326 "home_page_url"
327 (Jsonfeed.home_page_url feed1)
328 (Jsonfeed.home_page_url feed2);
329 Alcotest.(check int)
330 "items count"
331 (List.length (Jsonfeed.items feed1))
332 (List.length (Jsonfeed.items feed2))
333 | Error err ->
334 Alcotest.fail
335 (Printf.sprintf "Round-trip parse failed: %s"
336 (Jsont.Error.to_string err)))
337
338let test_feed_parse_invalid_missing_content () =
339 let json =
340 {|{
341 "version": "https://jsonfeed.org/version/1.1",
342 "title": "Test",
343 "items": [
344 {
345 "id": "1"
346 }
347 ]
348 }|}
349 in
350 match Jsonfeed.of_string json with
351 | Ok _ -> Alcotest.fail "Should reject item without content"
352 | Error err ->
353 let err_str = Jsont.Error.to_string err in
354 Alcotest.(check bool)
355 "has error" true
356 (contains_substring err_str "content")
357
358let jsonfeed_tests =
359 [
360 ("create minimal feed", `Quick, test_feed_create_minimal);
361 ("create feed with items", `Quick, test_feed_create_with_items);
362 ("validate valid feed", `Quick, test_feed_validate_valid);
363 ("validate empty title", `Quick, test_feed_validate_empty_title);
364 ("to_string", `Quick, test_feed_to_string);
365 ("parse minimal feed", `Quick, test_feed_parse_minimal);
366 ("parse feed with item", `Quick, test_feed_parse_with_item);
367 ("round-trip", `Quick, test_feed_roundtrip);
368 ( "parse invalid missing content",
369 `Quick,
370 test_feed_parse_invalid_missing_content );
371 ]
372
373(* Unknown fields preservation tests *)
374
375let test_author_unknown_roundtrip () =
376 let json =
377 {|{
378 "name": "Test Author",
379 "custom_field": "custom value",
380 "another_extension": 42
381 }|}
382 in
383 match Jsont_bytesrw.decode_string' Author.jsont json with
384 | Error e ->
385 Alcotest.fail
386 (Printf.sprintf "Parse failed: %s" (Jsont.Error.to_string e))
387 | Ok author -> (
388 (* Check that unknown fields are preserved *)
389 let unknown = Author.unknown author in
390 Alcotest.(check bool)
391 "has unknown fields" false
392 (Jsonfeed.Unknown.is_empty unknown);
393 (* Encode and decode again *)
394 match Jsont_bytesrw.encode_string' Author.jsont author with
395 | Error e ->
396 Alcotest.fail
397 (Printf.sprintf "Encode failed: %s" (Jsont.Error.to_string e))
398 | Ok json2 -> (
399 match Jsont_bytesrw.decode_string' Author.jsont json2 with
400 | Error e ->
401 Alcotest.fail
402 (Printf.sprintf "Re-parse failed: %s" (Jsont.Error.to_string e))
403 | Ok author2 ->
404 (* Verify unknown fields survive roundtrip *)
405 let unknown2 = Author.unknown author2 in
406 Alcotest.(check bool)
407 "unknown fields preserved" false
408 (Jsonfeed.Unknown.is_empty unknown2)))
409
410let test_item_unknown_roundtrip () =
411 let json =
412 {|{
413 "id": "https://example.com/1",
414 "content_html": "<p>Test</p>",
415 "custom_metadata": "some custom data",
416 "x_custom_number": 123.45
417 }|}
418 in
419 match Jsont_bytesrw.decode_string' Item.jsont json with
420 | Error e ->
421 Alcotest.fail
422 (Printf.sprintf "Parse failed: %s" (Jsont.Error.to_string e))
423 | Ok item -> (
424 (* Check that unknown fields are preserved *)
425 let unknown = Item.unknown item in
426 Alcotest.(check bool)
427 "has unknown fields" false
428 (Jsonfeed.Unknown.is_empty unknown);
429 (* Encode and decode again *)
430 match Jsont_bytesrw.encode_string' Item.jsont item with
431 | Error e ->
432 Alcotest.fail
433 (Printf.sprintf "Encode failed: %s" (Jsont.Error.to_string e))
434 | Ok json2 -> (
435 match Jsont_bytesrw.decode_string' Item.jsont json2 with
436 | Error e ->
437 Alcotest.fail
438 (Printf.sprintf "Re-parse failed: %s" (Jsont.Error.to_string e))
439 | Ok item2 ->
440 let unknown2 = Item.unknown item2 in
441 Alcotest.(check bool)
442 "unknown fields preserved" false
443 (Jsonfeed.Unknown.is_empty unknown2)))
444
445let test_feed_unknown_roundtrip () =
446 let json =
447 {|{
448 "version": "https://jsonfeed.org/version/1.1",
449 "title": "Test Feed",
450 "items": [],
451 "custom_extension": "custom value",
452 "x_another_field": {"nested": "data"}
453 }|}
454 in
455 match Jsonfeed.of_string json with
456 | Error e ->
457 Alcotest.fail
458 (Printf.sprintf "Parse failed: %s" (Jsont.Error.to_string e))
459 | Ok feed -> (
460 (* Check that unknown fields are preserved *)
461 let unknown = Jsonfeed.unknown feed in
462 Alcotest.(check bool)
463 "has unknown fields" false
464 (Jsonfeed.Unknown.is_empty unknown);
465 (* Encode and decode again *)
466 match Jsonfeed.to_string feed with
467 | Error e ->
468 Alcotest.fail
469 (Printf.sprintf "Encode failed: %s" (Jsont.Error.to_string e))
470 | Ok json2 -> (
471 match Jsonfeed.of_string json2 with
472 | Error e ->
473 Alcotest.fail
474 (Printf.sprintf "Re-parse failed: %s" (Jsont.Error.to_string e))
475 | Ok feed2 ->
476 let unknown2 = Jsonfeed.unknown feed2 in
477 Alcotest.(check bool)
478 "unknown fields preserved" false
479 (Jsonfeed.Unknown.is_empty unknown2)))
480
481let test_hub_unknown_roundtrip () =
482 let json =
483 {|{
484 "type": "WebSub",
485 "url": "https://example.com/hub",
486 "custom_field": "test"
487 }|}
488 in
489 match Jsont_bytesrw.decode_string' Hub.jsont json with
490 | Error e ->
491 Alcotest.fail
492 (Printf.sprintf "Parse failed: %s" (Jsont.Error.to_string e))
493 | Ok hub -> (
494 let unknown = Hub.unknown hub in
495 Alcotest.(check bool)
496 "has unknown fields" false
497 (Jsonfeed.Unknown.is_empty unknown);
498 match Jsont_bytesrw.encode_string' Hub.jsont hub with
499 | Error e ->
500 Alcotest.fail
501 (Printf.sprintf "Encode failed: %s" (Jsont.Error.to_string e))
502 | Ok json2 -> (
503 match Jsont_bytesrw.decode_string' Hub.jsont json2 with
504 | Error e ->
505 Alcotest.fail
506 (Printf.sprintf "Re-parse failed: %s" (Jsont.Error.to_string e))
507 | Ok hub2 ->
508 let unknown2 = Hub.unknown hub2 in
509 Alcotest.(check bool)
510 "unknown fields preserved" false
511 (Jsonfeed.Unknown.is_empty unknown2)))
512
513let test_attachment_unknown_roundtrip () =
514 let json =
515 {|{
516 "url": "https://example.com/file.mp3",
517 "mime_type": "audio/mpeg",
518 "x_custom": "value"
519 }|}
520 in
521 match Jsont_bytesrw.decode_string' Attachment.jsont json with
522 | Error e ->
523 Alcotest.fail
524 (Printf.sprintf "Parse failed: %s" (Jsont.Error.to_string e))
525 | Ok att -> (
526 let unknown = Attachment.unknown att in
527 Alcotest.(check bool)
528 "has unknown fields" false
529 (Jsonfeed.Unknown.is_empty unknown);
530 match Jsont_bytesrw.encode_string' Attachment.jsont att with
531 | Error e ->
532 Alcotest.fail
533 (Printf.sprintf "Encode failed: %s" (Jsont.Error.to_string e))
534 | Ok json2 -> (
535 match Jsont_bytesrw.decode_string' Attachment.jsont json2 with
536 | Error e ->
537 Alcotest.fail
538 (Printf.sprintf "Re-parse failed: %s" (Jsont.Error.to_string e))
539 | Ok att2 ->
540 let unknown2 = Attachment.unknown att2 in
541 Alcotest.(check bool)
542 "unknown fields preserved" false
543 (Jsonfeed.Unknown.is_empty unknown2)))
544
545let unknown_fields_tests =
546 [
547 ("author unknown roundtrip", `Quick, test_author_unknown_roundtrip);
548 ("item unknown roundtrip", `Quick, test_item_unknown_roundtrip);
549 ("feed unknown roundtrip", `Quick, test_feed_unknown_roundtrip);
550 ("hub unknown roundtrip", `Quick, test_hub_unknown_roundtrip);
551 ("attachment unknown roundtrip", `Quick, test_attachment_unknown_roundtrip);
552 ]
553
554(* Main test suite *)
555
556let () =
557 Alcotest.run "jsonfeed"
558 [
559 ("Author", author_tests);
560 ("Attachment", attachment_tests);
561 ("Hub", hub_tests);
562 ("Item", item_tests);
563 ("Jsonfeed", jsonfeed_tests);
564 ("Unknown Fields", unknown_fields_tests);
565 ]