diff --git a/Cargo.lock b/Cargo.lock index 8ac9772c..be4fe2e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,9 +19,9 @@ checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" [[package]] name = "ahash" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42cd52102d3df161c77a887b608d7a4897d7cc112886a9537b738a887a03aaff" +checksum = "d713b3834d76b85304d4d525563c1276e2e30dc97cc67bfb4585a4a29fc2c89f" dependencies = [ "cfg-if", "getrandom", @@ -45,11 +45,59 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0942ffc6dcaadf03badf6e6a2d0228460359d5e34b57ccdc720b7382dfbd5ec5" +[[package]] +name = "anstream" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b09b5178381e0874812a9b157f7fe84982617e48f71f4e3235482775e5b540" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc" + +[[package]] +name = "anstyle-parse" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" +dependencies = [ + "anstyle", + "windows-sys 0.52.0", +] + [[package]] name = "anyhow" -version = "1.0.79" +version = "1.0.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" +checksum = "5ad32ce52e4161730f7098c077cd2ed6229b5804ccf99e5366be1ab72a98b4e1" [[package]] name = "async-stream" @@ -70,7 +118,7 @@ checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.50", ] [[package]] @@ -81,7 +129,7 @@ checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.50", ] [[package]] @@ -205,11 +253,27 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c48f0051a4b4c5e0b6d365cd04af53aeaa209e3cc15ec2cdb69e73cc87fbd0dc" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" -version = "3.14.0" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d32a994c2b3ca201d9b263612a374263f05e7adde37c4707f693dcd375076d1f" + +[[package]] +name = "bytecount" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" +checksum = "e1e5f035d16fc623ae5f74981db80a439803888314e3a555fd6f04acd51a3205" [[package]] name = "byteorder" @@ -238,6 +302,66 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "clap" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c918d541ef2913577a0f9566e9ce27cb35b6df072075769e0b26cb5a554520da" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f3e7391dad68afb0c2ede1bf619f579a3dc9c2ec67f089baa397123a2f3d1eb" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "307bc0538d5f0f83b8248db3087aa92fe504e4691294d0c96c0eabc33f47ba47" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.50", +] + +[[package]] +name = "clap_lex" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + +[[package]] +name = "console" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "unicode-width", + "windows-sys 0.52.0", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -284,6 +408,25 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.11" @@ -309,6 +452,71 @@ dependencies = [ "typenum", ] +[[package]] +name = "cucumber" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5063d8cf24f4998ad01cac265da468a15ca682a8f4f826d50e661964e8d9b8" +dependencies = [ + "anyhow", + "async-trait", + "clap", + "console", + "crossbeam-utils", + "cucumber-codegen", + "cucumber-expressions", + "derive_more", + "drain_filter_polyfill", + "either", + "futures", + "gherkin", + "globwalk", + "humantime", + "inventory", + "itertools 0.12.1", + "lazy-regex", + "linked-hash-map", + "once_cell", + "pin-project", + "regex", + "sealed", + "serde", + "serde_json", + "smart-default", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "cucumber-codegen" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01091e28d1f566c8b31b67948399d2efd6c0a8f6228a9785519ed7b73f7f0aef" +dependencies = [ + "cucumber-expressions", + "inflections", + "itertools 0.12.1", + "proc-macro2", + "quote", + "regex", + "syn 2.0.50", + "synthez", +] + +[[package]] +name = "cucumber-expressions" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d794fed319eea24246fb5f57632f7ae38d61195817b7eb659455aa5bdd7c1810" +dependencies = [ + "derive_more", + "either", + "nom", + "nom_locate", + "regex", + "regex-syntax 0.7.5", +] + [[package]] name = "deadpool" version = "0.10.0" @@ -350,6 +558,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_more" +version = "0.99.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "digest" version = "0.10.7" @@ -374,6 +593,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "drain_filter_polyfill" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408" + [[package]] name = "either" version = "1.10.0" @@ -383,6 +608,12 @@ dependencies = [ "serde", ] +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "encoding_rs" version = "0.8.33" @@ -566,7 +797,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.50", ] [[package]] @@ -622,12 +853,53 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gherkin" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20b79820c0df536d1f3a089a2fa958f61cb96ce9e0f3f8f507f5a31179567755" +dependencies = [ + "heck", + "peg", + "quote", + "serde", + "serde_json", + "syn 2.0.50", + "textwrap", + "thiserror", + "typed-builder", +] + [[package]] name = "gimli" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +[[package]] +name = "globset" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata 0.4.5", + "regex-syntax 0.8.2", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + [[package]] name = "h2" version = "0.3.24" @@ -788,6 +1060,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + [[package]] name = "hyper" version = "0.14.28" @@ -865,6 +1143,22 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "ignore" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata 0.4.5", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -885,6 +1179,18 @@ dependencies = [ "hashbrown 0.14.3", ] +[[package]] +name = "inflections" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" + +[[package]] +name = "inventory" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f958d3d68f4167080a18141e10381e7634563984a537f2a49a30fd8e53ac5767" + [[package]] name = "ipnet" version = "2.9.0" @@ -939,6 +1245,29 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "lazy-regex" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d12be4595afdf58bd19e4a9f4e24187da2a66700786ff660a418e9059937a4c" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", +] + +[[package]] +name = "lazy-regex-proc_macros" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44bcd58e6c97a7fcbaffcdc95728b393b8d98933bfadad49ed4097845b57ef0b" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.50", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -971,6 +1300,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.4.13" @@ -1101,6 +1436,17 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom_locate" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e3c83c053b0713da60c5b8de47fe8e494fe3ece5267b2f23090a07a053ba8f3" +dependencies = [ + "bytecount", + "memchr", + "nom", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -1202,9 +1548,9 @@ checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "openssl" -version = "0.10.63" +version = "0.10.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15c9d69dd87a29568d4d017cfe8ec518706046a05184e5aea92d0af890b803c8" +checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" dependencies = [ "bitflags 2.4.2", "cfg-if", @@ -1223,7 +1569,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.50", ] [[package]] @@ -1234,9 +1580,9 @@ checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] name = "openssl-sys" -version = "0.9.99" +version = "0.9.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e1bf214306098e4832460f797824c05d25aacdf896f64a985fb0fd992454ae" +checksum = "ae94056a791d0e1217d18b6cbdccb02c61e3054fc69893607f4067e3bb0b1fd1" dependencies = [ "cc", "libc", @@ -1279,6 +1625,33 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c" +[[package]] +name = "peg" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f76678828272f177ac33b7e2ac2e3e73cc6c1cd1e3e387928aa69562fa51367" +dependencies = [ + "peg-macros", + "peg-runtime", +] + +[[package]] +name = "peg-macros" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "636d60acf97633e48d266d7415a9355d4389cea327a193f87df395d88cd2b14d" +dependencies = [ + "peg-runtime", + "proc-macro2", + "quote", +] + +[[package]] +name = "peg-runtime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555b1514d2d99d78150d3c799d4c357a3e2c2a8062cd108e93a06d9057629c5" + [[package]] name = "pem" version = "3.0.3" @@ -1349,7 +1722,7 @@ checksum = "266c042b60c9c76b8d53061e52b2e0d1116abc57cefc8c5cd671619a56ac3690" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.50", ] [[package]] @@ -1439,7 +1812,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a41cf62165e97c7f814d2221421dbb9afcbcdb0a88068e5ea206e19951c2cbb5" dependencies = [ "proc-macro2", - "syn 2.0.48", + "syn 2.0.50", ] [[package]] @@ -1478,7 +1851,7 @@ dependencies = [ "prost", "prost-types", "regex", - "syn 2.0.48", + "syn 2.0.50", "tempfile", "which", ] @@ -1493,7 +1866,7 @@ dependencies = [ "itertools 0.11.0", "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.50", ] [[package]] @@ -1548,6 +1921,7 @@ dependencies = [ name = "ratings" version = "0.2.0" dependencies = [ + "cucumber", "dotenv", "envy", "futures", @@ -1623,6 +1997,12 @@ version = "0.6.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" +[[package]] +name = "regex-syntax" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da" + [[package]] name = "regex-syntax" version = "0.8.2" @@ -1671,16 +2051,17 @@ dependencies = [ [[package]] name = "ring" -version = "0.17.7" +version = "0.17.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "688c63d65483050968b2a8937f7995f443e27041a0f7700aa59b0822aedebb74" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" dependencies = [ "cc", + "cfg-if", "getrandom", "libc", "spin 0.9.8", "untrusted", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] @@ -1760,9 +2141,18 @@ checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" [[package]] name = "ryu" -version = "1.0.16" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" + +[[package]] +name = "same-file" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] [[package]] name = "schannel" @@ -1789,6 +2179,18 @@ dependencies = [ "untrusted", ] +[[package]] +name = "sealed" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a8caec23b7800fb97971a1c6ae365b6239aaeddfb934d6265f8505e795699d" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.50", +] + [[package]] name = "security-framework" version = "2.9.2" @@ -1814,29 +2216,29 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.196" +version = "1.0.197" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32" +checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.196" +version = "1.0.197" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67" +checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.50", ] [[package]] name = "serde_json" -version = "1.0.113" +version = "1.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69801b70b1c3dac963ecb03a364ba0ceda9cf60c71cfe475e99864759c8b8a79" +checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" dependencies = [ "itoa", "ryu", @@ -1938,6 +2340,23 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" +[[package]] +name = "smart-default" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.50", +] + +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "snapd" version = "0.1.0" @@ -2217,6 +2636,12 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "strsim" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01" + [[package]] name = "strum" version = "0.26.1" @@ -2236,7 +2661,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.48", + "syn 2.0.50", ] [[package]] @@ -2258,9 +2683,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.48" +version = "2.0.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" +checksum = "74f1bdc9872430ce9b75da68329d1c1746faf50ffac5f19e02b71e37ff881ffb" dependencies = [ "proc-macro2", "quote", @@ -2273,6 +2698,39 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "synthez" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d2c2202510a1e186e63e596d9318c91a8cbe85cd1a56a7be0c333e5f59ec8d" +dependencies = [ + "syn 2.0.50", + "synthez-codegen", + "synthez-core", +] + +[[package]] +name = "synthez-codegen" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f724aa6d44b7162f3158a57bccd871a77b39a4aef737e01bcdff41f4772c7746" +dependencies = [ + "syn 2.0.50", + "synthez-core", +] + +[[package]] +name = "synthez-core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bfa6ec52465e2425fd43ce5bbbe0f0b623964f7c63feb6b10980e816c654ea" +dependencies = [ + "proc-macro2", + "quote", + "sealed", + "syn 2.0.50", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -2306,6 +2764,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "terminal_size" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7" +dependencies = [ + "rustix", + "windows-sys 0.48.0", +] + +[[package]] +name = "textwrap" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.57" @@ -2323,7 +2802,7 @@ checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.50", ] [[package]] @@ -2419,7 +2898,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.50", ] [[package]] @@ -2520,7 +2999,7 @@ dependencies = [ "proc-macro2", "prost-build", "quote", - "syn 2.0.48", + "syn 2.0.50", ] [[package]] @@ -2588,7 +3067,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.50", ] [[package]] @@ -2636,6 +3115,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-builder" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe83c85a85875e8c4cb9ce4a890f05b23d38cd0d47647db7895d3d2a79566d2" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29a3151c41d0b13e3d011f98adc24434560ef06673a155a6c7f66b9879eecce2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.50", +] + [[package]] name = "typenum" version = "1.17.0" @@ -2654,11 +3153,17 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-normalization" -version = "0.1.22" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" dependencies = [ "tinyvec", ] @@ -2669,6 +3174,12 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" +[[package]] +name = "unicode-width" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" + [[package]] name = "unicode_categories" version = "0.1.1" @@ -2698,6 +3209,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + [[package]] name = "valuable" version = "0.1.0" @@ -2716,6 +3233,16 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "walkdir" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2752,7 +3279,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.50", "wasm-bindgen-shared", ] @@ -2786,7 +3313,7 @@ checksum = "642f325be6301eb8107a83d12a8ac6c1e1c54345a7ef1a9261962dfefda09e66" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.50", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2851,6 +3378,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" +dependencies = [ + "winapi", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -3016,7 +3552,7 @@ checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.48", + "syn 2.0.50", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4d19ecb0..f9c38670 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,3 +40,18 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } [build-dependencies] tonic-build = { version = "0.11", features = ["prost"] } + +[dev-dependencies] +cucumber = { version = "0.20.2", features = ["libtest", "tracing"] } + +[[test]] +name = "voting" +harness = false + +[[test]] +name = "authentication" +harness = false + +[[test]] +name = "chart" +harness = false diff --git a/src/features/common/entities.rs b/src/features/common/entities.rs index ece361e7..67f03e7e 100644 --- a/src/features/common/entities.rs +++ b/src/features/common/entities.rs @@ -8,13 +8,15 @@ const INSUFFICIENT_VOTES_QUANTITY: i64 = 25; /// A descriptive mapping of a number of ratings to a general indicator of "how good" /// an app can be said to be. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[allow(missing_docs)] pub enum RatingsBand { VeryGood = 0, Good = 1, Neutral = 2, Poor = 3, VeryPoor = 4, + #[default] InsufficientVotes = 5, } @@ -45,10 +47,37 @@ impl RatingsBand { } } +impl PartialOrd for RatingsBand { + fn partial_cmp(&self, other: &Self) -> Option { + if matches!(self, RatingsBand::InsufficientVotes) + || matches!(other, RatingsBand::InsufficientVotes) + { + None + } else { + // Negative ratings have a higher value i.e., 0 = Very Good and 4 = Very Poor + let max = Self::InsufficientVotes as u8; + (max - (*self as u8)).partial_cmp(&(max - (*other as u8))) + } + } +} + +impl From for RatingsBand { + fn from(value: crate::features::pb::common::RatingsBand) -> Self { + match value { + pb::RatingsBand::VeryGood => Self::VeryGood, + pb::RatingsBand::Good => Self::Good, + pb::RatingsBand::Neutral => Self::Neutral, + pb::RatingsBand::Poor => Self::Poor, + pb::RatingsBand::VeryPoor => Self::VeryPoor, + pb::RatingsBand::InsufficientVotes => Self::InsufficientVotes, + } + } +} + /// A descriptive rating object, usually used converted and transferred over the wire. /// This is an aggregated rating for a snap without holding every raw value, as determined /// by [`RatingsBand`]. -#[derive(Debug, Clone, FromRow)] +#[derive(Debug, Clone, FromRow, Default)] pub struct Rating { /// The ID of the snap this rating is for pub snap_id: String, @@ -81,6 +110,18 @@ impl Rating { } } +impl From for Rating { + fn from(value: crate::features::pb::common::Rating) -> Self { + Self { + snap_id: value.snap_id, + total_votes: value.total_votes, + ratings_band: crate::features::pb::common::RatingsBand::try_from(value.ratings_band) + .unwrap() + .into(), + } + } +} + /// A summary of votes for a given snap, this is then aggregated before transfer. #[derive(Debug, Clone, FromRow)] pub struct VoteSummary { diff --git a/src/features/mod.rs b/src/features/mod.rs index 8fd706af..ec0fc264 100644 --- a/src/features/mod.rs +++ b/src/features/mod.rs @@ -1,8 +1,7 @@ //! Contains various feature implementations for the ratings backend. pub mod chart; +pub mod common; pub mod pb; pub mod rating; pub mod user; - -mod common; diff --git a/test.env b/test.env new file mode 100644 index 00000000..0a385dbd --- /dev/null +++ b/test.env @@ -0,0 +1,17 @@ +APP_ENV=dev +APP_HOST=0.0.0.0 +APP_JWT_SECRET=deadbeef +APP_LOG_LEVEL=info +APP_NAME=ratings +APP_PORT=8080 +# Update this with some real PostgreSQL details +APP_POSTGRES_URI=postgresql://migration_user:strongpassword@localhost:5433/ratings +APP_MIGRATION_POSTGRES_URI=postgresql://migration_user:strongpassword@localhost:5433/ratings + +DOCKER_POSTGRES_USER=postgres +DOCKER_POSTGRES_PASSWORD=@1234 +DOCKER_MIGRATION_USER=migration_user +DOCKER_MIGRATION_PASSWORD=strongpassword +DOCKER_SERVICE_USER=service +DOCKER_SERVICE_PASSWORD=covfefe!1 +DOCKER_RATINGS_DB=ratings diff --git a/tests/app_tests/lifecycle_test.rs b/tests/app_tests/lifecycle_test.rs deleted file mode 100644 index 0b185db6..00000000 --- a/tests/app_tests/lifecycle_test.rs +++ /dev/null @@ -1,118 +0,0 @@ -use futures::FutureExt; -use ratings::{ - app::AppContext, - features::pb::common::RatingsBand, - features::pb::user::AuthenticateResponse, - utils::{Config, Infrastructure}, -}; - -use super::super::helpers::with_lifecycle::with_lifecycle; -use crate::helpers::test_data::TestData; -use crate::helpers::vote_generator::generate_votes; -use crate::helpers::{self, client_app::AppClient}; -use crate::helpers::{client_user::UserClient, data_faker}; - -#[tokio::test] -async fn app_lifecycle_test() -> Result<(), Box> { - let config = Config::load()?; - let infra = Infrastructure::new(&config).await?; - let app_ctx = AppContext::new(&config, infra); - - let data = TestData { - user_client: Some(UserClient::new(&config.socket())), - app_ctx, - id: None, - token: None, - app_client: Some(AppClient::new(&config.socket())), - snap_id: Some(data_faker::rnd_id()), - chart_client: None, - categories: None, - }; - - with_lifecycle(async { - vote_once(data.clone()).then(vote_up).await; - }) - .await; - Ok(()) -} - -async fn vote_once(mut data: TestData) -> TestData { - let vote_up = true; - let expected_total_votes = 1; - let expected_rating_band = RatingsBand::InsufficientVotes; - - generate_votes( - &data.snap_id.clone().unwrap(), - 111, - vote_up, - 1, - data.clone(), - ) - .await - .expect("Votes should succeed"); - - let id: String = helpers::data_faker::rnd_sha_256(); - data.id = Some(id.to_string()); - - let client = data.user_client.clone().unwrap(); - let response: AuthenticateResponse = client.authenticate(&id).await.unwrap().into_inner(); - let token: String = response.token; - data.token = Some(token.to_string()); - - let result = data - .clone() - .app_client - .unwrap() - .get_rating(&data.snap_id.clone().unwrap(), &data.token.clone().unwrap()) - .await - .expect("Get Rating should succeed") - .into_inner() - .rating - .unwrap(); - - let actual_snap_id = result.snap_id; - let actual_total_votes = result.total_votes; - let actual_ratings_band = result.ratings_band; - - assert_eq!(data.snap_id.clone().unwrap(), actual_snap_id); - assert_eq!(expected_total_votes, actual_total_votes); - assert_eq!(expected_rating_band as i32, actual_ratings_band); - - data -} -async fn vote_up(data: TestData) -> TestData { - let vote_up = true; - let expected_total_votes = 26; - let expected_rating_band = RatingsBand::VeryGood; - - generate_votes( - &data.snap_id.clone().unwrap(), - 111, - vote_up, - 25, - data.clone(), - ) - .await - .expect("Votes should succeed"); - - let result = data - .clone() - .app_client - .unwrap() - .get_rating(&data.snap_id.clone().unwrap(), &data.token.clone().unwrap()) - .await - .expect("Get Rating should succeed") - .into_inner() - .rating - .unwrap(); - - let actual_snap_id = result.snap_id; - let actual_total_votes = result.total_votes; - let actual_ratings_band = result.ratings_band; - - assert_eq!(data.snap_id.clone().unwrap(), actual_snap_id); - assert_eq!(expected_total_votes, actual_total_votes); - assert_eq!(expected_rating_band as i32, actual_ratings_band); - - data -} diff --git a/tests/authentication.rs b/tests/authentication.rs new file mode 100644 index 00000000..32e62a88 --- /dev/null +++ b/tests/authentication.rs @@ -0,0 +1,104 @@ +use cucumber::{given, then, when, World}; + +use helpers::client::*; +use ratings::utils::{Config, Infrastructure}; +use sqlx::Row; +use tonic::{Code, Status}; + +mod helpers; + +#[derive(Clone, Debug, Default, World)] +struct AuthenticationWorld { + client_hash: String, + client: Option, + tokens: Vec, + auth_error: Option, +} + +#[given(expr = "a valid client hash")] +fn generate_hash(world: &mut AuthenticationWorld) { + world.client_hash = helpers::data_faker::rnd_sha_256(); +} + +#[given(expr = "a bad client with the hash {word}")] +fn with_hash(world: &mut AuthenticationWorld, hash: String) { + world.client_hash = hash; +} + +#[when(expr = "the client attempts to authenticate")] +#[when(expr = "that client authenticates a second time")] +#[given(expr = "an authenticated client")] +async fn authenticate(world: &mut AuthenticationWorld) { + let config = Config::load().expect("Could not load config"); + + world.client = Some(TestClient::new(config.socket())); + + match world + .client + .as_ref() + .unwrap() + .authenticate(&world.client_hash) + .await + { + Ok(resp) => world.tokens.push(resp.into_inner().token), + Err(err) => world.auth_error = Some(err), + } +} + +#[then(expr = "the authentication is rejected")] +fn check_rejected(world: &mut AuthenticationWorld) { + assert!(world.auth_error.is_some()); + + let err = world.auth_error.as_ref().unwrap(); + + assert_eq!(err.code(), Code::InvalidArgument); +} + +#[then(expr = "the returned token is valid")] +#[then(expr = "both tokens are valid")] +fn verify_token(world: &mut AuthenticationWorld) { + assert!( + world.auth_error.is_none(), + "needed clean exit, instead got status {:?}", + world.auth_error + ); + + let config = Config::load().expect("Could not load config"); + + for token in world.tokens.iter() { + helpers::assert::assert_token_is_valid(token, &config.jwt_secret); + } +} + +#[then(expr = "the hash is only in the database once")] +async fn no_double_auth(world: &mut AuthenticationWorld) { + // In other test scenarios we might do this when we init the world, but + // given authentication only needs this once this is fine + let config = Config::load().expect("Could not load config"); + let infra = Infrastructure::new(&config) + .await + .expect("Could not init DB"); + + // User still registered + let row = sqlx::query("SELECT COUNT(*) FROM users WHERE client_hash = $1") + .bind(&world.client_hash) + .fetch_one(&mut *infra.repository().await.expect("could not connect to DB")) + .await + .unwrap(); + + let count: i64 = row.try_get("count").expect("Failed to get count"); + + // Only appears in db once + assert_eq!(count, 1); +} + +#[tokio::main] +async fn main() { + dotenv::from_filename("test.env").ok(); + + AuthenticationWorld::cucumber() + .repeat_skipped() + .init_tracing() + .run_and_exit("tests/features/user/authentication.feature") + .await +} diff --git a/tests/chart.rs b/tests/chart.rs new file mode 100644 index 00000000..43fc839e --- /dev/null +++ b/tests/chart.rs @@ -0,0 +1,252 @@ +#![cfg(test)] + +use cucumber::{given, then, when, Parameter, World}; +use futures::FutureExt; +use helpers::client::*; +use rand::{thread_rng, Rng}; +use ratings::{ + features::{ + common::entities::{calculate_band, VoteSummary}, + pb::chart::{Category, ChartData, Timeframe}, + }, + utils::{Config, Infrastructure}, +}; +use sqlx::Connection; +use strum::EnumString; + +mod helpers; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Parameter, EnumString)] +#[param(name = "category", regex = "Utilities|Development")] +pub enum TestCategory { + Utilities, + Development, +} + +impl From for Category { + fn from(value: TestCategory) -> Self { + match value { + TestCategory::Development => Self::Development, + TestCategory::Utilities => Self::Utilities, + } + } +} + +#[derive(Debug, World)] +#[world(init = Self::new)] +struct ChartWorld { + token: String, + snap_ids: Vec, + test_snap: String, + client: TestClient, + chart_data: Vec, +} + +impl ChartWorld { + async fn new() -> Self { + let config = Config::load().expect("could not load config"); + let client = TestClient::new(config.socket()); + + let token = client + .authenticate(&helpers::data_faker::rnd_sha_256()) + .await + .expect("could not authenticate test client") + .into_inner() + .token; + + Self { + snap_ids: Vec::with_capacity(25), + test_snap: Default::default(), + chart_data: Vec::new(), + client, + token, + } + } +} + +#[given(expr = "a snap with id {string} gets {int} votes where {int} are upvotes")] +async fn set_test_snap(world: &mut ChartWorld, snap_id: String, votes: usize, upvotes: usize) { + world.test_snap = snap_id; + + helpers::vote_generator::generate_votes( + &world.test_snap, + 1, + true, + upvotes as u64, + &world.client, + ) + .await + .expect("could not generate votes"); + + tracing::debug!("done generating upvotes"); + + helpers::vote_generator::generate_votes( + &world.test_snap, + 1, + false, + (votes - upvotes) as u64, + &world.client, + ) + .await + .expect("could not generate votes"); + + tracing::debug!("done generating downvotes"); +} + +#[given( + expr = "{int} test snaps gets between {int} and {int} votes, where {int} to {int} are upvotes" +)] +async fn generate_snaps( + world: &mut ChartWorld, + num_snaps: usize, + min_vote: usize, + max_vote: usize, + min_upvote: usize, + max_upvote: usize, +) { + let mut expected = Vec::with_capacity(num_snaps); + + for i in 1..=num_snaps { + tracing::debug!("starting snap {i} / {num_snaps}"); + + let (upvotes, votes) = { + let mut rng = thread_rng(); + + let upvotes = rng.gen_range(min_upvote..max_upvote); + let min_vote = Ord::max(upvotes, min_vote); + let votes = rng.gen_range(min_vote..=max_vote); + (upvotes, votes) + }; + + let id = helpers::data_faker::rnd_id(); + + helpers::vote_generator::generate_votes(&id, 1, true, upvotes as u64, &world.client) + .await + .expect("could not generate votes"); + + tracing::debug!("done generating upvotes ({i} / {num_snaps})"); + + helpers::vote_generator::generate_votes( + &id, + 1, + false, + (votes - upvotes) as u64, + &world.client, + ) + .await + .expect("could not generate votes"); + + tracing::debug!("done generating downvotes ({i} / {num_snaps})"); + + let summary = VoteSummary { + snap_id: id, + total_votes: votes as i64, + positive_votes: upvotes as i64, + }; + + expected.push((calculate_band(&summary).0.unwrap(), summary.snap_id)); + } + + expected.sort_unstable_by(|(band1, _), (band2, _)| band1.partial_cmp(band2).unwrap().reverse()); + world.snap_ids.extend(expected.drain(..).map(|(band, id)| { + tracing::debug!("id: {id}; band: {band}"); + id + })); +} + +#[when(expr = "the client fetches the top snaps")] +async fn get_chart(world: &mut ChartWorld) { + get_chart_internal(world, None).await; +} + +#[when(expr = "the client fetches the top snaps for {category}")] +async fn get_chart_of_category(world: &mut ChartWorld, category: TestCategory) { + get_chart_internal(world, Some(category.into())).await; +} + +async fn get_chart_internal(world: &mut ChartWorld, category: Option) { + world.chart_data = world + .client + .get_chart_of_category(Timeframe::Unspecified, category, &world.token) + .await + .expect("couldn't get chart") + .into_inner() + .ordered_chart_data; +} + +#[then(expr = "the top {int} snaps are returned in the proper order")] +async fn chart_order(world: &mut ChartWorld, top: usize) { + assert_eq!(world.chart_data.len(), top); + + assert!(world + .chart_data + .iter() + .zip(world.snap_ids.iter()) + .all(|(data, id)| { + let left = &data + .rating + .as_ref() + .expect("no rating in chart data?") + .snap_id; + + tracing::debug!("chart data: {data:?}, expected: {id}"); + + left == id + })) +} + +#[then(expr = "the top snap returned is the one with the ID {string}")] +async fn check_test_snap(world: &mut ChartWorld, snap_id: String) { + assert_eq!( + world.test_snap, snap_id, + "feature file and test snap definition got out of sync" + ); + + assert_eq!( + &world.chart_data[0].rating.as_ref().unwrap().snap_id, + &snap_id, + "top chart result is not test snap" + ); +} + +/// Automatically clears and snaps with >= TO_CLEAR votes, preventing them from interfering with tests +/// Being independent, while also not affecting other tests that require lower vote counts +async fn clear_db() { + const TO_CLEAR: usize = 3; + + let config = Config::load().unwrap(); + let infra = Infrastructure::new(&config).await.unwrap(); + let mut conn = infra.repository().await.unwrap(); + + let mut tx = conn.begin().await.unwrap(); + + sqlx::query( + r#"DELETE FROM votes WHERE snap_id IN + (SELECT snap_id FROM votes GROUP BY snap_id HAVING COUNT(*) >= $1) + "#, + ) + .bind(TO_CLEAR as i64) + .execute(&mut *tx) + .await + .unwrap(); + + sqlx::query("TRUNCATE TABLE snap_categories") + .execute(&mut *tx) + .await + .unwrap(); + + tx.commit().await.unwrap(); +} + +#[tokio::main] +async fn main() { + dotenv::from_filename("test.env").ok(); + + ChartWorld::cucumber() + .before(|_, _, _, _| clear_db().boxed_local()) + .repeat_failed() + .init_tracing() + .max_concurrent_scenarios(1) + .run_and_exit("tests/features/chart.feature") + .await +} diff --git a/tests/chart_tests/category.rs b/tests/chart_tests/category.rs deleted file mode 100644 index 00ad8bd7..00000000 --- a/tests/chart_tests/category.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! These tests require *specific* snaps because they do `snapd` lookups, so we can't -//! use the data-faked tests for this -//! -//! Warning! This actually causes problems if the number of votes is too big because the other -//! tests use the same DB and use *randomized* data, make sure you don't vote *too* much -//! on the test snap and break things. - -use futures::FutureExt; -use ratings::{ - app::AppContext, - features::pb::{ - chart::{Category, Timeframe}, - user::{AuthenticateResponse, VoteRequest}, - }, - utils::{Config, Infrastructure}, -}; - -use crate::{ - clear_test_snap, - helpers::{ - self, client_app::AppClient, client_chart::ChartClient, client_user::UserClient, - test_data::TestData, vote_generator::generate_votes, with_lifecycle::with_lifecycle, - }, - CLEAR_TEST_SNAP, -}; - -use super::super::{TESTING_SNAP_CATEGORIES, TESTING_SNAP_ID}; - -#[tokio::test] -async fn category_chart_filtering() -> Result<(), Box> { - let config = Config::load()?; - let infra = Infrastructure::new(&config).await?; - let app_ctx = AppContext::new(&config, infra); - - CLEAR_TEST_SNAP.get_or_init(clear_test_snap).await; - - let data = TestData { - user_client: Some(UserClient::new(&config.socket())), - app_ctx, - id: None, - token: None, - app_client: Some(AppClient::new(&config.socket())), - snap_id: Some(TESTING_SNAP_ID.to_string()), - chart_client: Some(ChartClient::new(&config.socket())), - categories: Some(TESTING_SNAP_CATEGORIES.iter().cloned().collect()), - }; - - with_lifecycle(async { - vote(data) - .then(multiple_votes) - .then(is_in_right_category) - .then(is_not_in_wrong_category) - .await; - }) - .await; - Ok(()) -} - -/// Run a regular vote so that category information is gotten -async fn vote(mut data: TestData) -> TestData { - let id: String = helpers::data_faker::rnd_sha_256(); - data.id = Some(id.to_string()); - - let client = data.user_client.clone().unwrap(); - let response: AuthenticateResponse = client.authenticate(&id).await.unwrap().into_inner(); - let token: String = response.token; - data.token = Some(token.to_string()); - - let token = data.token.clone().unwrap(); - let client = data.user_client.clone().unwrap(); - - let ballet = VoteRequest { - snap_id: data.snap_id.clone().unwrap(), - snap_revision: 2, - vote_up: true, - }; - - client - .vote(&token, ballet) - .await - .expect("vote should succeed") - .into_inner(); - data -} - -// Does an app voted against multiple times appear correctly in the chart? -pub async fn multiple_votes(data: TestData) -> TestData { - // This should rank our snap_id at the top of the chart, but only for our category - generate_votes(&data.snap_id.clone().unwrap(), 111, true, 50, data.clone()) - .await - .expect("Votes should succeed"); - - data -} - -async fn is_in_right_category(data: TestData) -> TestData { - for category in data.categories.as_ref().unwrap() { - let chart_data_result = data - .chart_client - .as_ref() - .unwrap() - .get_chart_of_category( - Timeframe::Unspecified, - Some(*category), - &data.token.clone().unwrap(), - ) - .await - .expect("Get Chart should succeed") - .into_inner() - .ordered_chart_data; - - assert!(chart_data_result - .into_iter() - .filter_map(|v| v.rating.map(|v| v.snap_id)) - .any(|v| &v == data.snap_id.as_ref().unwrap())); - } - - data -} - -async fn is_not_in_wrong_category(data: TestData) -> TestData { - debug_assert!(!data - .categories - .as_ref() - .unwrap() - .contains(&Category::ArtAndDesign)); - - let chart_data_result = data - .chart_client - .as_ref() - .unwrap() - .get_chart_of_category( - Timeframe::Unspecified, - Some(Category::ArtAndDesign), - &data.token.clone().unwrap(), - ) - .await - .expect("Get Chart should succeed") - .into_inner() - .ordered_chart_data; - - assert!(chart_data_result - .into_iter() - .filter_map(|v| v.rating.map(|v| v.snap_id)) - .all(|v| &v != data.snap_id.as_ref().unwrap())); - - data -} diff --git a/tests/chart_tests/lifecycle_test.rs b/tests/chart_tests/lifecycle_test.rs deleted file mode 100644 index 237a9503..00000000 --- a/tests/chart_tests/lifecycle_test.rs +++ /dev/null @@ -1,233 +0,0 @@ -use futures::FutureExt; -use ratings::{ - app::AppContext, - features::pb::{ - chart::Timeframe, - common::{Rating, RatingsBand}, - user::AuthenticateResponse, - }, - utils::{Config, Infrastructure}, -}; - -use super::super::helpers::with_lifecycle::with_lifecycle; -use crate::helpers::vote_generator::generate_votes; -use crate::helpers::{self, client_app::AppClient}; -use crate::helpers::{client_chart::ChartClient, test_data::TestData}; -use crate::helpers::{client_user::UserClient, data_faker}; - -#[tokio::test] -async fn chart_lifecycle_test() -> Result<(), Box> { - let config = Config::load()?; - let infra = Infrastructure::new(&config).await?; - let app_ctx = AppContext::new(&config, infra); - - let data = TestData { - user_client: Some(UserClient::new(&config.socket())), - app_ctx, - id: None, - token: None, - app_client: Some(AppClient::new(&config.socket())), - snap_id: Some(data_faker::rnd_id()), - chart_client: Some(ChartClient::new(&config.socket())), - categories: None, - }; - - with_lifecycle(async { - vote_once(data.clone()) - .then(multiple_votes) - .then(timeframed_votes_dont_appear) - .await; - }) - .await; - Ok(()) -} - -// Does an app voted against once appear correctly in the chart? -async fn vote_once(mut data: TestData) -> TestData { - let vote_up = true; - - // Fill up chart with other votes so ours doesn't appear - for _ in 0..20 { - generate_votes(&data_faker::rnd_id(), 111, vote_up, 25, data.clone()) - .await - .expect("Votes should succeed"); - } - - let vote_up = true; - - generate_votes( - &data.snap_id.clone().unwrap(), - 111, - vote_up, - 1, - data.clone(), - ) - .await - .expect("Votes should succeed"); - - let id: String = helpers::data_faker::rnd_sha_256(); - data.id = Some(id.to_string()); - - let client = data.user_client.clone().unwrap(); - let response: AuthenticateResponse = client.authenticate(&id).await.unwrap().into_inner(); - let token: String = response.token; - data.token = Some(token.to_string()); - - let timeframe = Timeframe::Unspecified; - - let chart_data_result = data - .clone() - .chart_client - .unwrap() - .get_chart(timeframe, &data.token.clone().unwrap()) - .await - .expect("Get Chart should succeed") - .into_inner() - .ordered_chart_data; - - let result = chart_data_result.into_iter().find(|chart_data| { - if let Some(rating) = &chart_data.rating { - rating.snap_id == data.snap_id.clone().unwrap() - } else { - false - } - }); - - // Should not appear in chart - assert_eq!(result, None); - - data -} - -// Does an app voted against multiple times appear correctly in the chart? -async fn multiple_votes(mut data: TestData) -> TestData { - let vote_up = true; - let expected_raw_rating = 0.8; - let expected_rating = Rating { - snap_id: data.snap_id.clone().unwrap(), - total_votes: 101, - ratings_band: RatingsBand::VeryGood.into(), - }; - - // This should rank our snap_id at the top of the chart - generate_votes( - &data.snap_id.clone().unwrap(), - 111, - vote_up, - 100, - data.clone(), - ) - .await - .expect("Votes should succeed"); - - let id: String = helpers::data_faker::rnd_sha_256(); - data.id = Some(id.to_string()); - - let client = data.user_client.clone().unwrap(); - let response: AuthenticateResponse = client.authenticate(&id).await.unwrap().into_inner(); - let token: String = response.token; - data.token = Some(token.to_string()); - - let timeframe = Timeframe::Unspecified; - - let chart_data_result = data - .clone() - .chart_client - .unwrap() - .get_chart(timeframe, &data.token.clone().unwrap()) - .await - .expect("Get Chart should succeed") - .into_inner() - .ordered_chart_data; - - // Should be at the top of the chart - if let Some(chart_data) = chart_data_result.first() { - let actual_rating = chart_data.rating.clone().expect("Rating should exist"); - let actual_raw_rating = chart_data.raw_rating; - - assert_eq!(expected_rating, actual_rating); - assert!(expected_raw_rating < actual_raw_rating); - } else { - panic!("No chart data available"); - } - - data -} - -// Does the Timeframe correctly filter out app data? -async fn timeframed_votes_dont_appear(mut data: TestData) -> TestData { - let mut conn = data.repository().await.unwrap(); - - // Timewarp the votes back two months so they are out of the requested timeframe - sqlx::query("UPDATE votes SET created = created - INTERVAL '2 months' WHERE snap_id = $1") - .bind(&data.snap_id.clone().unwrap()) - .execute(&mut *conn) - .await - .unwrap(); - - let id: String = helpers::data_faker::rnd_sha_256(); - data.id = Some(id.to_string()); - - let client = data.user_client.clone().unwrap(); - let response: AuthenticateResponse = client.authenticate(&id).await.unwrap().into_inner(); - let token: String = response.token; - data.token = Some(token.to_string()); - - let timeframe = Timeframe::Month; - - let chart_data_result = data - .clone() - .chart_client - .unwrap() - .get_chart(timeframe, &data.token.clone().unwrap()) - .await - .expect("Get Chart should succeed") - .into_inner() - .ordered_chart_data; - - let result = chart_data_result.into_iter().find(|chart_data| { - if let Some(rating) = &chart_data.rating { - rating.snap_id == data.snap_id.clone().unwrap() - } else { - false - } - }); - - // Should no longer find the ratings as they are too old - assert_eq!(result, None); - - let expected_raw_rating = 0.8; - let expected_rating = Rating { - snap_id: data.snap_id.clone().unwrap(), - total_votes: 101, - ratings_band: RatingsBand::VeryGood.into(), - }; - - // Unspecified timeframe should now pick up the ratings again - let timeframe = Timeframe::Unspecified; - let chart_data_result = data - .clone() - .chart_client - .unwrap() - .get_chart(timeframe, &data.token.clone().unwrap()) - .await - .expect("Get Chart should succeed") - .into_inner() - .ordered_chart_data; - - let result = chart_data_result.into_iter().find(|chart_data| { - if let Some(rating) = &chart_data.rating { - rating.snap_id == data.snap_id.clone().unwrap() - } else { - false - } - }); - - let actual_rating = result.clone().unwrap().rating.unwrap(); - let actual_raw_rating = result.unwrap().raw_rating; - - assert_eq!(expected_rating, actual_rating); - assert!(expected_raw_rating < actual_raw_rating); - - data -} diff --git a/tests/features/chart.feature b/tests/features/chart.feature new file mode 100644 index 00000000..3f0daaf4 --- /dev/null +++ b/tests/features/chart.feature @@ -0,0 +1,17 @@ +Feature: List of top 20 snaps + Background: + Given a snap with id "3Iwi803Tk3KQwyD6jFiAJdlq8MLgBIoD" gets 100 votes where 75 are upvotes + Given 25 test snaps gets between 150 and 200 votes, where 125 to 175 are upvotes + + Scenario: Tails opens the store homepage, seeing the top snaps + When the client fetches the top snaps + Then the top 20 snaps are returned in the proper order + + Scenario Outline: Tails opens a few store categories, retrieving the top chart for those snaps + When the client fetches the top snaps for + Then the top snap returned is the one with the ID "3Iwi803Tk3KQwyD6jFiAJdlq8MLgBIoD" + + Examples: + | category | + | Utilities | + | Development | \ No newline at end of file diff --git a/tests/features/user/authentication.feature b/tests/features/user/authentication.feature new file mode 100644 index 00000000..9aa6ee7d --- /dev/null +++ b/tests/features/user/authentication.feature @@ -0,0 +1,24 @@ +Feature: User authentication + + Scenario: The Snap Store tries to authenticate + Given a valid client hash + When the client attempts to authenticate + Then the returned token is valid + + Rule: Client hashes must be exactly 64 characters long + Scenario Outline: Eggman tries to directly rate a Snap with an unofficial client with an improper hardcoded "hash" + Given a bad client with the hash + When the client attempts to authenticate + Then the authentication is rejected + + Examples: + | hash | + | notarealhash | + | abcdefghijkabcdefghijkabcdefghijkabcdefghijkabcdefghijkabcdefghijk | + + Scenario: Charmy's client authenticates twice + Given a valid client hash + Given an authenticated client + When that client authenticates a second time + Then both tokens are valid + And the hash is only in the database once diff --git a/tests/features/user/voting.feature b/tests/features/user/voting.feature new file mode 100644 index 00000000..85434f59 --- /dev/null +++ b/tests/features/user/voting.feature @@ -0,0 +1,22 @@ +Feature: User voting + Background: + Given a Snap named "chu-chu-garden" has already accumulated 5 votes and 3 upvotes + + Scenario: Amy upvotes a snap she hasn't voted for in the past + When Amy casts an upvote + Then the total number of votes strictly increases + And the ratings band monotonically increases + + Rule: Votes that a user updates do not change the total vote count + + Scenario Outline: Sonic changes his vote between downvote and upvote because "chu-chu-garden" got better/worse + Given Sonic originally voted + When Sonic changes his vote to + Then the ratings band + But the total number of votes stays constant + + Examples: + | original | after | direction | + | upvote | downvote | monotonically increases | + | downvote | upvote | monotonically decreases | + diff --git a/tests/helpers/client_app.rs b/tests/helpers/client/app.rs similarity index 65% rename from tests/helpers/client_app.rs rename to tests/helpers/client/app.rs index 37785a57..0c27bdaf 100644 --- a/tests/helpers/client_app.rs +++ b/tests/helpers/client/app.rs @@ -1,25 +1,20 @@ +use tonic::async_trait; use tonic::{metadata::MetadataValue, transport::Endpoint, Request, Response, Status}; -use ratings::features::pb::app::{app_client as pb, GetRatingRequest, GetRatingResponse}; +use ratings::features::pb::app::{GetRatingRequest, GetRatingResponse}; -#[derive(Debug, Clone)] -pub struct AppClient { - url: String, -} +use ratings::features::pb::app::app_client as pb; -impl AppClient { - pub fn new(socket: &str) -> Self { - Self { - url: format!("http://{socket}/"), - } - } +use super::Client; - pub async fn get_rating( +#[async_trait] +pub trait AppClient: Client { + async fn get_rating( &self, - id: &str, token: &str, + id: &str, ) -> Result, Status> { - let channel = Endpoint::from_shared(self.url.clone()) + let channel = Endpoint::from_shared(self.url().to_string()) .unwrap() .connect() .await diff --git a/tests/helpers/client/chart.rs b/tests/helpers/client/chart.rs new file mode 100644 index 00000000..70cb20f4 --- /dev/null +++ b/tests/helpers/client/chart.rs @@ -0,0 +1,58 @@ +use tonic::metadata::MetadataValue; +use tonic::transport::Endpoint; +use tonic::{async_trait, Request, Response, Status}; + +use ratings::features::pb::chart::{chart_client as pb, Category, Timeframe}; +use ratings::features::pb::chart::{GetChartRequest, GetChartResponse}; + +use super::Client; + +#[async_trait] +pub trait ChartClient: Client { + async fn get_chart( + &self, + timeframe: Timeframe, + token: &str, + ) -> Result, Status> { + let channel = Endpoint::from_shared(self.url().to_string()) + .unwrap() + .connect() + .await + .unwrap(); + let mut client = pb::ChartClient::with_interceptor(channel, move |mut req: Request<()>| { + let header: MetadataValue<_> = format!("Bearer {token}").parse().unwrap(); + req.metadata_mut().insert("authorization", header); + Ok(req) + }); + client + .get_chart(GetChartRequest { + timeframe: timeframe.into(), + category: None, + }) + .await + } + + async fn get_chart_of_category( + &self, + timeframe: Timeframe, + category: Option, + token: &str, + ) -> Result, Status> { + let channel = Endpoint::from_shared(self.url().to_string()) + .unwrap() + .connect() + .await + .unwrap(); + let mut client = pb::ChartClient::with_interceptor(channel, move |mut req: Request<()>| { + let header: MetadataValue<_> = format!("Bearer {token}").parse().unwrap(); + req.metadata_mut().insert("authorization", header); + Ok(req) + }); + client + .get_chart(GetChartRequest { + timeframe: timeframe.into(), + category: category.map(|v| v.into()), + }) + .await + } +} diff --git a/tests/helpers/client/mod.rs b/tests/helpers/client/mod.rs new file mode 100644 index 00000000..bd9ee928 --- /dev/null +++ b/tests/helpers/client/mod.rs @@ -0,0 +1,35 @@ +pub mod app; +pub mod chart; +pub mod user; + +use std::fmt::Display; + +pub use self::{app::AppClient, chart::ChartClient, user::UserClient}; + +pub trait Client { + fn url(&self) -> &str; +} + +#[derive(Debug, Clone)] +pub struct TestClient { + url: String, +} + +impl TestClient { + pub fn new(url: D) -> Self { + Self { + url: format!("http://{}/", url), + } + } +} + +impl Client for TestClient { + #[inline(always)] + fn url(&self) -> &str { + &self.url + } +} + +impl AppClient for TestClient {} +impl ChartClient for TestClient {} +impl UserClient for TestClient {} diff --git a/tests/helpers/client_user.rs b/tests/helpers/client/user.rs similarity index 65% rename from tests/helpers/client_user.rs rename to tests/helpers/client/user.rs index b338d1dc..940cef91 100644 --- a/tests/helpers/client_user.rs +++ b/tests/helpers/client/user.rs @@ -1,6 +1,6 @@ use tonic::metadata::MetadataValue; use tonic::transport::Endpoint; -use tonic::{Request, Response, Status}; +use tonic::{async_trait, Request, Response, Status}; use ratings::features::pb::user::user_client as pb; use ratings::features::pb::user::{ @@ -8,29 +8,12 @@ use ratings::features::pb::user::{ VoteRequest, }; -#[derive(Debug, Clone)] -pub struct UserClient { - url: String, -} - -impl UserClient { - pub fn new(socket: &str) -> Self { - Self { - url: format!("http://{socket}/"), - } - } - - #[allow(dead_code)] - pub async fn authenticate(&self, id: &str) -> Result, Status> { - let mut client = pb::UserClient::connect(self.url.clone()).await.unwrap(); - client - .authenticate(AuthenticateRequest { id: id.to_string() }) - .await - } +use super::Client; - #[allow(dead_code)] - pub async fn vote(&self, token: &str, ballet: VoteRequest) -> Result, Status> { - let channel = Endpoint::from_shared(self.url.clone()) +#[async_trait] +pub trait UserClient: Client { + async fn vote(&self, token: &str, ballet: VoteRequest) -> Result, Status> { + let channel = Endpoint::from_shared(self.url().to_string()) .unwrap() .connect() .await @@ -43,13 +26,12 @@ impl UserClient { client.vote(ballet).await } - #[allow(dead_code)] - pub async fn get_snap_votes( + async fn get_snap_votes( &self, token: &str, request: GetSnapVotesRequest, ) -> Result, Status> { - let channel = Endpoint::from_shared(self.url.clone()) + let channel = Endpoint::from_shared(self.url().to_string()) .unwrap() .connect() .await @@ -62,9 +44,8 @@ impl UserClient { client.get_snap_votes(request).await } - #[allow(dead_code)] - pub async fn delete(&self, token: &str) -> Result, Status> { - let channel = Endpoint::from_shared(self.url.clone()) + async fn delete(&self, token: &str) -> Result, Status> { + let channel = Endpoint::from_shared(self.url().to_string()) .unwrap() .connect() .await @@ -77,4 +58,13 @@ impl UserClient { client.delete(()).await } + + async fn authenticate(&self, id: &str) -> Result, Status> { + let mut client = pb::UserClient::connect(self.url().to_string()) + .await + .unwrap(); + client + .authenticate(AuthenticateRequest { id: id.to_string() }) + .await + } } diff --git a/tests/helpers/client_chart.rs b/tests/helpers/client_chart.rs deleted file mode 100644 index 04963a7d..00000000 --- a/tests/helpers/client_chart.rs +++ /dev/null @@ -1,52 +0,0 @@ -use tonic::metadata::MetadataValue; -use tonic::transport::Endpoint; -use tonic::{Request, Response, Status}; - -use ratings::features::pb::chart::{ - chart_client as pb, Category, GetChartRequest, GetChartResponse, Timeframe, -}; - -#[derive(Debug, Clone)] -pub struct ChartClient { - url: String, -} - -impl ChartClient { - pub fn new(socket: &str) -> Self { - Self { - url: format!("http://{socket}/"), - } - } - - pub async fn get_chart_of_category( - &self, - timeframe: Timeframe, - category: Option, - token: &str, - ) -> Result, Status> { - let channel = Endpoint::from_shared(self.url.clone()) - .unwrap() - .connect() - .await - .unwrap(); - let mut client = pb::ChartClient::with_interceptor(channel, move |mut req: Request<()>| { - let header: MetadataValue<_> = format!("Bearer {token}").parse().unwrap(); - req.metadata_mut().insert("authorization", header); - Ok(req) - }); - client - .get_chart(GetChartRequest { - timeframe: timeframe.into(), - category: category.map(|v| v.into()), - }) - .await - } - - pub async fn get_chart( - &self, - timeframe: Timeframe, - token: &str, - ) -> Result, Status> { - self.get_chart_of_category(timeframe, None, token).await - } -} diff --git a/tests/helpers/hooks.rs b/tests/helpers/hooks.rs deleted file mode 100644 index 525d307d..00000000 --- a/tests/helpers/hooks.rs +++ /dev/null @@ -1,11 +0,0 @@ -use std::sync::Once; - -static INIT: Once = Once::new(); - -pub async fn before_all() { - INIT.call_once(|| { - tracing_subscriber::fmt().init(); - }); -} - -pub async fn after_all() {} diff --git a/tests/helpers/mod.rs b/tests/helpers/mod.rs index ff410932..7db13580 100644 --- a/tests/helpers/mod.rs +++ b/tests/helpers/mod.rs @@ -1,9 +1,6 @@ +#![allow(dead_code)] + pub mod assert; -pub mod client_app; -pub mod client_chart; -pub mod client_user; +pub mod client; pub mod data_faker; -pub mod hooks; -pub mod test_data; pub mod vote_generator; -pub mod with_lifecycle; diff --git a/tests/helpers/test_data.rs b/tests/helpers/test_data.rs deleted file mode 100644 index 88297c9b..00000000 --- a/tests/helpers/test_data.rs +++ /dev/null @@ -1,32 +0,0 @@ -use std::collections::HashSet; - -use ratings::app::AppContext; -use sqlx::{pool::PoolConnection, Postgres}; - -use ratings::features::pb::chart::Category; - -use super::client_app::AppClient; -use super::client_chart::ChartClient; -use super::client_user::UserClient; - -#[derive(Debug, Clone)] -pub struct TestData { - pub user_client: Option, - pub app_client: Option, - pub chart_client: Option, - pub id: Option, - pub snap_id: Option, - pub token: Option, - pub app_ctx: AppContext, - pub categories: Option>, -} - -impl TestData { - pub async fn repository(&self) -> Result, sqlx::Error> { - self.app_ctx.clone().infrastructure().repository().await - } - - pub fn socket(&self) -> String { - self.app_ctx.config().socket() - } -} diff --git a/tests/helpers/vote_generator.rs b/tests/helpers/vote_generator.rs index 00ed6396..001e973f 100644 --- a/tests/helpers/vote_generator.rs +++ b/tests/helpers/vote_generator.rs @@ -1,17 +1,58 @@ -use super::test_data::TestData; +use std::sync::Arc; + +use super::client::*; use crate::helpers; +use futures::future::join_all; use ratings::features::pb::user::{AuthenticateResponse, VoteRequest}; +use thiserror::Error; +use tonic::Status; + +#[derive(Clone, Debug, Error)] +pub enum GenerateVoteError { + #[error("there was a panic while attempting to authenticate the votes: {0}")] + Panic(String), + #[error("there was a negative response from the server: {0}")] + Status(#[from] Status), +} + +impl From for GenerateVoteError { + fn from(value: String) -> Self { + Self::Panic(value) + } +} pub async fn generate_votes( snap_id: &str, snap_revision: i32, vote_up: bool, count: u64, - data: TestData, -) -> Result<(), Box> { + client: &TestClient, +) -> Result<(), GenerateVoteError> { + let mut joins = Vec::with_capacity(count as usize); + + let snap_id = Arc::new(snap_id.to_string()); + let client = Arc::new(client.clone()); for _ in 0..count { - register_and_vote(snap_id, snap_revision, vote_up, data.clone()).await?; + let snap_id = snap_id.clone(); + let client = client.clone(); + joins.push(tokio::spawn(async move { + register_and_vote(&snap_id, snap_revision, vote_up, &client).await + })); } + + for join in join_all(joins).await { + join.map_err(|e| { + if e.is_panic() { + e.into_panic() + .downcast::<&'static str>() + .unwrap() + .to_string() + } else { + format!("other error: {}", e) + } + })?? + } + Ok(()) } @@ -19,9 +60,8 @@ async fn register_and_vote( snap_id: &str, snap_revision: i32, vote_up: bool, - data: TestData, -) -> Result<(), Box> { - let client = data.user_client.clone().unwrap(); + client: &TestClient, +) -> Result<(), Status> { let id: String = helpers::data_faker::rnd_sha_256(); let response: AuthenticateResponse = client .authenticate(&id) diff --git a/tests/helpers/with_lifecycle.rs b/tests/helpers/with_lifecycle.rs deleted file mode 100644 index 9dae81b3..00000000 --- a/tests/helpers/with_lifecycle.rs +++ /dev/null @@ -1,12 +0,0 @@ -use std::future::Future; - -use crate::helpers::hooks::{after_all, before_all}; - -pub async fn with_lifecycle(f: F) -where - F: Future, -{ - before_all().await; - f.await; - after_all().await; -} diff --git a/tests/mod.rs b/tests/mod.rs deleted file mode 100644 index 4a8bdbea..00000000 --- a/tests/mod.rs +++ /dev/null @@ -1,57 +0,0 @@ -use ratings::{ - app::AppContext, - features::pb::chart::Category, - utils::{Config, Infrastructure}, -}; -use sqlx::Connection; -use tokio::sync::OnceCell; - -const TESTING_SNAP_ID: &str = "3Iwi803Tk3KQwyD6jFiAJdlq8MLgBIoD"; -const TESTING_SNAP_CATEGORIES: [Category; 2] = [Category::Utilities, Category::Development]; - -/// Call [`clear_test_snap`] with this at the start of category tests to clear the Test snap info, -/// this prevents it from polluting other integration tests by repeated runs eventually outstripping -/// the random data. -static CLEAR_TEST_SNAP: OnceCell<()> = OnceCell::const_new(); - -async fn clear_test_snap() { - let config = Config::load().unwrap(); - let infra = Infrastructure::new(&config).await.unwrap(); - let app_ctx = AppContext::new(&config, infra); - let mut conn = app_ctx.infrastructure().repository().await.unwrap(); - - let mut tx = conn.begin().await.unwrap(); - - sqlx::query("DELETE FROM votes WHERE snap_id = $1;") - .bind(TESTING_SNAP_ID) - .execute(&mut *tx) - .await - .unwrap(); - - sqlx::query("DELETE FROM snap_categories WHERE snap_id = $1;") - .bind(TESTING_SNAP_ID) - .execute(&mut *tx) - .await - .unwrap(); - - tx.commit().await.unwrap(); -} - -mod user_tests { - mod category; - mod double_authenticate_test; - mod get_votes_lifecycle_test; - mod reject_invalid_register_test; - mod simple_lifecycle_test; -} - -mod app_tests { - mod lifecycle_test; -} - -mod chart_tests { - mod category; - mod lifecycle_test; -} - -mod helpers; diff --git a/tests/user_tests/category.rs b/tests/user_tests/category.rs deleted file mode 100644 index 2e55ca04..00000000 --- a/tests/user_tests/category.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! These tests require *specific* snaps because they do `snapd` lookups, so we can't -//! use the data-faked tests for this - -use std::collections::HashSet; - -use futures::{FutureExt, StreamExt}; -use ratings::{ - app::AppContext, - features::pb::{ - chart::Category, - user::{GetSnapVotesRequest, VoteRequest}, - }, - utils::{Config, Infrastructure}, -}; -use sqlx::{pool::PoolConnection, Postgres, Row}; - -use super::simple_lifecycle_test::authenticate; -use crate::helpers::{ - client_user::UserClient, test_data::TestData, with_lifecycle::with_lifecycle, -}; -use crate::{clear_test_snap, CLEAR_TEST_SNAP}; - -use super::super::{TESTING_SNAP_CATEGORIES, TESTING_SNAP_ID}; - -// Test getting the categories after casting a vote on our test snap, we can't use random -// data since this makes an actual `snapd` request so we use an unlisted test snap with some -// predictable categories set. -#[tokio::test] -async fn category_on_cast_vote() -> Result<(), Box> { - let config = Config::load()?; - let infra = Infrastructure::new(&config).await?; - let app_ctx = AppContext::new(&config, infra); - - CLEAR_TEST_SNAP.get_or_init(clear_test_snap).await; - - let data = TestData { - user_client: Some(UserClient::new(&config.socket())), - app_ctx, - id: Some(TESTING_SNAP_ID.to_string()), - token: None, - app_client: None, - snap_id: Some(TESTING_SNAP_ID.to_string()), - chart_client: None, - categories: Some(TESTING_SNAP_CATEGORIES.iter().cloned().collect()), - }; - - with_lifecycle(async { - authenticate(data.clone()).then(vote).await; - }) - .await; - Ok(()) -} - -// Test getting the categories after getting the votes on our test snap, this isn't -// part of the earlier lifecycle because getting the votes is separate behavior from -// casting them and so it should be "clear" when we fetch the categories. -#[tokio::test] -async fn category_on_get_votes() -> Result<(), Box> { - let config = Config::load()?; - let infra = Infrastructure::new(&config).await?; - let app_ctx = AppContext::new(&config, infra); - - CLEAR_TEST_SNAP.get_or_init(clear_test_snap).await; - - let data = TestData { - user_client: Some(UserClient::new(&config.socket())), - app_ctx, - id: None, - token: None, - app_client: None, - snap_id: Some(TESTING_SNAP_ID.to_string()), - chart_client: None, - categories: Some(TESTING_SNAP_CATEGORIES.iter().cloned().collect()), - }; - - with_lifecycle(async { - authenticate(data.clone()).then(vote).then(get_votes).await; - }) - .await; - Ok(()) -} - -async fn vote(data: TestData) -> TestData { - let token = data.token.clone().unwrap(); - let client = data.user_client.clone().unwrap(); - - let ballet = VoteRequest { - snap_id: data.snap_id.clone().unwrap(), - snap_revision: 2, - vote_up: true, - }; - - client - .vote(&token, ballet) - .await - .expect("vote should succeed") - .into_inner(); - - vote_sets_category( - data.snap_id.as_ref().unwrap(), - &mut data.repository().await.unwrap(), - data.categories.as_ref().unwrap(), - ) - .await; - data -} - -async fn get_votes(data: TestData) -> TestData { - let token = data.token.clone().unwrap(); - let client = data.user_client.clone().unwrap(); - - let request = GetSnapVotesRequest { - snap_id: data.snap_id.clone().unwrap(), - }; - client - .get_snap_votes(&token, request) - .await - .expect("get votes should succeed"); - - vote_sets_category( - data.snap_id.as_ref().unwrap(), - &mut data.repository().await.unwrap(), - data.categories.as_ref().unwrap(), - ) - .await; - data -} - -async fn vote_sets_category( - snap_id: &str, - conn: &mut PoolConnection, - expected: &HashSet, -) { - let result = sqlx::query( - r#" - SELECT snap_categories.category - FROM snap_categories - WHERE snap_categories.snap_id = $1; - "#, - ) - .bind(snap_id) - .fetch(&mut **conn) - .map(|row| { - row.expect("error when retrieving row") - .try_get::("category") - .expect("could not get category field") - }) - .collect::>() - .await; - - assert_eq!(&result, expected); -} diff --git a/tests/user_tests/double_authenticate_test.rs b/tests/user_tests/double_authenticate_test.rs deleted file mode 100644 index 9b357876..00000000 --- a/tests/user_tests/double_authenticate_test.rs +++ /dev/null @@ -1,81 +0,0 @@ -use crate::helpers; -use crate::helpers::test_data::TestData; - -use super::super::helpers::client_user::UserClient; -use super::super::helpers::with_lifecycle::with_lifecycle; -use ratings::app::AppContext; -use ratings::features::pb::user::AuthenticateResponse; -use ratings::utils::{self, Infrastructure}; -use sqlx::Row; -use utils::Config; - -#[tokio::test] -async fn authenticate_twice_test() -> Result<(), Box> { - let config = Config::load()?; - let infra = Infrastructure::new(&config).await?; - let app_ctx = AppContext::new(&config, infra); - - let data = TestData { - user_client: Some(UserClient::new(&config.socket())), - app_ctx, - id: None, - token: None, - app_client: None, - snap_id: None, - chart_client: None, - categories: None, - }; - - with_lifecycle(async { - double_authenticate(data.clone()).await; - }) - .await; - Ok(()) -} - -async fn double_authenticate(data: TestData) { - let id: String = helpers::data_faker::rnd_sha_256(); - let client = data.user_client.clone().unwrap(); - - // First authenticate, registers user - let response: AuthenticateResponse = client - .authenticate(&id) - .await - .expect("authentication request should succeed") - .into_inner(); - - let first_token: String = response.token; - helpers::assert::assert_token_is_valid(&first_token, &data.app_ctx.config().jwt_secret); - - let mut conn = data.repository().await.unwrap(); - let rows = sqlx::query("SELECT * FROM users WHERE client_hash = $1") - .bind(&id) - .fetch_one(&mut *conn) - .await - .unwrap(); - - let actual: String = rows.get("client_hash"); - assert_eq!(actual, id); - - // Second authenticate - let response: AuthenticateResponse = client - .authenticate(&id) - .await - .expect("authentication request should succeed") - .into_inner(); - - let second_token: String = response.token; - helpers::assert::assert_token_is_valid(&second_token, &data.app_ctx.config().jwt_secret); - - // User still registered - let row = sqlx::query("SELECT COUNT(*) FROM users WHERE client_hash = $1") - .bind(&id) - .fetch_one(&mut *conn) - .await - .unwrap(); - - let count: i64 = row.try_get("count").expect("Failed to get count"); - - // Only appears in db once - assert_eq!(count, 1); -} diff --git a/tests/user_tests/get_votes_lifecycle_test.rs b/tests/user_tests/get_votes_lifecycle_test.rs deleted file mode 100644 index 7f302f0b..00000000 --- a/tests/user_tests/get_votes_lifecycle_test.rs +++ /dev/null @@ -1,191 +0,0 @@ -use crate::helpers; -use crate::helpers::test_data::TestData; - -use super::super::helpers::client_user::UserClient; -use super::super::helpers::with_lifecycle::with_lifecycle; -use futures::FutureExt; -use ratings::app::AppContext; -use ratings::features::pb::user::{AuthenticateResponse, GetSnapVotesRequest, VoteRequest}; -use ratings::utils::{self, Infrastructure}; -use sqlx::Row; - -use utils::Config; - -#[tokio::test] -async fn get_votes_lifecycle_test() -> Result<(), Box> { - let config = Config::load()?; - let infra = Infrastructure::new(&config).await?; - let app_ctx = AppContext::new(&config, infra); - - let data = TestData { - user_client: Some(UserClient::new(&config.socket())), - app_ctx, - id: None, - token: None, - app_client: None, - snap_id: None, - chart_client: None, - categories: None, - }; - - with_lifecycle(async { - authenticate(data.clone()) - .then(cast_vote) - .then(get_votes) - .await; - }) - .await; - Ok(()) -} - -async fn authenticate(mut data: TestData) -> TestData { - let id: String = helpers::data_faker::rnd_sha_256(); - data.id = Some(id.to_string()); - - let client = data.user_client.clone().unwrap(); - let response: AuthenticateResponse = client - .authenticate(&id) - .await - .expect("authentication request should succeed") - .into_inner(); - - let token: String = response.token; - data.token = Some(token.to_string()); - helpers::assert::assert_token_is_valid(&token, &data.app_ctx.config().jwt_secret); - - let mut conn = data.repository().await.unwrap(); - - let rows = sqlx::query("SELECT * FROM users WHERE client_hash = $1") - .bind(&id) - .fetch_one(&mut *conn) - .await - .unwrap(); - - let actual: String = rows.get("client_hash"); - - assert_eq!(actual, id); - - data -} - -async fn cast_vote(data: TestData) -> TestData { - let id = data.id.clone().unwrap(); - let token = data.token.clone().unwrap(); - let client = data.user_client.clone().unwrap(); - - let expected_snap_id = "r4LxMVp7zWramXsJQAKdamxy6TAWlaDD"; - let expected_snap_revision = 111; - let expected_vote_up = true; - - let ballet = VoteRequest { - snap_id: expected_snap_id.to_string(), - snap_revision: expected_snap_revision, - vote_up: expected_vote_up, - }; - - client - .vote(&token, ballet) - .await - .expect("vote should succeed") - .into_inner(); - - let mut conn = data.repository().await.unwrap(); - - let result = sqlx::query( - r#" - SELECT votes.* - FROM votes - JOIN users ON votes.user_id_fk = users.id - WHERE users.client_hash = $1 AND votes.snap_id = $2 AND votes.snap_revision = $3; - "#, - ) - .bind(&id) - .bind(expected_snap_id) - .bind(expected_snap_revision) - .fetch_one(&mut *conn) - .await - .unwrap(); - - let actual_snap_id: String = result.try_get("snap_id").unwrap(); - let actual_snap_revision: i32 = result.try_get("snap_revision").unwrap(); - let actual_vote_up: bool = result.try_get("vote_up").unwrap(); - - assert_eq!(actual_snap_id, expected_snap_id); - assert_eq!(actual_snap_revision, expected_snap_revision); - assert_eq!(actual_vote_up, expected_vote_up); - - let expected_snap_id = "r4LxMVp7zWramXsJQAKdamxy6TAWlaDD"; - let expected_snap_revision = 112; - let expected_vote_up = false; - - let ballet = VoteRequest { - snap_id: expected_snap_id.to_string(), - snap_revision: expected_snap_revision, - vote_up: expected_vote_up, - }; - - client - .vote(&token, ballet) - .await - .expect("vote should succeed") - .into_inner(); - - let result = sqlx::query( - r#" - SELECT votes.* - FROM votes - JOIN users ON votes.user_id_fk = users.id - WHERE users.client_hash = $1 AND votes.snap_id = $2 AND votes.snap_revision = $3; - "#, - ) - .bind(&id) - .bind(expected_snap_id) - .bind(expected_snap_revision) - .fetch_one(&mut *conn) - .await - .unwrap(); - - let actual_snap_id: String = result.try_get("snap_id").unwrap(); - let actual_snap_revision: i32 = result.try_get("snap_revision").unwrap(); - let actual_vote_up: bool = result.try_get("vote_up").unwrap(); - - assert_eq!(actual_snap_id, expected_snap_id); - assert_eq!(actual_snap_revision, expected_snap_revision); - assert_eq!(actual_vote_up, expected_vote_up); - - data -} - -async fn get_votes(data: TestData) -> TestData { - let token = data.token.clone().unwrap(); - let client = data.user_client.clone().unwrap(); - - let expected_snap_id = "r4LxMVp7zWramXsJQAKdamxy6TAWlaDD".to_string(); - let expected_first_revision = 111; - let expected_first_vote_up = true; - let expected_second_revision = 112; - let expected_second_vote_up = false; - - let request = GetSnapVotesRequest { - snap_id: expected_snap_id.clone(), - }; - let votes = client - .get_snap_votes(&token, request) - .await - .expect("get votes should succeed") - .into_inner() - .votes; - - let actual_snap_id = &votes[0].snap_id; - let actual_first_revision = votes[0].snap_revision; - let actual_first_vote_up = votes[0].vote_up; - let actual_second_revision = votes[1].snap_revision; - let actual_second_vote_up = votes[1].vote_up; - - assert_eq!(actual_snap_id, &expected_snap_id); - assert_eq!(actual_first_revision, expected_first_revision); - assert_eq!(actual_first_vote_up, expected_first_vote_up); - assert_eq!(actual_second_vote_up, expected_second_vote_up); - assert_eq!(actual_second_revision, expected_second_revision); - data -} diff --git a/tests/user_tests/reject_invalid_register_test.rs b/tests/user_tests/reject_invalid_register_test.rs deleted file mode 100644 index 4ef10529..00000000 --- a/tests/user_tests/reject_invalid_register_test.rs +++ /dev/null @@ -1,42 +0,0 @@ -use ratings::utils::Config; -use tonic::Code; - -use super::super::helpers::{client_user::UserClient, with_lifecycle::with_lifecycle}; - -#[tokio::test] -async fn blank() -> Result<(), Box> { - let config = Config::load()?; - - with_lifecycle(async { - let id = ""; - let client = UserClient::new(&config.socket()); - - match client.authenticate(id).await { - Ok(response) => panic!("expected Err but got Ok: {response:?}"), - Err(status) => { - assert_eq!(status.code(), Code::InvalidArgument) - } - } - }) - .await; - Ok(()) -} - -#[tokio::test] -async fn wrong_length() -> Result<(), Box> { - let config = Config::load()?; - - with_lifecycle(async { - let client_hash = "foobarbazbun"; - let client = UserClient::new(&config.socket()); - - match client.authenticate(client_hash).await { - Ok(response) => panic!("expected Err but got Ok: {response:?}"), - Err(status) => { - assert_eq!(status.code(), Code::InvalidArgument) - } - } - }) - .await; - Ok(()) -} diff --git a/tests/user_tests/simple_lifecycle_test.rs b/tests/user_tests/simple_lifecycle_test.rs deleted file mode 100644 index b22c855b..00000000 --- a/tests/user_tests/simple_lifecycle_test.rs +++ /dev/null @@ -1,139 +0,0 @@ -use crate::helpers; -use crate::helpers::test_data::TestData; -use futures::FutureExt; - -use super::super::helpers::client_user::UserClient; -use super::super::helpers::with_lifecycle::with_lifecycle; -use ratings::app::AppContext; -use ratings::features::pb::user::{AuthenticateResponse, VoteRequest}; -use ratings::utils::{self, Infrastructure}; -use sqlx::Row; - -use utils::Config; - -#[tokio::test] -async fn user_simple_lifecycle_test() -> Result<(), Box> { - let config = Config::load()?; - let infra = Infrastructure::new(&config).await?; - let app_ctx = AppContext::new(&config, infra); - - let data = TestData { - user_client: Some(UserClient::new(&config.socket())), - app_ctx, - id: None, - token: None, - app_client: None, - snap_id: None, - chart_client: None, - categories: None, - }; - - with_lifecycle(async { - authenticate(data.clone()).then(vote).then(delete).await; - }) - .await; - Ok(()) -} - -pub(super) async fn authenticate(mut data: TestData) -> TestData { - let id: String = helpers::data_faker::rnd_sha_256(); - data.id = Some(id.to_string()); - - let client = data.user_client.clone().unwrap(); - let response: AuthenticateResponse = client - .authenticate(&id) - .await - .expect("authentication request should succeed") - .into_inner(); - - let token: String = response.token; - data.token = Some(token.to_string()); - helpers::assert::assert_token_is_valid(&token, &data.app_ctx.config().jwt_secret); - - let mut conn = data.repository().await.unwrap(); - - let rows = sqlx::query("SELECT * FROM users WHERE client_hash = $1") - .bind(&id) - .fetch_one(&mut *conn) - .await - .unwrap(); - - let actual: String = rows.get("client_hash"); - - assert_eq!(actual, id); - - data -} - -async fn vote(data: TestData) -> TestData { - let id = data.id.clone().unwrap(); - let token = data.token.clone().unwrap(); - let client = data.user_client.clone().unwrap(); - - let expected_snap_id = "r4LxMVp7zWramXsJQAKdamxy6TAWlaDD"; - let expected_snap_revision = 111; - let expected_vote_up = true; - - let ballet = VoteRequest { - snap_id: expected_snap_id.to_string(), - snap_revision: expected_snap_revision, - vote_up: expected_vote_up, - }; - - client - .vote(&token, ballet) - .await - .expect("vote should succeed") - .into_inner(); - - let mut conn = data.repository().await.unwrap(); - - let result = sqlx::query( - r#" - SELECT votes.* - FROM votes - JOIN users ON votes.user_id_fk = users.id - WHERE users.client_hash = $1 AND votes.snap_id = $2 AND votes.snap_revision = $3; - "#, - ) - .bind(&id) - .bind(expected_snap_id) - .bind(expected_snap_revision) - .fetch_one(&mut *conn) - .await - .unwrap(); - - let actual_snap_id: String = result.try_get("snap_id").unwrap(); - let actual_snap_revision: i32 = result.try_get("snap_revision").unwrap(); - let actual_vote_up: bool = result.try_get("vote_up").unwrap(); - - assert_eq!(actual_snap_id, expected_snap_id); - assert_eq!(actual_snap_revision, expected_snap_revision); - assert_eq!(actual_vote_up, expected_vote_up); - - data -} - -async fn delete(data: TestData) -> TestData { - let token = data.token.clone().unwrap(); - let client = UserClient::new(&data.socket()); - client.delete(&token.clone()).await.unwrap(); - - let id = data.id.clone().unwrap(); - - let mut conn = data.repository().await.unwrap(); - - let result = sqlx::query("SELECT * FROM users WHERE client_hash = $1") - .bind(&id) - .fetch_one(&mut *conn) - .await; - - let Err(sqlx::Error::RowNotFound) = result else { - panic!( - "The user {} still exists in the database or there was a database error", - id - ); - }; - - data -} diff --git a/tests/voting.rs b/tests/voting.rs new file mode 100644 index 00000000..677c27a6 --- /dev/null +++ b/tests/voting.rs @@ -0,0 +1,263 @@ +use std::str::FromStr; + +use cucumber::{given, then, when, Parameter, World}; +use helpers::client::*; +use ratings::{ + features::{ + common::entities::{Rating, RatingsBand, VoteSummary}, + pb::user::VoteRequest, + }, + utils::Config, +}; +mod helpers; + +#[derive(Debug, Default)] +struct AuthenticatedUser { + token: String, +} + +#[derive(Debug, Default, Copy, Clone, Parameter, strum::EnumString)] +#[param(name = "vote-type", regex = "upvote|downvote")] +#[strum(ascii_case_insensitive)] +enum VoteType { + #[default] + Upvote, + Downvote, +} +impl From for bool { + fn from(value: VoteType) -> Self { + match value { + VoteType::Upvote => true, + VoteType::Downvote => false, + } + } +} + +impl From for u64 { + fn from(value: VoteType) -> Self { + bool::from(value) as u64 + } +} + +#[derive(Debug, Default, Copy, Clone, Parameter)] +#[param( + name = "direction", + regex = "strictly increases|strictly decreases|stays constant|monotonically increases|monotonically decreases" +)] +enum Direction { + #[default] + StrictlyIncrease, + StrictlyDecrease, + StaysConstant, + MonotonicallyIncrease, + MonotonicallyDecrease, +} + +impl FromStr for Direction { + type Err = String; + + fn from_str(s: &str) -> Result { + Ok(match s { + "strictly increases" => Self::StrictlyIncrease, + "strictly decreases" => Self::StrictlyDecrease, + "stays constant" => Self::StaysConstant, + "monotonically increases" => Self::MonotonicallyIncrease, + "monotonically decreases" => Self::MonotonicallyDecrease, + _ => return Err(format!("invalid vote count direction {s}")), + }) + } +} + +impl Direction { + fn check_and_apply(&self, current: &mut u64, new: u64) { + match self { + Direction::StrictlyDecrease => assert_eq!(new, *current - 1), + + Direction::StrictlyIncrease => assert_eq!(new, *current + 1), + + Direction::StaysConstant => assert_eq!(new, *current), + Direction::MonotonicallyIncrease => assert!( + new == *current || new == *current + 1, + "value is not montonically increasing, got {new}; current was {current}" + ), + Direction::MonotonicallyDecrease => assert!( + new == *current || new == *current - 1, + "value is not montonically decreasing, got {new}; current was {current}" + ), + }; + + *current = new + } + + fn check_and_apply_band(&self, current: &mut RatingsBand, new: RatingsBand) { + let comparison = (*current).partial_cmp(&new); + + if comparison.is_none() { + *current = new; + // Unable to conclude anything if there isn't enough information + return; + } + + let comparison = comparison.unwrap(); + + match (*self, comparison) { + (Direction::StrictlyIncrease, std::cmp::Ordering::Less) => {} + (Direction::StrictlyDecrease, std::cmp::Ordering::Greater) => {} + (Direction::StaysConstant, std::cmp::Ordering::Equal) => {} + (Direction::MonotonicallyIncrease, std::cmp::Ordering::Equal) + | (Direction::MonotonicallyIncrease, std::cmp::Ordering::Greater) => {} + (Direction::MonotonicallyDecrease, std::cmp::Ordering::Less) + | (Direction::MonotonicallyDecrease, std::cmp::Ordering::Equal) => {} + _ => { + panic!("Ratings band did not properly {self:?}; current: {current:?}; new: {new:?}") + } + } + + *current = new + } +} + +#[derive(Debug, PartialEq, Eq)] +struct Snap(String); + +impl Default for Snap { + fn default() -> Self { + Snap("93jv9vhsfbb8f7".to_string()) + } +} + +#[derive(Debug, World)] +#[world(init = Self::new)] +struct VotingWorld { + user: AuthenticatedUser, + client: TestClient, + snap: Snap, + rating: Rating, +} + +impl VotingWorld { + async fn new() -> Self { + let config = Config::load().expect("Could not load config"); + let client = TestClient::new(config.socket()); + + let id = helpers::data_faker::rnd_sha_256(); + tracing::debug!("User ID for this test: {id}"); + let user = AuthenticatedUser { + token: client + .authenticate(&id) + .await + .expect("could not authenticate user") + .into_inner() + .token, + }; + + VotingWorld { + user, + client, + snap: Default::default(), + rating: Default::default(), + } + } +} + +#[given(expr = "a Snap named {string} has already accumulated {int} votes and {int} upvotes")] +async fn seed_snap(world: &mut VotingWorld, _snap_name: String, votes: i64, upvotes: i64) { + world.snap.0 = helpers::data_faker::rnd_id(); + tracing::debug!("Snap ID for this test: {}", world.snap.0); + + helpers::vote_generator::generate_votes(&world.snap.0, 1, true, upvotes as u64, &world.client) + .await + .expect("could not generate votes"); + helpers::vote_generator::generate_votes( + &world.snap.0, + 1, + false, + (votes - upvotes) as u64, + &world.client, + ) + .await + .expect("could not generate votes"); + + let summary = VoteSummary { + snap_id: world.snap.0.clone(), + total_votes: votes, + positive_votes: upvotes, + }; + + world.rating = Rating::new(summary); +} + +#[when(expr = "{word} casts a(n) {vote-type}")] +#[when(expr = "{word} changes his/her/their vote to {vote-type}")] +async fn vote(world: &mut VotingWorld, _user_name: String, vote_type: VoteType) { + let request = VoteRequest { + snap_id: world.snap.0.clone(), + snap_revision: 1, + vote_up: vote_type.into(), + }; + + world + .client + .vote(&world.user.token, request) + .await + .expect("could not cast vote"); +} + +#[given(expr = "{word} originally voted {vote-type}")] +async fn originally_voted(world: &mut VotingWorld, _user_name: String, vote_type: VoteType) { + vote(world, _user_name, vote_type).await; + + world.rating = world + .client + .get_rating(&world.user.token, &world.snap.0) + .await + .expect("could not get snap rating") + .into_inner() + .rating + .expect("expected an actual rating") + .into(); +} + +#[then(expr = "the total number of votes {direction}")] +async fn check_vote(world: &mut VotingWorld, direction: Direction) { + let votes = world + .client + .get_rating(&world.user.token, &world.snap.0) + .await + .expect("could not get snap rating") + .into_inner() + .rating + .expect("Rating response was empty") + .total_votes; + + direction.check_and_apply(&mut world.rating.total_votes, votes); +} + +#[then(expr = "the ratings band {direction}")] +async fn check_upvote(world: &mut VotingWorld, direction: Direction) { + let band = world + .client + .get_rating(&world.user.token, &world.snap.0) + .await + .expect("could not get snap rating") + .into_inner() + .rating + .expect("Rating response was empty") + .ratings_band; + + let band = + ratings::features::pb::common::RatingsBand::try_from(band).expect("Unknown ratings band"); + + direction.check_and_apply_band(&mut world.rating.ratings_band, band.into()); +} + +#[tokio::main] +async fn main() { + dotenv::from_filename("test.env").ok(); + + VotingWorld::cucumber() + .repeat_skipped() + .init_tracing() + .run_and_exit("tests/features/user/voting.feature") + .await +}