ocaml http/1, http/2 and websocket client and server library
1(** HCS Test Suite
2
3 This test suite covers:
4 - Codec module (identity, string codecs)
5 - Stream module (sync and async streams)
6 - Http module (request builder DSL)
7 - Router module (path parsing, route matching)
8 - Property-based tests using QCheck *)
9
10open Alcotest
11
12(* ================================================================== *)
13(* Codec Tests *)
14(* ================================================================== *)
15
16module Test_codec = struct
17 open Hcs.Codec
18
19 let test_identity_codec_encode () =
20 let encoder : Cstruct.t Identity_codec.encoder = Fun.id in
21 let input = Cstruct.of_string "hello world" in
22 match Identity_codec.encode encoder input with
23 | Ok result ->
24 check string "encode" "hello world" (Cstruct.to_string result)
25 | Error e -> fail e
26
27 let test_identity_codec_decode () =
28 let decoder : Cstruct.t Identity_codec.decoder = fun cs -> Ok cs in
29 let input = Cstruct.of_string "hello world" in
30 match Identity_codec.decode decoder input with
31 | Ok result ->
32 check string "decode" "hello world" (Cstruct.to_string result)
33 | Error e -> fail e
34
35 let test_identity_codec_content_type () =
36 check string "content_type" "application/octet-stream"
37 Identity_codec.content_type
38
39 let test_string_codec_encode () =
40 let encoder : string String_codec.encoder = Fun.id in
41 match String_codec.encode encoder "hello world" with
42 | Ok result ->
43 check string "encode" "hello world" (Cstruct.to_string result)
44 | Error e -> fail e
45
46 let test_string_codec_decode () =
47 let decoder : string String_codec.decoder = fun s -> Ok s in
48 let input = Cstruct.of_string "hello world" in
49 match String_codec.decode decoder input with
50 | Ok result -> check string "decode" "hello world" result
51 | Error e -> fail e
52
53 let test_string_codec_content_type () =
54 check string "content_type" "text/plain; charset=utf-8"
55 String_codec.content_type
56
57 let test_codec_error_to_string () =
58 check string "encode_error" "Encode error: test"
59 (codec_error_to_string (Encode_error "test"));
60 check string "decode_error" "Decode error: test"
61 (codec_error_to_string (Decode_error "test"));
62 check string "unsupported" "Unsupported body type for codec operation"
63 (codec_error_to_string Unsupported_body_type)
64
65 let test_with_codec_encode_body () =
66 let module W = With_codec (String_codec) in
67 let encoder : string String_codec.encoder = String.uppercase_ascii in
68 match W.encode_body encoder "hello" with
69 | Ok result -> check string "encode_body" "HELLO" result
70 | Error e -> fail (codec_error_to_string e)
71
72 let test_with_codec_decode_body () =
73 let module W = With_codec (String_codec) in
74 let decoder : string String_codec.decoder =
75 fun s -> Ok (String.uppercase_ascii s)
76 in
77 match W.decode_body decoder "hello" with
78 | Ok result -> check string "decode_body" "HELLO" result
79 | Error e -> fail (codec_error_to_string e)
80
81 let tests =
82 [
83 test_case "identity codec encode" `Quick test_identity_codec_encode;
84 test_case "identity codec decode" `Quick test_identity_codec_decode;
85 test_case "identity codec content_type" `Quick
86 test_identity_codec_content_type;
87 test_case "string codec encode" `Quick test_string_codec_encode;
88 test_case "string codec decode" `Quick test_string_codec_decode;
89 test_case "string codec content_type" `Quick
90 test_string_codec_content_type;
91 test_case "codec error to string" `Quick test_codec_error_to_string;
92 test_case "with_codec encode_body" `Quick test_with_codec_encode_body;
93 test_case "with_codec decode_body" `Quick test_with_codec_decode_body;
94 ]
95end
96
97(* ================================================================== *)
98(* Stream Tests *)
99(* ================================================================== *)
100
101module Test_stream = struct
102 open Hcs.Stream
103
104 (* Sync stream tests *)
105 let test_sync_empty () =
106 let s = Sync.empty in
107 check (list int) "empty" [] (Sync.to_list s)
108
109 let test_sync_singleton () =
110 let s = Sync.singleton 42 in
111 check (list int) "singleton" [ 42 ] (Sync.to_list s)
112
113 let test_sync_of_list () =
114 let s = Sync.of_list [ 1; 2; 3 ] in
115 check (list int) "of_list" [ 1; 2; 3 ] (Sync.to_list s)
116
117 let test_sync_map () =
118 let s = Sync.of_list [ 1; 2; 3 ] |> Sync.map (fun x -> x * 2) in
119 check (list int) "map" [ 2; 4; 6 ] (Sync.to_list s)
120
121 let test_sync_filter () =
122 let s =
123 Sync.of_list [ 1; 2; 3; 4; 5 ] |> Sync.filter (fun x -> x mod 2 = 0)
124 in
125 check (list int) "filter" [ 2; 4 ] (Sync.to_list s)
126
127 let test_sync_take () =
128 let s = Sync.of_list [ 1; 2; 3; 4; 5 ] |> Sync.take 3 in
129 check (list int) "take" [ 1; 2; 3 ] (Sync.to_list s)
130
131 let test_sync_drop () =
132 let s = Sync.of_list [ 1; 2; 3; 4; 5 ] |> Sync.drop 2 in
133 check (list int) "drop" [ 3; 4; 5 ] (Sync.to_list s)
134
135 let test_sync_fold () =
136 let result = Sync.of_list [ 1; 2; 3; 4; 5 ] |> Sync.fold ( + ) 0 in
137 check int "fold" 15 result
138
139 let test_sync_chunks () =
140 let s = Sync.of_list [ 1; 2; 3; 4; 5 ] |> Sync.chunks 2 in
141 check
142 (list (list int))
143 "chunks"
144 [ [ 1; 2 ]; [ 3; 4 ]; [ 5 ] ]
145 (Sync.to_list s)
146
147 let test_sync_repeat () =
148 let s = Sync.repeat 3 "x" in
149 check (list string) "repeat" [ "x"; "x"; "x" ] (Sync.to_list s)
150
151 let test_sync_unfold () =
152 let s = Sync.unfold (fun n -> if n > 0 then Some (n, n - 1) else None) 3 in
153 check (list int) "unfold" [ 3; 2; 1 ] (Sync.to_list s)
154
155 let test_sync_append () =
156 let s1 = Sync.of_list [ 1; 2 ] in
157 let s2 = Sync.of_list [ 3; 4 ] in
158 let s = Sync.append s1 s2 in
159 check (list int) "append" [ 1; 2; 3; 4 ] (Sync.to_list s)
160
161 let test_sync_cstructs_to_string () =
162 let s =
163 Sync.of_list
164 [
165 Cstruct.of_string "hello";
166 Cstruct.of_string " ";
167 Cstruct.of_string "world";
168 ]
169 in
170 check string "cstructs_to_string" "hello world" (Sync.cstructs_to_string s)
171
172 let test_sync_string_to_cstructs () =
173 let s = Sync.string_to_cstructs ~chunk_size:5 "hello world" in
174 let result =
175 s |> Sync.map Cstruct.to_string |> Sync.to_list |> String.concat ""
176 in
177 check string "string_to_cstructs" "hello world" result
178
179 (* Async stream tests *)
180 let test_async_empty () =
181 let s = Async.empty in
182 check (list int) "async empty" [] (Async.to_list s)
183
184 let test_async_singleton () =
185 let s = Async.singleton 42 in
186 check (list int) "async singleton" [ 42 ] (Async.to_list s)
187
188 let test_async_of_list () =
189 let s = Async.of_list [ 1; 2; 3 ] in
190 check (list int) "async of_list" [ 1; 2; 3 ] (Async.to_list s)
191
192 let test_async_map () =
193 let s = Async.of_list [ 1; 2; 3 ] in
194 let mapped = Async.map (fun x -> x * 2) s in
195 check (list int) "async map" [ 2; 4; 6 ] (Async.to_list mapped)
196
197 let test_async_filter () =
198 let s = Async.of_list [ 1; 2; 3; 4; 5 ] in
199 let filtered = Async.filter (fun x -> x mod 2 = 0) s in
200 check (list int) "async filter" [ 2; 4 ] (Async.to_list filtered)
201
202 let test_async_take () =
203 let s = Async.of_list [ 1; 2; 3; 4; 5 ] in
204 let taken = Async.take 3 s in
205 check (list int) "async take" [ 1; 2; 3 ] (Async.to_list taken)
206
207 let test_async_fold () =
208 let s = Async.of_list [ 1; 2; 3; 4; 5 ] in
209 let result = Async.fold ( + ) 0 s in
210 check int "async fold" 15 result
211
212 (* Chunked encoding tests *)
213 let test_chunked_encode () =
214 let chunks = Sync.of_list [ Cstruct.of_string "hello" ] in
215 let encoded = Chunked.encode chunks in
216 let result = Sync.cstructs_to_string encoded in
217 (* 5 in hex is "5", chunk format: "5\r\nhello\r\n" followed by terminator "0\r\n\r\n" *)
218 check string "chunked encode" "5\r\nhello\r\n0\r\n\r\n" result
219
220 let test_chunked_content_length () =
221 let chunks =
222 Sync.of_list [ Cstruct.of_string "hello"; Cstruct.of_string " world" ]
223 in
224 let len = Chunked.content_length chunks in
225 check int "chunked content_length" 11 len
226
227 let tests =
228 [
229 (* Sync tests *)
230 test_case "sync empty" `Quick test_sync_empty;
231 test_case "sync singleton" `Quick test_sync_singleton;
232 test_case "sync of_list" `Quick test_sync_of_list;
233 test_case "sync map" `Quick test_sync_map;
234 test_case "sync filter" `Quick test_sync_filter;
235 test_case "sync take" `Quick test_sync_take;
236 test_case "sync drop" `Quick test_sync_drop;
237 test_case "sync fold" `Quick test_sync_fold;
238 test_case "sync chunks" `Quick test_sync_chunks;
239 test_case "sync repeat" `Quick test_sync_repeat;
240 test_case "sync unfold" `Quick test_sync_unfold;
241 test_case "sync append" `Quick test_sync_append;
242 test_case "sync cstructs_to_string" `Quick test_sync_cstructs_to_string;
243 test_case "sync string_to_cstructs" `Quick test_sync_string_to_cstructs;
244 (* Async tests *)
245 test_case "async empty" `Quick test_async_empty;
246 test_case "async singleton" `Quick test_async_singleton;
247 test_case "async of_list" `Quick test_async_of_list;
248 test_case "async map" `Quick test_async_map;
249 test_case "async filter" `Quick test_async_filter;
250 test_case "async take" `Quick test_async_take;
251 test_case "async fold" `Quick test_async_fold;
252 (* Chunked tests *)
253 test_case "chunked encode" `Quick test_chunked_encode;
254 test_case "chunked content_length" `Quick test_chunked_content_length;
255 ]
256end
257
258(* ================================================================== *)
259(* HTTP Request Builder Tests *)
260(* ================================================================== *)
261
262module Test_http = struct
263 open Hcs.Http
264
265 let test_get () =
266 let req = get "https://example.com/path" |> build in
267 check string "method" "GET" (meth_to_string req.req_meth);
268 check string "host" "example.com" (host req);
269 check string "path" "/path" (path req);
270 check bool "is_https" true (is_https req)
271
272 let test_post () =
273 let req = post "http://example.com/api" |> build in
274 check string "method" "POST" (meth_to_string req.req_meth);
275 check bool "is_https" false (is_https req)
276
277 let test_put () =
278 let req = put "http://example.com/resource" |> build in
279 check string "method" "PUT" (meth_to_string req.req_meth)
280
281 let test_delete () =
282 let req = delete "http://example.com/resource/1" |> build in
283 check string "method" "DELETE" (meth_to_string req.req_meth)
284
285 let test_patch () =
286 let req = patch "http://example.com/resource/1" |> build in
287 check string "method" "PATCH" (meth_to_string req.req_meth)
288
289 let test_head () =
290 let req = head "http://example.com/" |> build in
291 check string "method" "HEAD" (meth_to_string req.req_meth)
292
293 let test_options () =
294 let req = options "http://example.com/" |> build in
295 check string "method" "OPTIONS" (meth_to_string req.req_meth)
296
297 let test_header () =
298 let req =
299 get "http://example.com" |> header "X-Custom" "value"
300 |> header "X-Another" "another"
301 |> build
302 in
303 check bool "has X-Custom"
304 (List.mem ("X-Custom", "value") req.req_headers)
305 true;
306 check bool "has X-Another"
307 (List.mem ("X-Another", "another") req.req_headers)
308 true
309
310 let test_content_type () =
311 let req =
312 post "http://example.com" |> content_type "application/json" |> build
313 in
314 check bool "has Content-Type"
315 (List.mem ("Content-Type", "application/json") req.req_headers)
316 true
317
318 let test_accept () =
319 let req = get "http://example.com" |> accept "text/html" |> build in
320 check bool "has Accept"
321 (List.mem ("Accept", "text/html") req.req_headers)
322 true
323
324 let test_user_agent () =
325 let req = get "http://example.com" |> user_agent "HCS/1.0" |> build in
326 check bool "has User-Agent"
327 (List.mem ("User-Agent", "HCS/1.0") req.req_headers)
328 true
329
330 let test_bearer () =
331 let req = get "http://example.com" |> bearer "token123" |> build in
332 check bool "has Authorization"
333 (List.mem ("Authorization", "Bearer token123") req.req_headers)
334 true
335
336 let test_query () =
337 let req =
338 get "http://example.com/search"
339 |> query "q" "ocaml" |> query "limit" "10" |> build
340 in
341 let pq = path_and_query req in
342 check bool "has q param" (String.length pq > 0) true
343
344 let test_body_string () =
345 let req =
346 post "http://example.com"
347 |> body_string ~content_type:"text/plain" "hello"
348 |> build
349 in
350 check string "body" "hello" (body_to_string req.req_body);
351 check bool "has Content-Type"
352 (List.mem ("Content-Type", "text/plain") req.req_headers)
353 true
354
355 let test_body_json () =
356 let req =
357 post "http://example.com" |> body_json {|{"key": "value"}|} |> build
358 in
359 check string "body" {|{"key": "value"}|} (body_to_string req.req_body);
360 check bool "has Content-Type"
361 (List.mem ("Content-Type", "application/json") req.req_headers)
362 true
363
364 let test_form () =
365 let req =
366 post "http://example.com"
367 |> form [ ("name", "Alice"); ("age", "30") ]
368 |> build
369 in
370 let body_str = body_to_string req.req_body in
371 check bool "has name" (String.length body_str > 0) true;
372 check bool "has Content-Type"
373 (List.mem
374 ("Content-Type", "application/x-www-form-urlencoded")
375 req.req_headers)
376 true
377
378 let test_port_default_http () =
379 let req = get "http://example.com/" |> build in
380 check int "port" 80 (port req)
381
382 let test_port_default_https () =
383 let req = get "https://example.com/" |> build in
384 check int "port" 443 (port req)
385
386 let test_port_explicit () =
387 let req = get "http://example.com:8080/" |> build in
388 check int "port" 8080 (port req)
389
390 let test_meth_of_string () =
391 check string "GET" "GET" (meth_to_string (meth_of_string "GET"));
392 check string "POST" "POST" (meth_to_string (meth_of_string "POST"));
393 check string "CUSTOM" "CUSTOM" (meth_to_string (meth_of_string "CUSTOM"))
394
395 let test_body_length () =
396 check int "empty body" 0 (body_length Empty);
397 check int "string body" 5 (body_length (String "hello"));
398 check int "form body" (String.length "a=1&b=2")
399 (body_length (Form [ ("a", "1"); ("b", "2") ]))
400
401 let test_cookie () =
402 let req = get "http://example.com" |> cookie "session" "abc123" |> build in
403 check bool "has Cookie"
404 (List.exists
405 (fun (n, v) -> n = "Cookie" && String.length v > 0)
406 req.req_headers)
407 true
408
409 let test_cookies_multiple () =
410 let req =
411 get "http://example.com" |> cookie "a" "1" |> cookie "b" "2" |> build
412 in
413 let cookie_header =
414 List.find_opt (fun (n, _) -> n = "Cookie") req.req_headers
415 in
416 match cookie_header with
417 | Some (_, v) -> check bool "has a" (String.length v > 0) true
418 | None -> fail "no Cookie header"
419
420 let tests =
421 [
422 test_case "get" `Quick test_get;
423 test_case "post" `Quick test_post;
424 test_case "put" `Quick test_put;
425 test_case "delete" `Quick test_delete;
426 test_case "patch" `Quick test_patch;
427 test_case "head" `Quick test_head;
428 test_case "options" `Quick test_options;
429 test_case "header" `Quick test_header;
430 test_case "content_type" `Quick test_content_type;
431 test_case "accept" `Quick test_accept;
432 test_case "user_agent" `Quick test_user_agent;
433 test_case "bearer" `Quick test_bearer;
434 test_case "query" `Quick test_query;
435 test_case "body_string" `Quick test_body_string;
436 test_case "body_json" `Quick test_body_json;
437 test_case "form" `Quick test_form;
438 test_case "port default http" `Quick test_port_default_http;
439 test_case "port default https" `Quick test_port_default_https;
440 test_case "port explicit" `Quick test_port_explicit;
441 test_case "meth_of_string" `Quick test_meth_of_string;
442 test_case "body_length" `Quick test_body_length;
443 test_case "cookie" `Quick test_cookie;
444 test_case "cookies multiple" `Quick test_cookies_multiple;
445 ]
446end
447
448module Test_pipeline = struct
449 open Hcs.Pipeline
450
451 let test_empty () = check int "empty length" 0 (length empty)
452
453 let test_create () =
454 let p = create [ Fun.id; Fun.id ] in
455 check int "create length" 2 (length p)
456
457 let test_plug () =
458 let p = empty |> fun t -> plug t Fun.id in
459 check int "plug length" 1 (length p)
460
461 let test_plug_first () =
462 let p = empty |> fun t -> plug_first t Fun.id in
463 check int "plug_first length" 1 (length p)
464
465 let test_compose () =
466 let p1 = create [ Fun.id ] in
467 let p2 = create [ Fun.id; Fun.id ] in
468 let combined = compose p1 p2 in
469 check int "compose length" 3 (length combined)
470
471 let test_is_empty () =
472 check bool "empty is empty" true (is_empty empty);
473 check bool "non-empty not empty" false (is_empty (create [ Fun.id ]))
474
475 let test_apply () =
476 let called = ref false in
477 let mark_plug : Hcs.Plug.t =
478 fun handler req ->
479 called := true;
480 handler req
481 in
482 let p = create [ mark_plug ] in
483 let dummy_req : Hcs.Server.request =
484 { meth = `GET; target = "/"; headers = []; body = ""; version = HTTP_1_1 }
485 in
486 let handler _req = Hcs.Server.respond "ok" in
487 let wrapped = apply p handler in
488 let _ = wrapped dummy_req in
489 check bool "plug was called" true !called
490
491 let tests =
492 [
493 test_case "empty" `Quick test_empty;
494 test_case "create" `Quick test_create;
495 test_case "plug" `Quick test_plug;
496 test_case "plug_first" `Quick test_plug_first;
497 test_case "compose" `Quick test_compose;
498 test_case "is_empty" `Quick test_is_empty;
499 test_case "apply" `Quick test_apply;
500 ]
501end
502
503module Test_router = struct
504 open Hcs.Router
505
506 let test_parse_path_empty () =
507 check (list string) "empty" []
508 (List.map
509 (function Literal s -> s | Param s -> ":" ^ s | Wildcard -> "*")
510 (parse_path ""))
511
512 let test_parse_path_root () =
513 check (list string) "root" []
514 (List.map
515 (function Literal s -> s | Param s -> ":" ^ s | Wildcard -> "*")
516 (parse_path "/"))
517
518 let test_parse_path_simple () =
519 let segments = parse_path "/users/list" in
520 check int "length" 2 (List.length segments);
521 match segments with
522 | [ Literal "users"; Literal "list" ] -> ()
523 | _ -> fail "unexpected segments"
524
525 let test_parse_path_with_params () =
526 let segments = parse_path "/users/:id/posts/:post_id" in
527 check int "length" 4 (List.length segments);
528 match segments with
529 | [ Literal "users"; Param "id"; Literal "posts"; Param "post_id" ] -> ()
530 | _ -> fail "unexpected segments"
531
532 let test_parse_path_with_wildcard () =
533 let segments = parse_path "/files/*" in
534 check int "length" 2 (List.length segments);
535 match segments with
536 | [ Literal "files"; Wildcard ] -> ()
537 | _ -> fail "unexpected segments"
538
539 let test_router_literal_match () =
540 let router = empty () in
541 add_route router ~method_:(Some `GET) ~path:"/users"
542 ~handler:"users_handler" ~plugs:[];
543 match lookup router ~method_:`GET ~path:"/users" with
544 | Some { handler; _ } -> check string "handler" "users_handler" handler
545 | None -> fail "no match"
546
547 let test_router_param_match () =
548 let router = empty () in
549 add_route router ~method_:(Some `GET) ~path:"/users/:id"
550 ~handler:"user_handler" ~plugs:[];
551 match lookup router ~method_:`GET ~path:"/users/123" with
552 | Some { handler; params; _ } ->
553 check string "handler" "user_handler" handler;
554 check (option string) "id param" (Some "123") (param "id" params)
555 | None -> fail "no match"
556
557 let test_router_multiple_params () =
558 let router = empty () in
559 add_route router ~method_:(Some `GET) ~path:"/users/:user_id/posts/:post_id"
560 ~handler:"post_handler" ~plugs:[];
561 match lookup router ~method_:`GET ~path:"/users/42/posts/100" with
562 | Some { handler; params; _ } ->
563 check string "handler" "post_handler" handler;
564 check (option string) "user_id" (Some "42") (param "user_id" params);
565 check (option string) "post_id" (Some "100") (param "post_id" params)
566 | None -> fail "no match"
567
568 let test_router_wildcard_match () =
569 let router = empty () in
570 add_route router ~method_:(Some `GET) ~path:"/files/*"
571 ~handler:"files_handler" ~plugs:[];
572 match lookup router ~method_:`GET ~path:"/files/path/to/file.txt" with
573 | Some { handler; params; _ } ->
574 check string "handler" "files_handler" handler;
575 check (option string) "wildcard" (Some "path/to/file.txt")
576 (param "*" params)
577 | None -> fail "no match"
578
579 let test_router_method_match () =
580 let router = empty () in
581 add_route router ~method_:(Some `GET) ~path:"/users" ~handler:"get_handler"
582 ~plugs:[];
583 add_route router ~method_:(Some `POST) ~path:"/users"
584 ~handler:"post_handler" ~plugs:[];
585 (match lookup router ~method_:`GET ~path:"/users" with
586 | Some { handler; _ } -> check string "GET handler" "get_handler" handler
587 | None -> fail "no GET match");
588 match lookup router ~method_:`POST ~path:"/users" with
589 | Some { handler; _ } -> check string "POST handler" "post_handler" handler
590 | None -> fail "no POST match"
591
592 let test_router_any_method () =
593 let router = empty () in
594 add_route router ~method_:None ~path:"/health" ~handler:"health_handler"
595 ~plugs:[];
596 (match lookup router ~method_:`GET ~path:"/health" with
597 | Some { handler; _ } -> check string "GET" "health_handler" handler
598 | None -> fail "no GET match");
599 match lookup router ~method_:`POST ~path:"/health" with
600 | Some { handler; _ } -> check string "POST" "health_handler" handler
601 | None -> fail "no POST match"
602
603 let test_router_no_match () =
604 let router = empty () in
605 add_route router ~method_:(Some `GET) ~path:"/users" ~handler:"handler"
606 ~plugs:[];
607 match lookup router ~method_:`GET ~path:"/posts" with
608 | Some _ -> fail "unexpected match"
609 | None -> ()
610
611 let test_router_compile () =
612 let routes =
613 [
614 Route.get "/users" "list_users";
615 Route.post "/users" "create_user";
616 Route.get "/users/:id" "get_user";
617 Route.delete "/users/:id" "delete_user";
618 ]
619 in
620 let router = compile routes in
621 (match lookup router ~method_:`GET ~path:"/users" with
622 | Some { handler; _ } -> check string "list" "list_users" handler
623 | None -> fail "no list match");
624 match lookup router ~method_:`GET ~path:"/users/42" with
625 | Some { handler; params; _ } ->
626 check string "get" "get_user" handler;
627 check (option string) "id" (Some "42") (param "id" params)
628 | None -> fail "no get match"
629
630 let test_param_helpers () =
631 let params = [ ("id", "42"); ("name", "alice") ] in
632 check (option string) "param" (Some "42") (param "id" params);
633 check (option string) "param missing" None (param "foo" params);
634 check string "param_or" "default" (param_or "foo" ~default:"default" params);
635 check (option int) "param_int" (Some 42) (param_int "id" params);
636 check int "param_int_or" 0 (param_int_or "foo" ~default:0 params)
637
638 let test_scope_prefix () =
639 let routes = scope "/api" [ Route.get "/users" "list_users" ] in
640 let router = compile routes in
641 match lookup router ~method_:`GET ~path:"/api/users" with
642 | Some { handler; _ } -> check string "scoped handler" "list_users" handler
643 | None -> fail "no match for scoped route"
644
645 let test_scope_root_prefix () =
646 let routes = scope "/" [ Route.get "/users" "list_users" ] in
647 let router = compile routes in
648 match lookup router ~method_:`GET ~path:"/users" with
649 | Some { handler; _ } -> check string "root scope" "list_users" handler
650 | None -> fail "no match"
651
652 let test_scope_nested_path () =
653 let routes = scope "/api/v1" [ Route.get "/users/:id" "get_user" ] in
654 let router = compile routes in
655 match lookup router ~method_:`GET ~path:"/api/v1/users/42" with
656 | Some { handler; params; _ } ->
657 check string "nested scope" "get_user" handler;
658 check (option string) "id param" (Some "42") (param "id" params)
659 | None -> fail "no match"
660
661 let test_compile_scopes () =
662 let api_routes = scope "/api" [ Route.get "/posts" "list_posts" ] in
663 let web_routes = scope "/" [ Route.get "/" "home" ] in
664 let router = compile_scopes [ api_routes; web_routes ] in
665 (match lookup router ~method_:`GET ~path:"/api/posts" with
666 | Some { handler; _ } -> check string "api route" "list_posts" handler
667 | None -> fail "no api match");
668 match lookup router ~method_:`GET ~path:"/" with
669 | Some { handler; _ } -> check string "web route" "home" handler
670 | None -> fail "no web match"
671
672 let test_route_with_plug () =
673 let dummy_plug : Hcs.Plug.t = fun handler -> handler in
674 let base_route = Route.get "/admin" "admin_handler" in
675 let route = Route.plug dummy_plug base_route in
676 let router = compile [ route ] in
677 match lookup router ~method_:`GET ~path:"/admin" with
678 | Some { handler; plugs; _ } ->
679 check string "handler" "admin_handler" handler;
680 check int "has plugs" 1 (List.length plugs)
681 | None -> fail "no match"
682
683 let tests =
684 [
685 test_case "parse_path empty" `Quick test_parse_path_empty;
686 test_case "parse_path root" `Quick test_parse_path_root;
687 test_case "parse_path simple" `Quick test_parse_path_simple;
688 test_case "parse_path with params" `Quick test_parse_path_with_params;
689 test_case "parse_path with wildcard" `Quick test_parse_path_with_wildcard;
690 test_case "router literal match" `Quick test_router_literal_match;
691 test_case "router param match" `Quick test_router_param_match;
692 test_case "router multiple params" `Quick test_router_multiple_params;
693 test_case "router wildcard match" `Quick test_router_wildcard_match;
694 test_case "router method match" `Quick test_router_method_match;
695 test_case "router any method" `Quick test_router_any_method;
696 test_case "router no match" `Quick test_router_no_match;
697 test_case "router compile" `Quick test_router_compile;
698 test_case "param helpers" `Quick test_param_helpers;
699 test_case "scope prefix" `Quick test_scope_prefix;
700 test_case "scope root prefix" `Quick test_scope_root_prefix;
701 test_case "scope nested path" `Quick test_scope_nested_path;
702 test_case "compile scopes" `Quick test_compile_scopes;
703 test_case "route with plug" `Quick test_route_with_plug;
704 ]
705end
706
707(* ================================================================== *)
708(* Log Tests *)
709(* ================================================================== *)
710
711module Test_log = struct
712 open Hcs.Log
713
714 let test_level_to_string () =
715 check string "debug" "DEBUG" (level_to_string Debug);
716 check string "info" "INFO" (level_to_string Info);
717 check string "warn" "WARN" (level_to_string Warn);
718 check string "error" "ERROR" (level_to_string Error)
719
720 let test_level_of_string () =
721 check bool "DEBUG" true (level_of_string "DEBUG" = Some Debug);
722 check bool "debug" true (level_of_string "debug" = Some Debug);
723 check bool "INFO" true (level_of_string "INFO" = Some Info);
724 check bool "WARN" true (level_of_string "WARN" = Some Warn);
725 check bool "unknown" true (level_of_string "unknown" = None)
726
727 let test_level_gte () =
728 check bool "error >= debug" true (level_gte Error Debug);
729 check bool "error >= error" true (level_gte Error Error);
730 check bool "debug >= error" false (level_gte Debug Error);
731 check bool "warn >= info" true (level_gte Warn Info)
732
733 let test_method_to_string () =
734 check string "GET" "GET" (method_to_string GET);
735 check string "POST" "POST" (method_to_string POST);
736 check string "Other" "CUSTOM" (method_to_string (Other "CUSTOM"))
737
738 let test_event_to_string_request_start () =
739 let e =
740 Request_start { id = "req-1"; meth = GET; uri = "/test"; headers = [] }
741 in
742 let s = event_to_string e in
743 check bool "contains id" (String.length s > 0) true;
744 check bool "contains GET" true (String.length s > 0)
745
746 let test_event_to_string_request_end () =
747 let e =
748 Request_end
749 {
750 id = "req-1";
751 status = 200;
752 duration_ms = 42.5;
753 body_size = Some 1024;
754 }
755 in
756 let s = event_to_string e in
757 check bool "contains status" (String.length s > 0) true
758
759 let test_event_to_string_error () =
760 let e =
761 Error
762 { id = Some "req-1"; error = "connection failed"; context = Some "TLS" }
763 in
764 let s = event_to_string e in
765 check bool "contains error" (String.length s > 0) true
766
767 let test_event_to_json_request_start () =
768 let e =
769 Request_start { id = "req-1"; meth = GET; uri = "/test"; headers = [] }
770 in
771 let j = event_to_json e in
772 check bool "is json" true (String.sub j 0 1 = "{");
773 check bool "contains event" true (String.length j > 10)
774
775 let test_event_to_json_custom () =
776 let e = Custom { name = "test_event"; data = [ ("key", "value") ] } in
777 let j = event_to_json e in
778 check bool "is json" true (String.sub j 0 1 = "{");
779 check bool "contains name" true (String.length j > 10)
780
781 let test_null_logger () =
782 (* Should not raise *)
783 null Info
784 (Request_start { id = "req-1"; meth = GET; uri = "/"; headers = [] });
785 null Error (Error { id = None; error = "test"; context = None })
786
787 let test_custom_logger () =
788 let logged = ref [] in
789 let logger = custom (fun level msg -> logged := (level, msg) :: !logged) in
790 logger Info
791 (Request_start { id = "req-1"; meth = GET; uri = "/"; headers = [] });
792 check int "logged count" 1 (List.length !logged)
793
794 let test_combine_loggers () =
795 let count1 = ref 0 in
796 let count2 = ref 0 in
797 let logger1 = custom (fun _ _ -> incr count1) in
798 let logger2 = custom (fun _ _ -> incr count2) in
799 let combined = combine [ logger1; logger2 ] in
800 combined Info (Custom { name = "test"; data = [] });
801 check int "logger1 count" 1 !count1;
802 check int "logger2 count" 1 !count2
803
804 let test_with_min_level () =
805 let logged = ref [] in
806 let base = custom (fun level msg -> logged := (level, msg) :: !logged) in
807 let filtered = with_min_level Warn base in
808 filtered Debug (Custom { name = "debug"; data = [] });
809 filtered Info (Custom { name = "info"; data = [] });
810 filtered Warn (Custom { name = "warn"; data = [] });
811 filtered Error (Custom { name = "error"; data = [] });
812 check int "logged count" 2 (List.length !logged)
813
814 let test_generate_request_id () =
815 let id1 = generate_request_id () in
816 let id2 = generate_request_id () in
817 check bool "starts with req-" true (String.sub id1 0 4 = "req-");
818 check bool "unique" true (id1 <> id2)
819
820 let tests =
821 [
822 test_case "level_to_string" `Quick test_level_to_string;
823 test_case "level_of_string" `Quick test_level_of_string;
824 test_case "level_gte" `Quick test_level_gte;
825 test_case "method_to_string" `Quick test_method_to_string;
826 test_case "event_to_string request_start" `Quick
827 test_event_to_string_request_start;
828 test_case "event_to_string request_end" `Quick
829 test_event_to_string_request_end;
830 test_case "event_to_string error" `Quick test_event_to_string_error;
831 test_case "event_to_json request_start" `Quick
832 test_event_to_json_request_start;
833 test_case "event_to_json custom" `Quick test_event_to_json_custom;
834 test_case "null logger" `Quick test_null_logger;
835 test_case "custom logger" `Quick test_custom_logger;
836 test_case "combine loggers" `Quick test_combine_loggers;
837 test_case "with_min_level" `Quick test_with_min_level;
838 test_case "generate_request_id" `Quick test_generate_request_id;
839 ]
840end
841
842module Test_tls_config = struct
843 open Hcs.Tls_config
844
845 let test_alpn_constants () =
846 check string "h2" "h2" alpn_h2;
847 check string "http/1.1" "http/1.1" alpn_http11;
848 check string "h2c" "h2c" alpn_h2c
849
850 let test_protocol_of_alpn () =
851 check (option string) "h2 -> HTTP_2" (Some "HTTP_2")
852 (Option.map
853 (function HTTP_2 -> "HTTP_2" | HTTP_1_1 -> "HTTP_1_1")
854 (protocol_of_alpn "h2"));
855 check (option string) "http/1.1 -> HTTP_1_1" (Some "HTTP_1_1")
856 (Option.map
857 (function HTTP_2 -> "HTTP_2" | HTTP_1_1 -> "HTTP_1_1")
858 (protocol_of_alpn "http/1.1"));
859 check (option string) "unknown -> None" None
860 (Option.map
861 (function HTTP_2 -> "HTTP_2" | HTTP_1_1 -> "HTTP_1_1")
862 (protocol_of_alpn "unknown"))
863
864 let test_alpn_of_protocol () =
865 check string "HTTP_2 -> h2" "h2" (alpn_of_protocol HTTP_2);
866 check string "HTTP_1_1 -> http/1.1" "http/1.1" (alpn_of_protocol HTTP_1_1)
867
868 let test_client_configs () =
869 check
870 (option (list string))
871 "default has http/1.1" (Some [ "http/1.1" ]) Client.default.alpn_protocols;
872 check
873 (option (list string))
874 "h2 has h2" (Some [ "h2" ]) Client.h2.alpn_protocols;
875 check
876 (option (list string))
877 "h2_or_http11 has both"
878 (Some [ "h2"; "http/1.1" ])
879 Client.h2_or_http11.alpn_protocols
880
881 let tests =
882 [
883 test_case "alpn constants" `Quick test_alpn_constants;
884 test_case "protocol_of_alpn" `Quick test_protocol_of_alpn;
885 test_case "alpn_of_protocol" `Quick test_alpn_of_protocol;
886 test_case "client configs" `Quick test_client_configs;
887 ]
888end
889
890(* ================================================================== *)
891(* Request Tests *)
892(* ================================================================== *)
893
894module Test_request = struct
895 open Hcs
896
897 let make_request ?(body = "") target : Server.request =
898 { meth = `GET; target; headers = []; body; version = Server.HTTP_1_1 }
899
900 let test_query_basic () =
901 let req = make_request "/search?q=ocaml&limit=10" in
902 check (option string) "q" (Some "ocaml") (Request.query req "q");
903 check (option string) "limit" (Some "10") (Request.query req "limit");
904 check (option string) "missing" None (Request.query req "missing")
905
906 let test_query_or () =
907 let req = make_request "/search?q=ocaml" in
908 check string "existing" "ocaml" (Request.query_or ~default:"" req "q");
909 check string "missing" "default"
910 (Request.query_or ~default:"default" req "x")
911
912 let test_query_int () =
913 let req = make_request "/page?num=42&bad=abc" in
914 check (option int) "valid int" (Some 42) (Request.query_int req "num");
915 check (option int) "invalid int" None (Request.query_int req "bad");
916 check (option int) "missing" None (Request.query_int req "missing")
917
918 let test_query_int_or () =
919 let req = make_request "/page?num=42" in
920 check int "existing" 42 (Request.query_int_or ~default:0 req "num");
921 check int "missing" 1 (Request.query_int_or ~default:1 req "page")
922
923 let test_query_bool () =
924 let req =
925 make_request "/flags?a=true&b=1&c=yes&d=on&e=false&f=0&g=no&h=off&i=bad"
926 in
927 check (option bool) "true" (Some true) (Request.query_bool req "a");
928 check (option bool) "1" (Some true) (Request.query_bool req "b");
929 check (option bool) "yes" (Some true) (Request.query_bool req "c");
930 check (option bool) "on" (Some true) (Request.query_bool req "d");
931 check (option bool) "false" (Some false) (Request.query_bool req "e");
932 check (option bool) "0" (Some false) (Request.query_bool req "f");
933 check (option bool) "no" (Some false) (Request.query_bool req "g");
934 check (option bool) "off" (Some false) (Request.query_bool req "h");
935 check (option bool) "bad" None (Request.query_bool req "i")
936
937 let test_query_float () =
938 let req = make_request "/calc?x=3.14&y=-2.5&bad=abc" in
939 check
940 (option (float 0.001))
941 "valid float" (Some 3.14)
942 (Request.query_float req "x");
943 check
944 (option (float 0.001))
945 "negative" (Some (-2.5))
946 (Request.query_float req "y");
947 check (option (float 0.001)) "invalid" None (Request.query_float req "bad")
948
949 let test_query_all () =
950 let req = make_request "/tags?t=a&t=b&t=c" in
951 check (list string) "all values" [ "a"; "b"; "c" ]
952 (Request.query_all req "t")
953
954 let test_query_url_decode () =
955 let req = make_request "/search?q=hello%20world&name=foo%2Bbar" in
956 check (option string) "space" (Some "hello world") (Request.query req "q");
957 check (option string) "plus encoded" (Some "foo+bar")
958 (Request.query req "name")
959
960 let test_query_empty () =
961 let req = make_request "/path" in
962 check (option string) "no query string" None (Request.query req "q");
963 check
964 (list (pair string string))
965 "empty params" []
966 (Request.query_params_from_target "/path")
967
968 let test_form_field_basic () =
969 let req = make_request ~body:"name=alice&age=30" "/" in
970 check (option string) "name" (Some "alice") (Request.form_field req "name");
971 check (option string) "age" (Some "30") (Request.form_field req "age");
972 check (option string) "missing" None (Request.form_field req "missing")
973
974 let test_form_field_or () =
975 let req = make_request ~body:"name=bob" "/" in
976 check string "existing" "bob" (Request.form_field_or ~default:"" req "name");
977 check string "missing" "unknown"
978 (Request.form_field_or ~default:"unknown" req "email")
979
980 let test_form_int () =
981 let req = make_request ~body:"count=42&bad=xyz" "/" in
982 check (option int) "valid" (Some 42) (Request.form_int req "count");
983 check (option int) "invalid" None (Request.form_int req "bad");
984 check int "with default" 0 (Request.form_int_or ~default:0 req "missing")
985
986 let test_form_plus_as_space () =
987 let req = make_request ~body:"msg=hello+world&name=foo%2Bbar" "/" in
988 check (option string) "plus becomes space" (Some "hello world")
989 (Request.form_field req "msg");
990 check (option string) "encoded plus" (Some "foo+bar")
991 (Request.form_field req "name")
992
993 let test_form_url_decode () =
994 let req = make_request ~body:"text=hello%20world&special=%26%3D%3F" "/" in
995 check (option string) "percent space" (Some "hello world")
996 (Request.form_field req "text");
997 check (option string) "special chars" (Some "&=?")
998 (Request.form_field req "special")
999
1000 let test_form_empty_body () =
1001 let req = make_request ~body:"" "/" in
1002 check (option string) "empty body" None (Request.form_field req "anything");
1003 check
1004 (list (pair string string))
1005 "empty form_data" []
1006 (Request.form_data_from_body "")
1007
1008 let tests =
1009 [
1010 test_case "query basic" `Quick test_query_basic;
1011 test_case "query_or" `Quick test_query_or;
1012 test_case "query_int" `Quick test_query_int;
1013 test_case "query_int_or" `Quick test_query_int_or;
1014 test_case "query_bool" `Quick test_query_bool;
1015 test_case "query_float" `Quick test_query_float;
1016 test_case "query_all" `Quick test_query_all;
1017 test_case "query url decode" `Quick test_query_url_decode;
1018 test_case "query empty" `Quick test_query_empty;
1019 test_case "form_field basic" `Quick test_form_field_basic;
1020 test_case "form_field_or" `Quick test_form_field_or;
1021 test_case "form_int" `Quick test_form_int;
1022 test_case "form plus as space" `Quick test_form_plus_as_space;
1023 test_case "form url decode" `Quick test_form_url_decode;
1024 test_case "form empty body" `Quick test_form_empty_body;
1025 ]
1026end
1027
1028(* ================================================================== *)
1029(* Property-Based Tests (QCheck) *)
1030(* ================================================================== *)
1031
1032module Test_properties = struct
1033 (* Stream properties *)
1034 let prop_sync_map_identity =
1035 QCheck.Test.make ~name:"sync map identity" ~count:100
1036 QCheck.(list int)
1037 (fun l ->
1038 let open Hcs.Stream.Sync in
1039 let s = of_list l in
1040 to_list (map Fun.id s) = l)
1041
1042 let prop_sync_map_composition =
1043 QCheck.Test.make ~name:"sync map composition" ~count:100
1044 QCheck.(list nat_small)
1045 (fun l ->
1046 let open Hcs.Stream.Sync in
1047 let f x = x + 1 in
1048 let g x = x * 2 in
1049 let s = of_list l in
1050 to_list (map (fun x -> g (f x)) s) = to_list (map g (map f (of_list l))))
1051
1052 let prop_sync_filter_all =
1053 QCheck.Test.make ~name:"sync filter true keeps all" ~count:100
1054 QCheck.(list int)
1055 (fun l ->
1056 let open Hcs.Stream.Sync in
1057 to_list (filter (fun _ -> true) (of_list l)) = l)
1058
1059 let prop_sync_filter_none =
1060 QCheck.Test.make ~name:"sync filter false removes all" ~count:100
1061 QCheck.(list int)
1062 (fun l ->
1063 let open Hcs.Stream.Sync in
1064 to_list (filter (fun _ -> false) (of_list l)) = [])
1065
1066 let prop_sync_take_drop =
1067 QCheck.Test.make ~name:"sync take n ++ drop n = original" ~count:100
1068 QCheck.(pair (list int) nat_small)
1069 (fun (l, n) ->
1070 let open Hcs.Stream.Sync in
1071 let s1 = of_list l in
1072 let s2 = of_list l in
1073 let taken = to_list (take n s1) in
1074 let dropped = to_list (drop n s2) in
1075 taken @ dropped = l)
1076
1077 let prop_sync_fold_sum =
1078 QCheck.Test.make ~name:"sync fold sum = List.fold_left sum" ~count:100
1079 QCheck.(list nat_small)
1080 (fun l ->
1081 let open Hcs.Stream.Sync in
1082 fold ( + ) 0 (of_list l) = List.fold_left ( + ) 0 l)
1083
1084 (* Router properties *)
1085 let prop_router_param_extraction =
1086 QCheck.Test.make ~name:"router extracts all params" ~count:100
1087 QCheck.(pair string_small string_small)
1088 (fun (id, name) ->
1089 (* Skip edge cases: empty strings or strings containing / *)
1090 if
1091 id = "" || name = "" || String.contains id '/'
1092 || String.contains name '/'
1093 then true (* vacuously true for invalid inputs *)
1094 else
1095 let open Hcs.Router in
1096 let router = empty () in
1097 add_route router ~method_:(Some `GET) ~path:"/users/:id/items/:name"
1098 ~handler:"handler" ~plugs:[];
1099 match
1100 lookup router ~method_:`GET
1101 ~path:(Printf.sprintf "/users/%s/items/%s" id name)
1102 with
1103 | Some { params; _ } ->
1104 param "id" params = Some id && param "name" params = Some name
1105 | None -> false)
1106
1107 (* HTTP builder properties *)
1108 let prop_http_method_roundtrip =
1109 QCheck.Test.make ~name:"http method roundtrip" ~count:20
1110 QCheck.(
1111 make
1112 Gen.(
1113 oneof
1114 [
1115 return "GET";
1116 return "POST";
1117 return "PUT";
1118 return "DELETE";
1119 return "PATCH";
1120 return "HEAD";
1121 return "OPTIONS";
1122 ]))
1123 (fun m ->
1124 let open Hcs.Http in
1125 meth_to_string (meth_of_string m) = m)
1126
1127 let tests =
1128 List.map QCheck_alcotest.to_alcotest
1129 [
1130 prop_sync_map_identity;
1131 prop_sync_map_composition;
1132 prop_sync_filter_all;
1133 prop_sync_filter_none;
1134 prop_sync_take_drop;
1135 prop_sync_fold_sum;
1136 prop_router_param_extraction;
1137 prop_http_method_roundtrip;
1138 ]
1139end
1140
1141(* ================================================================== *)
1142(* Main *)
1143(* ================================================================== *)
1144
1145let () =
1146 run "HCS"
1147 [
1148 ("Codec", Test_codec.tests);
1149 ("Stream", Test_stream.tests);
1150 ("Http", Test_http.tests);
1151 ("Pipeline", Test_pipeline.tests);
1152 ("Router", Test_router.tests);
1153 ("Log", Test_log.tests);
1154 ("Tls_config", Test_tls_config.tests);
1155 ("Request", Test_request.tests);
1156 ("Properties", Test_properties.tests);
1157 ]