diff --git a/dune-project b/dune-project index c04ced4..b6c77b2 100644 --- a/dune-project +++ b/dune-project @@ -16,6 +16,7 @@ (name get-activity-lib) (synopsis "Collect activity as markdown") (depends + (alcotest :with-test) astring curly fmt diff --git a/get-activity-lib.opam b/get-activity-lib.opam index a98a942..f350fbd 100644 --- a/get-activity-lib.opam +++ b/get-activity-lib.opam @@ -7,6 +7,7 @@ homepage: "https://github.com/tarides/get-activity" bug-reports: "https://github.com/tarides/get-activity/issues" depends: [ "dune" {>= "2.8"} + "alcotest" {with-test} "astring" "curly" "fmt" diff --git a/test/lib/alcotest_ext.ml b/test/lib/alcotest_ext.ml new file mode 100644 index 0000000..330db4a --- /dev/null +++ b/test/lib/alcotest_ext.ml @@ -0,0 +1,75 @@ +open Alcotest + +module Msg = struct + type 'a t = [ `Msg of 'a ] + + let pp f fs (`Msg s : 'a t) = Format.fprintf fs "%a" f s + let eq f (`Msg s1 : 'a t) (`Msg s2 : 'a t) = f s1 s2 + + let testable t = + let pp = pp (Alcotest.pp t) in + let eq = eq (Alcotest.equal t) in + testable pp eq +end + +let msg = Msg.testable +let string_msg = msg string +let or_msg x = result x string_msg + +module Yojson = struct + type t = Yojson.Safe.t + + let pp = Yojson.Safe.pp + let eq = Yojson.Safe.equal + let testable : t testable = testable pp eq +end + +let yojson = Yojson.testable + +module Curly = struct + module Meth = struct + type t = Curly.Meth.t + + let pp = Curly.Meth.pp + + let eq (x : t) (y : t) = + let x = Format.asprintf "%a" Curly.Meth.pp x in + let y = Format.asprintf "%a" Curly.Meth.pp y in + String.equal x y + end + + module Header = struct + type t = Curly.Header.t + + let pp = Curly.Header.pp + + let eq (x : t) (y : t) = + let x = Format.asprintf "%a" Curly.Header.pp x in + let y = Format.asprintf "%a" Curly.Header.pp y in + String.equal x y + end +end + +module Request = struct + type t = Get_activity.Graphql.request + + let pp fs (x : t) = + Format.fprintf fs + "@[{@;\ + meth = %a;@;\ + url = %S@;\ + headers =@ %a@;\ + body =@ @[%a@];@]@;\ + }" + Curly.Meth.pp x.meth x.url Curly.Header.pp x.headers Yojson.pp x.body + + let eq (x : t) (y : t) = + Curly.Meth.eq x.meth y.meth + && String.equal x.url y.url + && Curly.Header.eq x.headers y.headers + && Yojson.eq x.body y.body + + let testable = Alcotest.testable pp eq +end + +let request = Request.testable diff --git a/test/lib/alcotest_ext.mli b/test/lib/alcotest_ext.mli new file mode 100644 index 0000000..d8aecc3 --- /dev/null +++ b/test/lib/alcotest_ext.mli @@ -0,0 +1,8 @@ +val msg : 'a Alcotest.testable -> [ `Msg of 'a ] Alcotest.testable +val string_msg : [ `Msg of string ] Alcotest.testable + +val or_msg : + 'a Alcotest.testable -> ('a, [ `Msg of string ]) result Alcotest.testable + +val yojson : Yojson.Safe.t Alcotest.testable +val request : Get_activity.Graphql.request Alcotest.testable diff --git a/test/lib/dune b/test/lib/dune new file mode 100644 index 0000000..162d0b1 --- /dev/null +++ b/test/lib/dune @@ -0,0 +1,4 @@ +(test + (name main) + (package get-activity-lib) + (libraries get-activity-lib alcotest)) diff --git a/test/lib/main.ml b/test/lib/main.ml new file mode 100644 index 0000000..1172be5 --- /dev/null +++ b/test/lib/main.ml @@ -0,0 +1,8 @@ +let () = + Alcotest.run "get-activity-lib" + [ + Test_token.suite; + Test_period.suite; + Test_graphql.suite; + Test_contributions.suite; + ] diff --git a/test/lib/test_contributions.ml b/test/lib/test_contributions.ml new file mode 100644 index 0000000..fde6c91 --- /dev/null +++ b/test/lib/test_contributions.ml @@ -0,0 +1,456 @@ +open Get_activity + +module Testable = struct + module Datetime = struct + let pp fs x = Format.fprintf fs "%S" x + let eq = String.equal + end + + module Item = struct + module Kind = struct + type t = [ `Issue | `PR | `Review of string | `New_repo ] + + let pp fs = function + | `Issue -> Format.fprintf fs "`Issue" + | `PR -> Format.fprintf fs "`PR" + | `Review x -> Format.fprintf fs "`Review %S" x + | `New_repo -> Format.fprintf fs "`New_repo" + + let eq (x : t) (y : t) = + match (x, y) with + | `Issue, `Issue | `PR, `PR | `New_repo, `New_repo -> true + | `Review x, `Review y -> String.equal x y + | _ -> false + end + + type t = Contributions.item + + let pp fs (x : t) = + Format.fprintf fs + "@[{@;\ + repo = %S;@;\ + kind = %a;@;\ + date = %a;@;\ + url = %S;@;\ + title = %S;@;\ + body = %S;@]@;\ + }@," + x.repo Kind.pp x.kind Datetime.pp x.date x.url x.title x.body + + let eq (x : t) (y : t) = + String.equal x.repo y.repo && Kind.eq x.kind y.kind + && Datetime.eq x.date y.date && String.equal x.url y.url + && String.equal x.title y.title + && String.equal x.body y.body + end + + module Repo_map = struct + type 'a t = 'a Contributions.Repo_map.t + + let pp f fs (x : 'a t) = + Contributions.Repo_map.iter + (fun key v -> + Format.fprintf fs + "@[{@;key = %S;@;value =@ @[%a@];@]@;}@," key f v) + x + + let eq = Contributions.Repo_map.equal + end + + module Contributions = struct + type t = Contributions.t + + let pp fs (x : t) = + Format.fprintf fs + "@[{@;username = %S;@;activity =@ @[%a@];@]@;}" x.username + (Repo_map.pp (Format.pp_print_list Item.pp)) + x.activity + + (* [List.equal] requires OCaml >= 4.12 *) + let list_equal eq lx ly = + try + List.iter2 (fun x y -> if not (eq x y) then failwith "not equal") lx ly; + true + with _ -> false + + let eq (x : t) (y : t) = + String.equal x.username y.username + && Repo_map.eq (list_equal Item.eq) x.activity y.activity + + let testable = Alcotest.testable pp eq + end + + let contributions = Contributions.testable +end + +let test_request = + let make_test name ~period ~token ~expected = + let name = Printf.sprintf "request: %s" name in + let test_fun () = + let actual = Contributions.request ~period ~token in + Alcotest.(check Alcotest_ext.request) name expected actual + in + (name, `Quick, test_fun) + in + [ + make_test "no token" ~token:"" ~period:("", "") + ~expected: + { + meth = `POST; + url = "https://api.github.com/graphql"; + headers = [ ("Authorization", "bearer ") ]; + body = + `Assoc + [ + ( "query", + `String + {|query($from: DateTime!, $to: DateTime!) { + viewer { + login + contributionsCollection(from: $from, to: $to) { + issueContributions(first: 100) { + nodes { + occurredAt + issue { + url + title + body + repository { nameWithOwner } + } + } + } + pullRequestContributions(first: 100) { + nodes { + occurredAt + pullRequest { + url + title + body + repository { nameWithOwner } + } + } + } + pullRequestReviewContributions(first: 100) { + nodes { + occurredAt + pullRequestReview { + url + pullRequest { title } + body + state + comments(first: 100) { nodes { body } } + repository { nameWithOwner } + } + } + } + repositoryContributions(first: 100) { + nodes { + occurredAt + repository { + url + nameWithOwner + } + } + } + } + } +}|} + ); + ( "variables", + `Assoc [ ("from", `String ""); ("to", `String "") ] ); + ]; + }; + ] + +let activity_example = + {| +{ + "data": { + "viewer": { + "login": "gpetiot", + "contributionsCollection": { + "issueContributions": { + "nodes": [ + { + "occurredAt": "2024-03-04T11:55:37Z", + "issue": { + "url": "https://github.com/tarides/get-activity/issues/8", + "title": "Add the PR/issues comments to the result of okra generate", + "body": "xxx", + "repository": { + "nameWithOwner": "tarides/get-activity" + } + } + }, + { + "occurredAt": "2024-02-27T12:05:04Z", + "issue": { + "url": "https://github.com/tarides/okra/issues/165", + "title": "Make the `get-activity` package known to ocaml-ci", + "body": "xxx", + "repository": { + "nameWithOwner": "tarides/okra" + } + } + } + ] + }, + "pullRequestContributions": { + "nodes": [ + { + "occurredAt": "2024-03-05T11:21:22Z", + "pullRequest": { + "url": "https://github.com/ocaml-ppx/ocamlformat/pull/2533", + "title": "Represent the expr sequence as a list", + "body": "xxx", + "repository": { + "nameWithOwner": "ocaml-ppx/ocamlformat" + } + } + }, + { + "occurredAt": "2024-03-04T17:20:11Z", + "pullRequest": { + "url": "https://github.com/realworldocaml/mdx/pull/450", + "title": "Add an 'exec' label to execute include OCaml blocks", + "body": "xxx", + "repository": { + "nameWithOwner": "realworldocaml/mdx" + } + } + } + ] + }, + "pullRequestReviewContributions": { + "nodes": [ + { + "occurredAt": "2024-03-05T11:43:04Z", + "pullRequestReview": { + "url": "https://github.com/realworldocaml/mdx/pull/449#pullrequestreview-1916654244", + "pullRequest": { + "title": "Add upgrade instructions in the changelog for #446" + }, + "body": "xxx", + "state": "APPROVED", + "comments": { + "nodes": [] + }, + "repository": { + "nameWithOwner": "realworldocaml/mdx" + } + } + }, + { + "occurredAt": "2024-02-28T11:09:41Z", + "pullRequestReview": { + "url": "https://github.com/tarides/okra/pull/166#pullrequestreview-1905972361", + "pullRequest": { + "title": "Make README.md more precise" + }, + "body": "xxx", + "state": "APPROVED", + "comments": { + "nodes": [] + }, + "repository": { + "nameWithOwner": "tarides/okra" + } + } + } + ] + }, + "repositoryContributions": { + "nodes": [ + { + "occurredAt": "2024-03-02T09:40:41Z", + "repository": { + "url": "https://github.com/gpetiot/config.ml", + "nameWithOwner": "gpetiot/config.ml" + } + }, + { + "occurredAt": "2024-03-01T10:43:33Z", + "repository": { + "url": "https://github.com/gpetiot/js_of_ocaml", + "nameWithOwner": "gpetiot/js_of_ocaml" + } + } + ] + } + } + } + } +} +|} + +let activity_example_json = Yojson.Safe.from_string activity_example + +let contributions_example = + let open Contributions in + { + username = "gpetiot"; + activity = + Repo_map.empty + |> Repo_map.add "gpetiot/config.ml" + [ + { + repo = "gpetiot/config.ml"; + kind = `New_repo; + date = "2024-03-02T09:40:41Z"; + url = "https://github.com/gpetiot/config.ml"; + title = "Created new repository"; + body = ""; + }; + ] + |> Repo_map.add "gpetiot/js_of_ocaml" + [ + { + repo = "gpetiot/js_of_ocaml"; + kind = `New_repo; + date = "2024-03-01T10:43:33Z"; + url = "https://github.com/gpetiot/js_of_ocaml"; + title = "Created new repository"; + body = ""; + }; + ] + |> Repo_map.add "ocaml-ppx/ocamlformat" + [ + { + repo = "ocaml-ppx/ocamlformat"; + kind = `PR; + date = "2024-03-05T11:21:22Z"; + url = "https://github.com/ocaml-ppx/ocamlformat/pull/2533"; + title = "Represent the expr sequence as a list"; + body = "xxx"; + }; + ] + |> Repo_map.add "realworldocaml/mdx" + [ + { + repo = "realworldocaml/mdx"; + kind = `Review "APPROVED"; + date = "2024-03-05T11:43:04Z"; + url = + "https://github.com/realworldocaml/mdx/pull/449#pullrequestreview-1916654244"; + title = "Add upgrade instructions in the changelog for #446"; + body = "xxx"; + }; + { + repo = "realworldocaml/mdx"; + kind = `PR; + date = "2024-03-04T17:20:11Z"; + url = "https://github.com/realworldocaml/mdx/pull/450"; + title = "Add an 'exec' label to execute include OCaml blocks"; + body = "xxx"; + }; + ] + |> Repo_map.add "tarides/get-activity" + [ + { + repo = "tarides/get-activity"; + kind = `Issue; + date = "2024-03-04T11:55:37Z"; + url = "https://github.com/tarides/get-activity/issues/8"; + title = + "Add the PR/issues comments to the result of okra generate"; + body = "xxx"; + }; + ] + |> Repo_map.add "tarides/okra" + [ + { + repo = "tarides/okra"; + kind = `Review "APPROVED"; + date = "2024-02-28T11:09:41Z"; + url = + "https://github.com/tarides/okra/pull/166#pullrequestreview-1905972361"; + title = "Make README.md more precise"; + body = "xxx"; + }; + { + repo = "tarides/okra"; + kind = `Issue; + date = "2024-02-27T12:05:04Z"; + url = "https://github.com/tarides/okra/issues/165"; + title = "Make the `get-activity` package known to ocaml-ci"; + body = "xxx"; + }; + ]; + } + +let test_of_json = + let make_test name ~from json ~expected = + let name = Printf.sprintf "of_json: %s" name in + let test_fun () = + let actual = Contributions.of_json ~from json in + Alcotest.(check Testable.contributions) name expected actual + in + (name, `Quick, test_fun) + in + [ + make_test "no token" ~from:"" activity_example_json + ~expected:contributions_example; + ] + +let test_is_empty = + let make_test name ~input ~expected = + let name = Printf.sprintf "is_empty: %s" name in + let test_fun () = + let actual = Contributions.is_empty input in + Alcotest.(check bool) name expected actual + in + (name, `Quick, test_fun) + in + [ + make_test "empty" + ~input: + { Contributions.username = ""; activity = Contributions.Repo_map.empty } + ~expected:true; + make_test "not empty" ~input:contributions_example ~expected:false; + ] + +let test_pp = + let make_test name ~input ~expected = + let name = Printf.sprintf "pp: %s" name in + let test_fun () = + let actual = Format.asprintf "%a" Contributions.pp input in + Alcotest.(check string) name expected actual + in + (name, `Quick, test_fun) + in + [ + make_test "empty" + ~input: + { Contributions.username = ""; activity = Contributions.Repo_map.empty } + ~expected:"(no activity)"; + make_test "not empty" ~input:contributions_example + ~expected: + "### gpetiot/config.ml\n\ + Created repository \ + [gpetiot/config.ml](https://github.com/gpetiot/config.ml).\n\ + ### gpetiot/js_of_ocaml\n\ + Created repository \ + [gpetiot/js_of_ocaml](https://github.com/gpetiot/js_of_ocaml).\n\ + ### ocaml-ppx/ocamlformat\n\ + Represent the expr sequence as a list \ + [#2533](https://github.com/ocaml-ppx/ocamlformat/pull/2533). \n\ + xxx### realworldocaml/mdx\n\ + APPROVED Add upgrade instructions in the changelog for #446 \ + [#449](https://github.com/realworldocaml/mdx/pull/449#pullrequestreview-1916654244). \n\ + xxx\n\ + Add an 'exec' label to execute include OCaml blocks \ + [#450](https://github.com/realworldocaml/mdx/pull/450). \n\ + xxx### tarides/get-activity\n\ + Add the PR/issues comments to the result of okra generate \ + [#8](https://github.com/tarides/get-activity/issues/8). \n\ + xxx### tarides/okra\n\ + APPROVED Make README.md more precise \ + [#166](https://github.com/tarides/okra/pull/166#pullrequestreview-1905972361). \n\ + xxx\n\ + Make the `get-activity` package known to ocaml-ci \ + [#165](https://github.com/tarides/okra/issues/165). \n\ + xxx"; + ] + +let suite = + ("Contributions", test_request @ test_of_json @ test_is_empty @ test_pp) diff --git a/test/lib/test_graphql.ml b/test/lib/test_graphql.ml new file mode 100644 index 0000000..a58230d --- /dev/null +++ b/test/lib/test_graphql.ml @@ -0,0 +1,24 @@ +open Get_activity + +let test_request = + let make_test name ?variables ~token ~query ~expected () = + let name = Printf.sprintf "request: %s" name in + let test_fun () = + let actual = Graphql.request ?variables ~token ~query () in + Alcotest.(check Alcotest_ext.request) name expected actual + in + (name, `Quick, test_fun) + in + [ + make_test "no token" ~token:"" ~query:"" + ~expected: + { + meth = `POST; + url = "https://api.github.com/graphql"; + headers = [ ("Authorization", "bearer ") ]; + body = `Assoc [ ("query", `String "") ]; + } + (); + ] + +let suite = ("Graphql", test_request) diff --git a/test/lib/test_period.ml b/test/lib/test_period.ml new file mode 100644 index 0000000..989ef7b --- /dev/null +++ b/test/lib/test_period.ml @@ -0,0 +1,33 @@ +open Get_activity + +let test_one_week = + let make_test name ~expected = + let name = Printf.sprintf "one_week: %s" name in + let test_fun () = + let actual = Period.one_week in + Alcotest.(check (float 0.)) name expected actual + in + (name, `Quick, test_fun) + in + [ make_test "default" ~expected:604800. ] + +let test_to_8601 = + let make_test name ~input ~expected = + let name = Printf.sprintf "to_8601: %s" name in + let test_fun () = + let actual = Period.to_8601 input in + Alcotest.(check string) name expected actual + in + (name, `Quick, test_fun) + in + [ + make_test "zero" ~input:0. ~expected:"1970-01-01T00:00:00Z"; + make_test "negative" ~input:(-1.) ~expected:"1969-12-31T23:59:59Z"; + make_test "ten" ~input:10. ~expected:"1970-01-01T00:00:10Z"; + make_test "one million" ~input:1_000_000. ~expected:"1970-01-12T13:46:40Z"; + make_test "one billion" ~input:1_000_000_000. + ~expected:"2001-09-09T01:46:40Z"; + ] + +let test_with_period = [] +let suite = ("Period", test_one_week @ test_to_8601 @ test_with_period) diff --git a/test/lib/test_token.ml b/test/lib/test_token.ml new file mode 100644 index 0000000..c339d75 --- /dev/null +++ b/test/lib/test_token.ml @@ -0,0 +1,25 @@ +open Get_activity + +let test_load = + let make_test name ~input ~expected = + let name = Printf.sprintf "load: %s" name in + let test_fun () = + let actual = Token.load input in + Alcotest.(check (Alcotest_ext.or_msg string)) name expected actual + in + (name, `Quick, test_fun) + in + let error_test name path = + let msg = + Format.sprintf + {|Can't open GitHub token file (%s: No such file or directory). +Go to https://github.com/settings/tokens to generate one.|} + path + in + make_test name ~input:path ~expected:(Error (`Msg msg)) + in + [ + error_test "empty" ""; error_test "invalid path" "invalid-path/invalid-path"; + ] + +let suite = ("Token", test_load)