From 40331451ca674a8eb26e816c05b9f2befbd9d068 Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Thu, 19 Mar 2026 10:05:38 -0400 Subject: [PATCH 01/12] Add repository commands for handling votes --- .gitignore | 1 + Cargo.lock | 419 ++++++++++++++++++++++++++++- Cargo.toml | 11 +- db/migrations/001_create_votes.sql | 8 + docker-compose.yml | 16 ++ src/lib.rs | 1 + src/test_helpers.rs | 16 ++ src/votes/model.rs | 19 +- src/votes/repository.rs | 160 ++++++++++- 9 files changed, 647 insertions(+), 4 deletions(-) create mode 100644 db/migrations/001_create_votes.sql create mode 100644 docker-compose.yml create mode 100644 src/test_helpers.rs diff --git a/.gitignore b/.gitignore index ea8c4bf..fedaa2b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +.env diff --git a/Cargo.lock b/Cargo.lock index 87ecf26..8990580 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,21 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "atoi" version = "2.0.0" @@ -120,6 +135,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "byteorder" version = "1.5.0" @@ -132,12 +153,35 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -153,6 +197,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -289,6 +339,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flume" version = "0.11.1" @@ -407,6 +463,19 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -553,6 +622,30 @@ dependencies = [ "tower-service", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -634,6 +727,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" version = "1.1.0" @@ -663,6 +762,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -671,6 +772,16 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -680,6 +791,12 @@ dependencies = [ "spin", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.183" @@ -951,6 +1068,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -969,6 +1096,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -996,7 +1129,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] [[package]] @@ -1054,6 +1187,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.23" @@ -1066,6 +1205,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -1163,6 +1308,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1248,6 +1399,7 @@ checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ "base64", "bytes", + "chrono", "crc", "crossbeam-queue", "either", @@ -1272,6 +1424,7 @@ dependencies = [ "tokio-stream", "tracing", "url", + "uuid", ] [[package]] @@ -1323,6 +1476,7 @@ dependencies = [ "bitflags", "byteorder", "bytes", + "chrono", "crc", "digest", "dotenvy", @@ -1351,6 +1505,7 @@ dependencies = [ "stringprep", "thiserror", "tracing", + "uuid", "whoami", ] @@ -1364,6 +1519,7 @@ dependencies = [ "base64", "bitflags", "byteorder", + "chrono", "crc", "dotenvy", "etcetera", @@ -1388,6 +1544,7 @@ dependencies = [ "stringprep", "thiserror", "tracing", + "uuid", "whoami", ] @@ -1398,6 +1555,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", + "chrono", "flume", "futures-channel", "futures-core", @@ -1413,6 +1571,7 @@ dependencies = [ "thiserror", "tracing", "url", + "uuid", ] [[package]] @@ -1698,11 +1857,19 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "uprs" version = "0.1.0" dependencies = [ + "anyhow", "axum", + "chrono", "serde", "sqlx", "tokio", @@ -1710,6 +1877,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "uuid", ] [[package]] @@ -1730,6 +1898,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -1754,12 +1933,109 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasite" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "whoami" version = "1.6.1" @@ -1770,12 +2046,65 @@ dependencies = [ "wasite", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -1851,6 +2180,94 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index dfaaeb0..1cdd092 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,11 +4,20 @@ version = "0.1.0" edition = "2024" [dependencies] +anyhow = "1" axum = "0.8" +chrono = "0.4.44" serde = { version = "1", features = ["derive"] } -sqlx = { version = "0.8.6", features = ["runtime-tokio", "postgres", "macros"] } +sqlx = { version = "0.8.6", features = [ + "runtime-tokio", + "postgres", + "macros", + "uuid", + "chrono", +] } tokio = { version = "1", features = ["full"] } tower = "0.5" tower-http = { version = "0.6", features = ["trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +uuid = { version = "1.22.0", features = ["v4"] } diff --git a/db/migrations/001_create_votes.sql b/db/migrations/001_create_votes.sql new file mode 100644 index 0000000..fcc08ea --- /dev/null +++ b/db/migrations/001_create_votes.sql @@ -0,0 +1,8 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS votes ( + slug text not null, + voter_id uuid not null, + created_at timestamptz NOT NULL DEFAULT timezone('utc'::text, now()), + primary key (slug, voter_id) +); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..86f7a6f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + db: + image: postgres:16 + container_name: uprs_db + restart: always + environment: + POSTGRES_USER: uprs + POSTGRES_PASSWORD: password123 + POSTGRES_DB: uprs + volumes: + - pgdata:/var/lib/postgresql/data + - ./db/migrations:/docker-entrypoint-initdb.d # run initial schema + ports: + - "5432:5432" +volumes: + pgdata: diff --git a/src/lib.rs b/src/lib.rs index 7f9f673..de4b4f6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod env; pub mod routes; pub mod state; +pub mod test_helpers; pub mod votes; use axum::Router; diff --git a/src/test_helpers.rs b/src/test_helpers.rs new file mode 100644 index 0000000..1ea5d68 --- /dev/null +++ b/src/test_helpers.rs @@ -0,0 +1,16 @@ +#[cfg(test)] +pub mod db { + use sqlx::PgPool; + + const DEFAULT_CONNECTION_STRING: &str = + "postgres://uprs:password123@localhost:5432/uprs"; + + pub async fn test_pool() -> PgPool { + let conn = std::env::var("POSTGRES_CONNECTION_STRING") + .unwrap_or_else(|_| DEFAULT_CONNECTION_STRING.to_string()); + + PgPool::connect(&conn) + .await + .expect("failed to connect to test database") + } +} diff --git a/src/votes/model.rs b/src/votes/model.rs index 52d2039..df5e2e0 100644 --- a/src/votes/model.rs +++ b/src/votes/model.rs @@ -1 +1,18 @@ -pub struct Vote {} +use chrono::{DateTime, Utc}; +use sqlx::prelude::FromRow; +use uuid::Uuid; + +/// Struct representing a single vote (row) in the votes table +#[derive(Debug, FromRow)] +pub struct Vote { + pub slug: String, + pub voter_id: Uuid, + pub created_at: DateTime, +} + +/// Struct representing the best slug posts +#[derive(Debug, FromRow)] +pub struct BestSlugs { + pub slug: String, + pub vote_count: i64, +} diff --git a/src/votes/repository.rs b/src/votes/repository.rs index 42e969f..e890721 100644 --- a/src/votes/repository.rs +++ b/src/votes/repository.rs @@ -1,3 +1,161 @@ -use sqlx::PgPool; +use anyhow::Result; +use sqlx::{PgPool, query, query_as, query_scalar}; +use uuid::Uuid; + +use crate::votes::model::BestSlugs; use super::model::Vote; + +pub async fn insert_new_vote(vote: &Vote, db: &PgPool) -> Result<()> { + query("insert into votes (slug, voter_id) values ($1, $2)") + .bind(vote.slug.clone()) + .bind(vote.voter_id) + .execute(db) + .await?; + Ok(()) +} + +pub async fn get_vote_count_for_slug(slug: &str, db: &PgPool) -> Result { + let count: i64 = query_scalar("select count(*) from votes where slug=$1") + .bind(slug) + .fetch_one(db) + .await?; + Ok(count) +} + +pub async fn get_top_n_slugs(n: i64, db: &PgPool) -> Result> { + if n > 0 { + let top_slugs = query_as::<_, BestSlugs>( + r#"select slug, count(*) AS vote_count + from votes + group by slug + order by vote_count desc + limit $1 + "#, + ) + .bind(n) + .fetch_all(db) + .await?; + Ok(top_slugs) + } else { + Ok(vec![]) + } +} + +pub async fn delete_vote(slug: &str, voter_id: &Uuid, db: &PgPool) -> Result<()> { + query("delete from votes where slug=$1 and voter_id=$2") + .bind(slug) + .bind(voter_id) + .execute(db) + .await?; + Ok(()) +} + +#[cfg(test)] +mod postgres_tests { + use sqlx::PgPool; + use uuid::Uuid; + + use crate::{ + test_helpers::db::test_pool, + votes::{ + model::Vote, + repository::{delete_vote, get_top_n_slugs, get_vote_count_for_slug, insert_new_vote}, + }, + }; + + async fn cleanup(db: &PgPool) { + for vote in test_votes() { + delete_vote(&vote.slug, &vote.voter_id, db).await.unwrap() + } + } + + fn test_votes() -> [Vote; 9] { + [ + Vote { + slug: "blog_post1".into(), + voter_id: Uuid::from_u128(0x1), + created_at: chrono::Utc::now(), + }, + Vote { + slug: "blog_post1".into(), + voter_id: Uuid::from_u128(0x2), + created_at: chrono::Utc::now(), + }, + Vote { + slug: "blog_post2".into(), + voter_id: Uuid::from_u128(0x3), + created_at: chrono::Utc::now(), + }, + Vote { + slug: "blog_post2".into(), + voter_id: Uuid::from_u128(0x4), + created_at: chrono::Utc::now(), + }, + Vote { + slug: "blog_post3".into(), + voter_id: Uuid::from_u128(0x5), + created_at: chrono::Utc::now(), + }, + Vote { + slug: "blog_post3".into(), + voter_id: Uuid::from_u128(0x6), + created_at: chrono::Utc::now(), + }, + Vote { + slug: "blog_post1".into(), + voter_id: Uuid::from_u128(0x7), + created_at: chrono::Utc::now(), + }, + Vote { + slug: "blog_post1".into(), + voter_id: Uuid::from_u128(0x8), + created_at: chrono::Utc::now(), + }, + Vote { + slug: "blog_post3".into(), + voter_id: Uuid::from_u128(0x9), + created_at: chrono::Utc::now(), + }, + ] + } + + #[tokio::test] + #[ignore] + pub async fn postgres_tests() { + let db = test_pool().await; + cleanup(&db).await; + let votes = test_votes(); + + for vote in votes.iter() { + insert_new_vote(vote, &db) + .await + .expect("Insertions to db failed"); + } + + assert_eq!(get_vote_count_for_slug("blog_post1", &db).await.unwrap(), 4); + assert_eq!(get_vote_count_for_slug("blog_post2", &db).await.unwrap(), 2); + assert_eq!(get_vote_count_for_slug("blog_post3", &db).await.unwrap(), 3); + + let top_2 = get_top_n_slugs(2, &db).await.unwrap(); + assert_eq!(top_2[0].slug, "blog_post1"); + assert_eq!(top_2[1].slug, "blog_post3"); + + delete_vote(&votes[4].slug, &votes[4].voter_id, &db) + .await + .unwrap(); + delete_vote(&votes[5].slug, &votes[5].voter_id, &db) + .await + .unwrap(); + + assert_eq!(get_vote_count_for_slug("blog_post1", &db).await.unwrap(), 4); + assert_eq!(get_vote_count_for_slug("blog_post2", &db).await.unwrap(), 2); + assert_eq!(get_vote_count_for_slug("blog_post3", &db).await.unwrap(), 1); + + let top_2 = get_top_n_slugs(2, &db).await.unwrap(); + assert_eq!(top_2[0].slug, "blog_post1"); + assert_eq!(top_2[1].slug, "blog_post2"); + + cleanup(&db).await; + } +} From e15c30fe06e16a55241576905726d5a9cdae17a5 Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Thu, 19 Mar 2026 12:55:57 -0400 Subject: [PATCH 02/12] Rework repositories to simplify the structure --- src/votes/repository.rs | 93 +++++++++++------------------------------ 1 file changed, 24 insertions(+), 69 deletions(-) diff --git a/src/votes/repository.rs b/src/votes/repository.rs index e890721..8bf5e78 100644 --- a/src/votes/repository.rs +++ b/src/votes/repository.rs @@ -4,12 +4,10 @@ use uuid::Uuid; use crate::votes::model::BestSlugs; -use super::model::Vote; - -pub async fn insert_new_vote(vote: &Vote, db: &PgPool) -> Result<()> { +pub async fn insert_new_vote(slug: &str, voter_id: &Uuid, db: &PgPool) -> Result<()> { query("insert into votes (slug, voter_id) values ($1, $2)") - .bind(vote.slug.clone()) - .bind(vote.voter_id) + .bind(slug) + .bind(voter_id) .execute(db) .await?; Ok(()) @@ -58,66 +56,27 @@ mod postgres_tests { use crate::{ test_helpers::db::test_pool, - votes::{ - model::Vote, - repository::{delete_vote, get_top_n_slugs, get_vote_count_for_slug, insert_new_vote}, - }, + votes::repository::{delete_vote, get_top_n_slugs, get_vote_count_for_slug, insert_new_vote}, }; - async fn cleanup(db: &PgPool) { - for vote in test_votes() { - delete_vote(&vote.slug, &vote.voter_id, db).await.unwrap() - } + fn test_votes() -> [(&'static str, Uuid); 9] { + [ + ("blog_post1", Uuid::from_u128(0x1)), + ("blog_post1", Uuid::from_u128(0x2)), + ("blog_post2", Uuid::from_u128(0x3)), + ("blog_post2", Uuid::from_u128(0x4)), + ("blog_post3", Uuid::from_u128(0x5)), + ("blog_post3", Uuid::from_u128(0x6)), + ("blog_post1", Uuid::from_u128(0x7)), + ("blog_post1", Uuid::from_u128(0x8)), + ("blog_post3", Uuid::from_u128(0x9)), + ] } - fn test_votes() -> [Vote; 9] { - [ - Vote { - slug: "blog_post1".into(), - voter_id: Uuid::from_u128(0x1), - created_at: chrono::Utc::now(), - }, - Vote { - slug: "blog_post1".into(), - voter_id: Uuid::from_u128(0x2), - created_at: chrono::Utc::now(), - }, - Vote { - slug: "blog_post2".into(), - voter_id: Uuid::from_u128(0x3), - created_at: chrono::Utc::now(), - }, - Vote { - slug: "blog_post2".into(), - voter_id: Uuid::from_u128(0x4), - created_at: chrono::Utc::now(), - }, - Vote { - slug: "blog_post3".into(), - voter_id: Uuid::from_u128(0x5), - created_at: chrono::Utc::now(), - }, - Vote { - slug: "blog_post3".into(), - voter_id: Uuid::from_u128(0x6), - created_at: chrono::Utc::now(), - }, - Vote { - slug: "blog_post1".into(), - voter_id: Uuid::from_u128(0x7), - created_at: chrono::Utc::now(), - }, - Vote { - slug: "blog_post1".into(), - voter_id: Uuid::from_u128(0x8), - created_at: chrono::Utc::now(), - }, - Vote { - slug: "blog_post3".into(), - voter_id: Uuid::from_u128(0x9), - created_at: chrono::Utc::now(), - }, - ] + async fn cleanup(db: &PgPool) { + for (slug, voter_id) in test_votes() { + delete_vote(slug, &voter_id, db).await.unwrap() + } } #[tokio::test] @@ -127,8 +86,8 @@ mod postgres_tests { cleanup(&db).await; let votes = test_votes(); - for vote in votes.iter() { - insert_new_vote(vote, &db) + for (slug, voter_id) in votes.iter() { + insert_new_vote(slug, voter_id, &db) .await .expect("Insertions to db failed"); } @@ -141,12 +100,8 @@ mod postgres_tests { assert_eq!(top_2[0].slug, "blog_post1"); assert_eq!(top_2[1].slug, "blog_post3"); - delete_vote(&votes[4].slug, &votes[4].voter_id, &db) - .await - .unwrap(); - delete_vote(&votes[5].slug, &votes[5].voter_id, &db) - .await - .unwrap(); + delete_vote(votes[4].0, &votes[4].1, &db).await.unwrap(); + delete_vote(votes[5].0, &votes[5].1, &db).await.unwrap(); assert_eq!(get_vote_count_for_slug("blog_post1", &db).await.unwrap(), 4); assert_eq!(get_vote_count_for_slug("blog_post2", &db).await.unwrap(), 2); From ea0c26a151534be06760b3cbf0c9e3656d6203db Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Thu, 19 Mar 2026 13:44:11 -0400 Subject: [PATCH 03/12] Implement idempotency for insertion --- src/votes/repository.rs | 132 ++++++++++++++++++++++++++++------------ 1 file changed, 94 insertions(+), 38 deletions(-) diff --git a/src/votes/repository.rs b/src/votes/repository.rs index 8bf5e78..18b7acb 100644 --- a/src/votes/repository.rs +++ b/src/votes/repository.rs @@ -5,11 +5,15 @@ use uuid::Uuid; use crate::votes::model::BestSlugs; pub async fn insert_new_vote(slug: &str, voter_id: &Uuid, db: &PgPool) -> Result<()> { - query("insert into votes (slug, voter_id) values ($1, $2)") - .bind(slug) - .bind(voter_id) - .execute(db) - .await?; + query( + r#"insert into votes (slug, voter_id) + values ($1, $2) + on conflict (slug, voter_id) do nothing"#, + ) + .bind(slug) + .bind(voter_id) + .execute(db) + .await?; Ok(()) } @@ -56,26 +60,14 @@ mod postgres_tests { use crate::{ test_helpers::db::test_pool, - votes::repository::{delete_vote, get_top_n_slugs, get_vote_count_for_slug, insert_new_vote}, + votes::repository::{ + delete_vote, get_top_n_slugs, get_vote_count_for_slug, insert_new_vote, + }, }; - fn test_votes() -> [(&'static str, Uuid); 9] { - [ - ("blog_post1", Uuid::from_u128(0x1)), - ("blog_post1", Uuid::from_u128(0x2)), - ("blog_post2", Uuid::from_u128(0x3)), - ("blog_post2", Uuid::from_u128(0x4)), - ("blog_post3", Uuid::from_u128(0x5)), - ("blog_post3", Uuid::from_u128(0x6)), - ("blog_post1", Uuid::from_u128(0x7)), - ("blog_post1", Uuid::from_u128(0x8)), - ("blog_post3", Uuid::from_u128(0x9)), - ] - } - - async fn cleanup(db: &PgPool) { - for (slug, voter_id) in test_votes() { - delete_vote(slug, &voter_id, db).await.unwrap() + async fn cleanup(db: &PgPool, votes: &[(String, Uuid)]) { + for (slug, voter_id) in votes { + delete_vote(slug, voter_id, db).await.unwrap() } } @@ -83,8 +75,18 @@ mod postgres_tests { #[ignore] pub async fn postgres_tests() { let db = test_pool().await; - cleanup(&db).await; - let votes = test_votes(); + let votes = vec![ + ("postgres_tests_blog_post1".to_string(), Uuid::from_u128(0x1)), + ("postgres_tests_blog_post1".to_string(), Uuid::from_u128(0x2)), + ("postgres_tests_blog_post2".to_string(), Uuid::from_u128(0x3)), + ("postgres_tests_blog_post2".to_string(), Uuid::from_u128(0x4)), + ("postgres_tests_blog_post3".to_string(), Uuid::from_u128(0x5)), + ("postgres_tests_blog_post3".to_string(), Uuid::from_u128(0x6)), + ("postgres_tests_blog_post1".to_string(), Uuid::from_u128(0x7)), + ("postgres_tests_blog_post1".to_string(), Uuid::from_u128(0x8)), + ("postgres_tests_blog_post3".to_string(), Uuid::from_u128(0x9)), + ]; + cleanup(&db, &votes).await; for (slug, voter_id) in votes.iter() { insert_new_vote(slug, voter_id, &db) @@ -92,25 +94,79 @@ mod postgres_tests { .expect("Insertions to db failed"); } - assert_eq!(get_vote_count_for_slug("blog_post1", &db).await.unwrap(), 4); - assert_eq!(get_vote_count_for_slug("blog_post2", &db).await.unwrap(), 2); - assert_eq!(get_vote_count_for_slug("blog_post3", &db).await.unwrap(), 3); + assert_eq!( + get_vote_count_for_slug("postgres_tests_blog_post1", &db) + .await + .unwrap(), + 4 + ); + assert_eq!( + get_vote_count_for_slug("postgres_tests_blog_post2", &db) + .await + .unwrap(), + 2 + ); + assert_eq!( + get_vote_count_for_slug("postgres_tests_blog_post3", &db) + .await + .unwrap(), + 3 + ); let top_2 = get_top_n_slugs(2, &db).await.unwrap(); - assert_eq!(top_2[0].slug, "blog_post1"); - assert_eq!(top_2[1].slug, "blog_post3"); + assert_eq!(top_2[0].slug, "postgres_tests_blog_post1"); + assert_eq!(top_2[1].slug, "postgres_tests_blog_post3"); - delete_vote(votes[4].0, &votes[4].1, &db).await.unwrap(); - delete_vote(votes[5].0, &votes[5].1, &db).await.unwrap(); + delete_vote(&votes[4].0, &votes[4].1, &db).await.unwrap(); + delete_vote(&votes[5].0, &votes[5].1, &db).await.unwrap(); - assert_eq!(get_vote_count_for_slug("blog_post1", &db).await.unwrap(), 4); - assert_eq!(get_vote_count_for_slug("blog_post2", &db).await.unwrap(), 2); - assert_eq!(get_vote_count_for_slug("blog_post3", &db).await.unwrap(), 1); + assert_eq!( + get_vote_count_for_slug("postgres_tests_blog_post1", &db) + .await + .unwrap(), + 4 + ); + assert_eq!( + get_vote_count_for_slug("postgres_tests_blog_post2", &db) + .await + .unwrap(), + 2 + ); + assert_eq!( + get_vote_count_for_slug("postgres_tests_blog_post3", &db) + .await + .unwrap(), + 1 + ); let top_2 = get_top_n_slugs(2, &db).await.unwrap(); - assert_eq!(top_2[0].slug, "blog_post1"); - assert_eq!(top_2[1].slug, "blog_post2"); + assert_eq!(top_2[0].slug, "postgres_tests_blog_post1"); + assert_eq!(top_2[1].slug, "postgres_tests_blog_post2"); - cleanup(&db).await; + cleanup(&db, &votes).await; + } + + #[tokio::test] + #[ignore] + pub async fn insert_idempotency_test() { + let db = test_pool().await; + let votes = vec![( + "insert_idempotency_test_blog_post1".to_string(), + Uuid::from_u128(0x1), + )]; + cleanup(&db, &votes).await; + + insert_new_vote(&votes[0].0, &votes[0].1, &db).await.unwrap(); + insert_new_vote(&votes[0].0, &votes[0].1, &db).await.unwrap(); + insert_new_vote(&votes[0].0, &votes[0].1, &db).await.unwrap(); + insert_new_vote(&votes[0].0, &votes[0].1, &db).await.unwrap(); + insert_new_vote(&votes[0].0, &votes[0].1, &db).await.unwrap(); + insert_new_vote(&votes[0].0, &votes[0].1, &db).await.unwrap(); + + let votes_count = get_vote_count_for_slug(&votes[0].0, &db).await.unwrap(); + + assert_eq!(votes_count, 1); + + cleanup(&db, &votes).await; } } From 3812563a5bea2dc786a23b53554c228d04b8a540 Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Thu, 19 Mar 2026 13:46:40 -0400 Subject: [PATCH 04/12] Fix formatting and add delete idempotency test --- src/votes/repository.rs | 94 ++++++++++++++++++++++++++++++++++------- 1 file changed, 79 insertions(+), 15 deletions(-) diff --git a/src/votes/repository.rs b/src/votes/repository.rs index 18b7acb..cda1ec4 100644 --- a/src/votes/repository.rs +++ b/src/votes/repository.rs @@ -76,15 +76,42 @@ mod postgres_tests { pub async fn postgres_tests() { let db = test_pool().await; let votes = vec![ - ("postgres_tests_blog_post1".to_string(), Uuid::from_u128(0x1)), - ("postgres_tests_blog_post1".to_string(), Uuid::from_u128(0x2)), - ("postgres_tests_blog_post2".to_string(), Uuid::from_u128(0x3)), - ("postgres_tests_blog_post2".to_string(), Uuid::from_u128(0x4)), - ("postgres_tests_blog_post3".to_string(), Uuid::from_u128(0x5)), - ("postgres_tests_blog_post3".to_string(), Uuid::from_u128(0x6)), - ("postgres_tests_blog_post1".to_string(), Uuid::from_u128(0x7)), - ("postgres_tests_blog_post1".to_string(), Uuid::from_u128(0x8)), - ("postgres_tests_blog_post3".to_string(), Uuid::from_u128(0x9)), + ( + "postgres_tests_blog_post1".to_string(), + Uuid::from_u128(0x1), + ), + ( + "postgres_tests_blog_post1".to_string(), + Uuid::from_u128(0x2), + ), + ( + "postgres_tests_blog_post2".to_string(), + Uuid::from_u128(0x3), + ), + ( + "postgres_tests_blog_post2".to_string(), + Uuid::from_u128(0x4), + ), + ( + "postgres_tests_blog_post3".to_string(), + Uuid::from_u128(0x5), + ), + ( + "postgres_tests_blog_post3".to_string(), + Uuid::from_u128(0x6), + ), + ( + "postgres_tests_blog_post1".to_string(), + Uuid::from_u128(0x7), + ), + ( + "postgres_tests_blog_post1".to_string(), + Uuid::from_u128(0x8), + ), + ( + "postgres_tests_blog_post3".to_string(), + Uuid::from_u128(0x9), + ), ]; cleanup(&db, &votes).await; @@ -156,12 +183,24 @@ mod postgres_tests { )]; cleanup(&db, &votes).await; - insert_new_vote(&votes[0].0, &votes[0].1, &db).await.unwrap(); - insert_new_vote(&votes[0].0, &votes[0].1, &db).await.unwrap(); - insert_new_vote(&votes[0].0, &votes[0].1, &db).await.unwrap(); - insert_new_vote(&votes[0].0, &votes[0].1, &db).await.unwrap(); - insert_new_vote(&votes[0].0, &votes[0].1, &db).await.unwrap(); - insert_new_vote(&votes[0].0, &votes[0].1, &db).await.unwrap(); + insert_new_vote(&votes[0].0, &votes[0].1, &db) + .await + .unwrap(); + insert_new_vote(&votes[0].0, &votes[0].1, &db) + .await + .unwrap(); + insert_new_vote(&votes[0].0, &votes[0].1, &db) + .await + .unwrap(); + insert_new_vote(&votes[0].0, &votes[0].1, &db) + .await + .unwrap(); + insert_new_vote(&votes[0].0, &votes[0].1, &db) + .await + .unwrap(); + insert_new_vote(&votes[0].0, &votes[0].1, &db) + .await + .unwrap(); let votes_count = get_vote_count_for_slug(&votes[0].0, &db).await.unwrap(); @@ -169,4 +208,29 @@ mod postgres_tests { cleanup(&db, &votes).await; } + + #[tokio::test] + #[ignore] + pub async fn delete_idempotency_test() { + let db = test_pool().await; + let votes = vec![( + "delete_idempotency_test_blog_post1".to_string(), + Uuid::from_u128(0x1), + )]; + cleanup(&db, &votes).await; + + insert_new_vote(&votes[0].0, &votes[0].1, &db) + .await + .unwrap(); + + delete_vote(&votes[0].0, &votes[0].1, &db).await.unwrap(); + delete_vote(&votes[0].0, &votes[0].1, &db).await.unwrap(); + delete_vote(&votes[0].0, &votes[0].1, &db).await.unwrap(); + + let votes_count = get_vote_count_for_slug(&votes[0].0, &db).await.unwrap(); + + assert_eq!(votes_count, 0); + + cleanup(&db, &votes).await; + } } From 01ae2aee7c94804bf8a03a04b6fec0a1ace3a530 Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Thu, 19 Mar 2026 13:46:55 -0400 Subject: [PATCH 05/12] Update dependencies --- Cargo.lock | 98 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 3 +- 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 8990580..15643f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,6 +60,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", + "axum-macros", "bytes", "form_urlencoded", "futures-util", @@ -105,6 +106,39 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-extra" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "base64" version = "0.22.1" @@ -197,6 +231,17 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -263,6 +308,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -925,6 +979,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + [[package]] name = "num-integer" version = "0.1.46" @@ -1059,6 +1119,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1654,6 +1720,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -1869,6 +1966,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "axum-extra", "chrono", "serde", "sqlx", diff --git a/Cargo.toml b/Cargo.toml index 1cdd092..8e0f767 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,8 @@ edition = "2024" [dependencies] anyhow = "1" -axum = "0.8" +axum = { version = "0.8", features = ["macros"] } +axum-extra = { version = "0.12.5", features = ["cookie"] } chrono = "0.4.44" serde = { version = "1", features = ["derive"] } sqlx = { version = "0.8.6", features = [ From ef3c5c7fe87c32461d9a5263562755fa8f3d7b38 Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Thu, 19 Mar 2026 13:47:18 -0400 Subject: [PATCH 06/12] Remove unneeded service (too thin) --- src/votes/service.rs | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 src/votes/service.rs diff --git a/src/votes/service.rs b/src/votes/service.rs deleted file mode 100644 index 440858a..0000000 --- a/src/votes/service.rs +++ /dev/null @@ -1,3 +0,0 @@ -use crate::state::AppState; - -use super::dto::{CreateVoteRequest, VoteResponse}; From 3bff31ff98d717a13aedf1ffc3a6631181756565 Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Thu, 19 Mar 2026 13:53:03 -0400 Subject: [PATCH 07/12] Add upvote handler --- src/votes/handlers.rs | 176 ++++++++++++++++++++++++++++++++++++++-- src/votes/mod.rs | 1 - src/votes/repository.rs | 3 - 3 files changed, 167 insertions(+), 13 deletions(-) diff --git a/src/votes/handlers.rs b/src/votes/handlers.rs index 7deb489..6e2cbfe 100644 --- a/src/votes/handlers.rs +++ b/src/votes/handlers.rs @@ -1,13 +1,171 @@ -use axum::{ - Router, - extract::{Path, State}, - routing::{get, post}, -}; +use axum::{Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::post}; +use axum_extra::extract::{CookieJar, cookie::Cookie}; +use serde::Deserialize; +use uuid::Uuid; -use crate::state::AppState; - -use super::dto::CreateVoteRequest; +use crate::{state::AppState, votes::repository::insert_new_vote}; pub fn router() -> Router { - Router::new() + Router::new().route("/vote", post(upvote_handler)) +} + +fn get_or_init_voter_id(jar: CookieJar) -> (CookieJar, Uuid) { + match jar.get("voter_id") { + Some(cookie) => match Uuid::parse_str(cookie.value()) { + Ok(voter_id) => (jar, voter_id), + Err(_) => { + let jar = jar.remove("voter_id"); + get_or_init_voter_id(jar) + } + }, + None => { + let voter_id = Uuid::new_v4(); + + let cookie = Cookie::build(("voter_id", voter_id.to_string())) + .path("/") + .http_only(true) + .build(); + (jar.add(cookie), voter_id) + } + } +} + +#[derive(Deserialize)] +struct UpvotePayload { + slug: String, +} + +async fn upvote_handler( + jar: CookieJar, + State(state): State, + Json(body): Json, +) -> impl IntoResponse { + let (jar, voter_id) = get_or_init_voter_id(jar); + + match insert_new_vote(&body.slug, &voter_id, &state.db).await { + Ok(()) => (StatusCode::OK, jar, "Successfully upvoted"), + Err(err) => { + println!("{err}"); + (StatusCode::INTERNAL_SERVER_ERROR, jar, "Failed to upvote") + } + } +} + +#[cfg(test)] +mod tests { + use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode, header}, + }; + use sqlx::{PgPool, query, query_scalar}; + use tower::ServiceExt; + use uuid::Uuid; + + use crate::{env::Env, state::AppState, test_helpers::db::test_pool}; + + async fn delete_votes_for_slug(slug: &str, db: &PgPool) { + query("delete from votes where slug = $1") + .bind(slug) + .execute(db) + .await + .unwrap(); + } + + async fn get_vote_count_for_slug(slug: &str, db: &PgPool) -> i64 { + query_scalar("select count(*) from votes where slug = $1") + .bind(slug) + .fetch_one(db) + .await + .unwrap() + } + + fn test_state(db: PgPool) -> AppState { + AppState { + db, + env: Env { + postgres_connection_string: String::new(), + }, + } + } + + #[tokio::test] + async fn upvote_sets_cookie_and_inserts_vote() { + let db = test_pool().await; + let slug = "upvote_handler_sets_cookie_and_inserts_vote"; + delete_votes_for_slug(slug, &db).await; + + let app = crate::app(test_state(db.clone())); + + let request = Request::builder() + .method("POST") + .uri("/vote") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(format!(r#"{{"slug":"{slug}"}}"#))) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert!(response.headers().contains_key(header::SET_COOKIE)); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(&body[..], b"Successfully upvoted"); + assert_eq!(get_vote_count_for_slug(slug, &db).await, 1); + + delete_votes_for_slug(slug, &db).await; + } + + #[tokio::test] + async fn upvote_is_idempotent_with_same_cookie() { + let db = test_pool().await; + let slug = "upvote_handler_idempotent_with_same_cookie"; + let voter_id = Uuid::from_u128(0xabc); + delete_votes_for_slug(slug, &db).await; + + let cookie = format!("voter_id={voter_id}"); + + for _ in 0..2 { + let app = crate::app(test_state(db.clone())); + let request = Request::builder() + .method("POST") + .uri("/vote") + .header(header::CONTENT_TYPE, "application/json") + .header(header::COOKIE, &cookie) + .body(Body::from(format!(r#"{{"slug":"{slug}"}}"#))) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + assert_eq!(get_vote_count_for_slug(slug, &db).await, 1); + delete_votes_for_slug(slug, &db).await; + } + + #[tokio::test] + async fn upvote_replaces_malformed_cookie() { + let db = test_pool().await; + let slug = "upvote_handler_replaces_malformed_cookie"; + delete_votes_for_slug(slug, &db).await; + + let app = crate::app(test_state(db.clone())); + let request = Request::builder() + .method("POST") + .uri("/vote") + .header(header::CONTENT_TYPE, "application/json") + .header(header::COOKIE, "voter_id=not-a-uuid") + .body(Body::from(format!(r#"{{"slug":"{slug}"}}"#))) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let set_cookie = response.headers().get(header::SET_COOKIE).unwrap(); + let set_cookie = set_cookie.to_str().unwrap(); + assert!(set_cookie.contains("voter_id=")); + assert!(!set_cookie.contains("not-a-uuid")); + assert_eq!(get_vote_count_for_slug(slug, &db).await, 1); + + delete_votes_for_slug(slug, &db).await; + } } diff --git a/src/votes/mod.rs b/src/votes/mod.rs index 76ef459..d53e2ac 100644 --- a/src/votes/mod.rs +++ b/src/votes/mod.rs @@ -2,4 +2,3 @@ pub mod dto; pub mod handlers; pub mod model; pub mod repository; -pub mod service; diff --git a/src/votes/repository.rs b/src/votes/repository.rs index cda1ec4..ee04afa 100644 --- a/src/votes/repository.rs +++ b/src/votes/repository.rs @@ -72,7 +72,6 @@ mod postgres_tests { } #[tokio::test] - #[ignore] pub async fn postgres_tests() { let db = test_pool().await; let votes = vec![ @@ -174,7 +173,6 @@ mod postgres_tests { } #[tokio::test] - #[ignore] pub async fn insert_idempotency_test() { let db = test_pool().await; let votes = vec![( @@ -210,7 +208,6 @@ mod postgres_tests { } #[tokio::test] - #[ignore] pub async fn delete_idempotency_test() { let db = test_pool().await; let votes = vec![( From f81b29c5e959dd1a47db65470bf72b00a341ed1a Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Thu, 19 Mar 2026 14:01:20 -0400 Subject: [PATCH 08/12] Refactor to use more idiomatic http pattern --- src/votes/handlers.rs | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/votes/handlers.rs b/src/votes/handlers.rs index 6e2cbfe..d6dcf93 100644 --- a/src/votes/handlers.rs +++ b/src/votes/handlers.rs @@ -1,12 +1,17 @@ -use axum::{Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::post}; +use axum::{ + Router, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing::post, +}; use axum_extra::extract::{CookieJar, cookie::Cookie}; -use serde::Deserialize; use uuid::Uuid; use crate::{state::AppState, votes::repository::insert_new_vote}; pub fn router() -> Router { - Router::new().route("/vote", post(upvote_handler)) + Router::new().route("/posts/{slug}/vote", post(upvote_handler)) } fn get_or_init_voter_id(jar: CookieJar) -> (CookieJar, Uuid) { @@ -30,19 +35,14 @@ fn get_or_init_voter_id(jar: CookieJar) -> (CookieJar, Uuid) { } } -#[derive(Deserialize)] -struct UpvotePayload { - slug: String, -} - async fn upvote_handler( jar: CookieJar, + Path(slug): Path, State(state): State, - Json(body): Json, ) -> impl IntoResponse { let (jar, voter_id) = get_or_init_voter_id(jar); - match insert_new_vote(&body.slug, &voter_id, &state.db).await { + match insert_new_vote(&slug, &voter_id, &state.db).await { Ok(()) => (StatusCode::OK, jar, "Successfully upvoted"), Err(err) => { println!("{err}"); @@ -98,9 +98,9 @@ mod tests { let request = Request::builder() .method("POST") - .uri("/vote") + .uri(format!("/posts/{slug}/vote")) .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(format!(r#"{{"slug":"{slug}"}}"#))) + .body(Body::empty()) .unwrap(); let response = app.oneshot(request).await.unwrap(); @@ -128,10 +128,10 @@ mod tests { let app = crate::app(test_state(db.clone())); let request = Request::builder() .method("POST") - .uri("/vote") + .uri(format!("/posts/{slug}/vote")) .header(header::CONTENT_TYPE, "application/json") .header(header::COOKIE, &cookie) - .body(Body::from(format!(r#"{{"slug":"{slug}"}}"#))) + .body(Body::empty()) .unwrap(); let response = app.oneshot(request).await.unwrap(); @@ -151,10 +151,10 @@ mod tests { let app = crate::app(test_state(db.clone())); let request = Request::builder() .method("POST") - .uri("/vote") + .uri(format!("/posts/{slug}/vote")) .header(header::CONTENT_TYPE, "application/json") .header(header::COOKIE, "voter_id=not-a-uuid") - .body(Body::from(format!(r#"{{"slug":"{slug}"}}"#))) + .body(Body::empty()) .unwrap(); let response = app.oneshot(request).await.unwrap(); From c77e58f21b93259627c21f8a86eb3b010c9af164 Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Thu, 19 Mar 2026 14:43:39 -0400 Subject: [PATCH 09/12] Add vote_exists and additional delete/get vote count handler --- src/votes/dto.rs | 10 ++++----- src/votes/handlers.rs | 48 +++++++++++++++++++++++++++++++++++++---- src/votes/mod.rs | 1 + src/votes/repository.rs | 41 ++++++++++++++++++++++++++++++++++- 4 files changed, 90 insertions(+), 10 deletions(-) diff --git a/src/votes/dto.rs b/src/votes/dto.rs index 6cda382..174acf8 100644 --- a/src/votes/dto.rs +++ b/src/votes/dto.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; -#[derive(Deserialize)] -pub struct CreateVoteRequest {} - -#[derive(Serialize)] -pub struct VoteResponse {} +#[derive(Serialize, Deserialize)] +pub struct VotesAndDidVote { + pub vote_count: i64, + pub voted: bool, +} diff --git a/src/votes/handlers.rs b/src/votes/handlers.rs index d6dcf93..0fb7b9e 100644 --- a/src/votes/handlers.rs +++ b/src/votes/handlers.rs @@ -1,17 +1,26 @@ use axum::{ - Router, + Json, Router, extract::{Path, State}, http::StatusCode, response::IntoResponse, - routing::post, + routing::{delete, get, post}, }; use axum_extra::extract::{CookieJar, cookie::Cookie}; use uuid::Uuid; -use crate::{state::AppState, votes::repository::insert_new_vote}; +use crate::{ + state::AppState, + votes::{ + repository::{delete_vote, insert_new_vote}, + service::get_votes_and_voted, + }, +}; pub fn router() -> Router { - Router::new().route("/posts/{slug}/vote", post(upvote_handler)) + Router::new() + .route("/posts/{slug}/vote", post(upvote_handler)) + .route("/posts/{slug}/vote", delete(downvote_handler)) + .route("/posts/{slug}/votes", get(get_votes_and_voted_handler)) } fn get_or_init_voter_id(jar: CookieJar) -> (CookieJar, Uuid) { @@ -51,6 +60,37 @@ async fn upvote_handler( } } +async fn downvote_handler( + jar: CookieJar, + Path(slug): Path, + State(state): State, +) -> impl IntoResponse { + let (jar, voter_id) = get_or_init_voter_id(jar); + + match delete_vote(&slug, &voter_id, &state.db).await { + Ok(()) => (StatusCode::OK, jar, "Successfully upvoted"), + Err(err) => { + println!("{err}"); + (StatusCode::INTERNAL_SERVER_ERROR, jar, "Failed to upvote") + } + } +} + +async fn get_votes_and_voted_handler( + jar: CookieJar, + Path(slug): Path, + State(state): State, +) -> impl IntoResponse { + let (jar, voter_id) = get_or_init_voter_id(jar); + match get_votes_and_voted(&slug, &voter_id, &state.db).await { + Ok(votes_and_voted) => (StatusCode::OK, jar, Json(votes_and_voted)).into_response(), + Err(err) => { + println!("{err}"); + (StatusCode::INTERNAL_SERVER_ERROR, jar, "Failed to upvote").into_response() + } + } +} + #[cfg(test)] mod tests { use axum::{ diff --git a/src/votes/mod.rs b/src/votes/mod.rs index d53e2ac..76ef459 100644 --- a/src/votes/mod.rs +++ b/src/votes/mod.rs @@ -2,3 +2,4 @@ pub mod dto; pub mod handlers; pub mod model; pub mod repository; +pub mod service; diff --git a/src/votes/repository.rs b/src/votes/repository.rs index ee04afa..853095c 100644 --- a/src/votes/repository.rs +++ b/src/votes/repository.rs @@ -4,6 +4,14 @@ use uuid::Uuid; use crate::votes::model::BestSlugs; +pub async fn vote_exists(slug: &str, voter_id: &Uuid, db: &PgPool) -> Result { + let count: i64 = query_scalar("select count(*) from votes where slug=$1 and voter_id=$2") + .bind(slug) + .bind(voter_id) + .fetch_one(db) + .await?; + Ok(count > 0) +} pub async fn insert_new_vote(slug: &str, voter_id: &Uuid, db: &PgPool) -> Result<()> { query( r#"insert into votes (slug, voter_id) @@ -61,7 +69,7 @@ mod postgres_tests { use crate::{ test_helpers::db::test_pool, votes::repository::{ - delete_vote, get_top_n_slugs, get_vote_count_for_slug, insert_new_vote, + delete_vote, get_top_n_slugs, get_vote_count_for_slug, insert_new_vote, vote_exists, }, }; @@ -230,4 +238,35 @@ mod postgres_tests { cleanup(&db, &votes).await; } + + #[tokio::test] + pub async fn vote_exists_test() { + let db = test_pool().await; + let votes = vec![ + ( + "vote_exists_test_blog_post1".to_string(), + Uuid::from_u128(0x1), + ), + ( + "vote_exists_test_blog_post2".to_string(), + Uuid::from_u128(0x2), + ), + ( + "vote_exists_test_blog_post3".to_string(), + Uuid::from_u128(0x3), + ), + ]; + cleanup(&db, &votes).await; + + insert_new_vote(&votes[0].0, &votes[0].1, &db) + .await + .unwrap(); + insert_new_vote(&votes[1].0, &votes[1].1, &db) + .await + .unwrap(); + + assert!(vote_exists(&votes[0].0, &votes[0].1, &db).await.unwrap()); + assert!(vote_exists(&votes[1].0, &votes[1].1, &db).await.unwrap()); + assert!(!vote_exists(&votes[2].0, &votes[2].1, &db).await.unwrap()); + } } From e525427f3e1582122a59856282ad38d3da0dbd19 Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Thu, 19 Mar 2026 14:56:04 -0400 Subject: [PATCH 10/12] Add missing service and tests for the new handlers --- Cargo.lock | 1 + Cargo.toml | 1 + src/votes/handlers.rs | 148 ++++++++++++++++++++++++++++++++++------ src/votes/repository.rs | 7 ++ src/votes/service.rs | 19 ++++++ 5 files changed, 156 insertions(+), 20 deletions(-) create mode 100644 src/votes/service.rs diff --git a/Cargo.lock b/Cargo.lock index 15643f9..cf454fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1969,6 +1969,7 @@ dependencies = [ "axum-extra", "chrono", "serde", + "serde_json", "sqlx", "tokio", "tower", diff --git a/Cargo.toml b/Cargo.toml index 8e0f767..f0e1c53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ axum = { version = "0.8", features = ["macros"] } axum-extra = { version = "0.12.5", features = ["cookie"] } chrono = "0.4.44" serde = { version = "1", features = ["derive"] } +serde_json = "1" sqlx = { version = "0.8.6", features = [ "runtime-tokio", "postgres", diff --git a/src/votes/handlers.rs b/src/votes/handlers.rs index 0fb7b9e..cd82ad7 100644 --- a/src/votes/handlers.rs +++ b/src/votes/handlers.rs @@ -19,7 +19,7 @@ use crate::{ pub fn router() -> Router { Router::new() .route("/posts/{slug}/vote", post(upvote_handler)) - .route("/posts/{slug}/vote", delete(downvote_handler)) + .route("/posts/{slug}/vote", delete(delete_vote_handler)) .route("/posts/{slug}/votes", get(get_votes_and_voted_handler)) } @@ -60,7 +60,7 @@ async fn upvote_handler( } } -async fn downvote_handler( +async fn delete_vote_handler( jar: CookieJar, Path(slug): Path, State(state): State, @@ -68,10 +68,14 @@ async fn downvote_handler( let (jar, voter_id) = get_or_init_voter_id(jar); match delete_vote(&slug, &voter_id, &state.db).await { - Ok(()) => (StatusCode::OK, jar, "Successfully upvoted"), + Ok(()) => (StatusCode::OK, jar, "Successfully deleted vote"), Err(err) => { println!("{err}"); - (StatusCode::INTERNAL_SERVER_ERROR, jar, "Failed to upvote") + ( + StatusCode::INTERNAL_SERVER_ERROR, + jar, + "Failed to delete vote", + ) } } } @@ -97,19 +101,17 @@ mod tests { body::{Body, to_bytes}, http::{Request, StatusCode, header}, }; - use sqlx::{PgPool, query, query_scalar}; + use serde_json::Value; + use sqlx::{PgPool, query_scalar}; use tower::ServiceExt; use uuid::Uuid; - use crate::{env::Env, state::AppState, test_helpers::db::test_pool}; - - async fn delete_votes_for_slug(slug: &str, db: &PgPool) { - query("delete from votes where slug = $1") - .bind(slug) - .execute(db) - .await - .unwrap(); - } + use crate::{ + env::Env, + state::AppState, + test_helpers::db::test_pool, + votes::repository::{delete_votes_for_slug, insert_new_vote}, + }; async fn get_vote_count_for_slug(slug: &str, db: &PgPool) -> i64 { query_scalar("select count(*) from votes where slug = $1") @@ -132,7 +134,7 @@ mod tests { async fn upvote_sets_cookie_and_inserts_vote() { let db = test_pool().await; let slug = "upvote_handler_sets_cookie_and_inserts_vote"; - delete_votes_for_slug(slug, &db).await; + delete_votes_for_slug(slug, &db).await.unwrap(); let app = crate::app(test_state(db.clone())); @@ -152,7 +154,7 @@ mod tests { assert_eq!(&body[..], b"Successfully upvoted"); assert_eq!(get_vote_count_for_slug(slug, &db).await, 1); - delete_votes_for_slug(slug, &db).await; + delete_votes_for_slug(slug, &db).await.unwrap(); } #[tokio::test] @@ -160,7 +162,7 @@ mod tests { let db = test_pool().await; let slug = "upvote_handler_idempotent_with_same_cookie"; let voter_id = Uuid::from_u128(0xabc); - delete_votes_for_slug(slug, &db).await; + delete_votes_for_slug(slug, &db).await.unwrap(); let cookie = format!("voter_id={voter_id}"); @@ -179,14 +181,14 @@ mod tests { } assert_eq!(get_vote_count_for_slug(slug, &db).await, 1); - delete_votes_for_slug(slug, &db).await; + delete_votes_for_slug(slug, &db).await.unwrap(); } #[tokio::test] async fn upvote_replaces_malformed_cookie() { let db = test_pool().await; let slug = "upvote_handler_replaces_malformed_cookie"; - delete_votes_for_slug(slug, &db).await; + delete_votes_for_slug(slug, &db).await.unwrap(); let app = crate::app(test_state(db.clone())); let request = Request::builder() @@ -206,6 +208,112 @@ mod tests { assert!(!set_cookie.contains("not-a-uuid")); assert_eq!(get_vote_count_for_slug(slug, &db).await, 1); - delete_votes_for_slug(slug, &db).await; + delete_votes_for_slug(slug, &db).await.unwrap(); + } + + #[tokio::test] + async fn delete_vote_removes_vote_for_cookie_voter() { + let db = test_pool().await; + let slug = "delete_vote_handler_removes_vote_for_cookie_voter"; + let voter_id = Uuid::from_u128(0xdef); + delete_votes_for_slug(slug, &db).await.unwrap(); + insert_new_vote(slug, &voter_id, &db).await.unwrap(); + + let app = crate::app(test_state(db.clone())); + let request = Request::builder() + .method("DELETE") + .uri(format!("/posts/{slug}/vote")) + .header(header::COOKIE, format!("voter_id={voter_id}")) + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(&body[..], b"Successfully deleted vote"); + assert_eq!(get_vote_count_for_slug(slug, &db).await, 0); + + delete_votes_for_slug(slug, &db).await.unwrap(); + } + + #[tokio::test] + async fn delete_vote_initializes_cookie_when_missing() { + let db = test_pool().await; + let slug = "delete_vote_handler_initializes_cookie_when_missing"; + delete_votes_for_slug(slug, &db).await.unwrap(); + + let app = crate::app(test_state(db.clone())); + let request = Request::builder() + .method("DELETE") + .uri(format!("/posts/{slug}/vote")) + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert!(response.headers().contains_key(header::SET_COOKIE)); + assert_eq!(get_vote_count_for_slug(slug, &db).await, 0); + + delete_votes_for_slug(slug, &db).await.unwrap(); + } + + #[tokio::test] + async fn get_votes_and_voted_returns_count_and_voted_true_for_existing_vote() { + let db = test_pool().await; + let slug = "get_votes_and_voted_handler_returns_existing_vote"; + let voter_id = Uuid::from_u128(0x123); + let other_voter_id = Uuid::from_u128(0x456); + delete_votes_for_slug(slug, &db).await.unwrap(); + insert_new_vote(slug, &voter_id, &db).await.unwrap(); + insert_new_vote(slug, &other_voter_id, &db).await.unwrap(); + + let app = crate::app(test_state(db.clone())); + let request = Request::builder() + .method("GET") + .uri(format!("/posts/{slug}/votes")) + .header(header::COOKIE, format!("voter_id={voter_id}")) + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let json: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["vote_count"], 2); + assert_eq!(json["voted"], true); + + delete_votes_for_slug(slug, &db).await.unwrap(); + } + + #[tokio::test] + async fn get_votes_and_voted_sets_cookie_and_returns_voted_false_for_new_voter() { + let db = test_pool().await; + let slug = "get_votes_and_voted_handler_sets_cookie_for_new_voter"; + let existing_voter_id = Uuid::from_u128(0x789); + delete_votes_for_slug(slug, &db).await.unwrap(); + insert_new_vote(slug, &existing_voter_id, &db) + .await + .unwrap(); + + let app = crate::app(test_state(db.clone())); + let request = Request::builder() + .method("GET") + .uri(format!("/posts/{slug}/votes")) + .body(Body::empty()) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert!(response.headers().contains_key(header::SET_COOKIE)); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let json: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["vote_count"], 1); + assert_eq!(json["voted"], false); + + delete_votes_for_slug(slug, &db).await.unwrap(); } } diff --git a/src/votes/repository.rs b/src/votes/repository.rs index 853095c..98be0ce 100644 --- a/src/votes/repository.rs +++ b/src/votes/repository.rs @@ -4,6 +4,13 @@ use uuid::Uuid; use crate::votes::model::BestSlugs; +pub async fn delete_votes_for_slug(slug: &str, db: &PgPool) -> Result<()> { + query("delete from votes where slug = $1") + .bind(slug) + .execute(db) + .await?; + Ok(()) +} pub async fn vote_exists(slug: &str, voter_id: &Uuid, db: &PgPool) -> Result { let count: i64 = query_scalar("select count(*) from votes where slug=$1 and voter_id=$2") .bind(slug) diff --git a/src/votes/service.rs b/src/votes/service.rs new file mode 100644 index 0000000..db4842f --- /dev/null +++ b/src/votes/service.rs @@ -0,0 +1,19 @@ +use anyhow::Result; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::votes::{ + dto::VotesAndDidVote, + repository::{get_vote_count_for_slug, vote_exists}, +}; + +pub async fn get_votes_and_voted( + slug: &str, + voter_id: &Uuid, + db: &PgPool, +) -> Result { + let vote_count = get_vote_count_for_slug(slug, db).await?; + let voted = vote_exists(slug, voter_id, db).await?; + + Ok(VotesAndDidVote { vote_count, voted }) +} From 2b110af8456dda049b5f2e947c96f6643acb9674 Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Thu, 19 Mar 2026 14:59:31 -0400 Subject: [PATCH 11/12] Add publish workflow --- .forgejo/workflows/publish.yml | 67 ++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .forgejo/workflows/publish.yml diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml new file mode 100644 index 0000000..5bf46a0 --- /dev/null +++ b/.forgejo/workflows/publish.yml @@ -0,0 +1,67 @@ +name: Publish Cargo Package + +on: + push: + tags: + - "v*" + workflow_dispatch: + +jobs: + publish: + runs-on: docker + env: + CARGO_TERM_COLOR: always + CARGO_REGISTRIES_FORGEJO_TOKEN: Bearer ${{ secrets.FORGEJO_CARGO_TOKEN }} + FORGEJO_CARGO_INDEX: ${{ github.server_url }}/${{ github.repository_owner }}/_cargo-index.git + steps: + - name: Check out repository + uses: https://data.forgejo.org/actions/checkout@v4 + + - name: Install Rust + run: | + if ! command -v cargo >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal + fi + + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + . "$HOME/.cargo/env" + rustup toolchain install stable --profile minimal + rustup default stable + cargo --version + + - name: Configure Cargo registry + run: | + mkdir -p "$HOME/.cargo" + cat > "$HOME/.cargo/config.toml" < Date: Thu, 19 Mar 2026 15:07:53 -0400 Subject: [PATCH 12/12] Fix logging --- src/votes/handlers.rs | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/votes/handlers.rs b/src/votes/handlers.rs index cd82ad7..3eeb713 100644 --- a/src/votes/handlers.rs +++ b/src/votes/handlers.rs @@ -6,6 +6,7 @@ use axum::{ routing::{delete, get, post}, }; use axum_extra::extract::{CookieJar, cookie::Cookie}; +use tracing::{error, info}; use uuid::Uuid; use crate::{ @@ -52,9 +53,12 @@ async fn upvote_handler( let (jar, voter_id) = get_or_init_voter_id(jar); match insert_new_vote(&slug, &voter_id, &state.db).await { - Ok(()) => (StatusCode::OK, jar, "Successfully upvoted"), + Ok(()) => { + info!(slug = %slug, voter_id = %voter_id, "upvoted successfully"); + (StatusCode::OK, jar, "Successfully upvoted") + } Err(err) => { - println!("{err}"); + error!(error = %err, slug = %slug, voter_id = %voter_id, "failed to insert vote"); (StatusCode::INTERNAL_SERVER_ERROR, jar, "Failed to upvote") } } @@ -68,9 +72,12 @@ async fn delete_vote_handler( let (jar, voter_id) = get_or_init_voter_id(jar); match delete_vote(&slug, &voter_id, &state.db).await { - Ok(()) => (StatusCode::OK, jar, "Successfully deleted vote"), + Ok(()) => { + info!(slug = %slug, voter_id = %voter_id, "vote deleted successfully"); + (StatusCode::OK, jar, "Successfully deleted vote") + } Err(err) => { - println!("{err}"); + error!(error = %err, slug = %slug, voter_id = %voter_id, "failed to delete vote"); ( StatusCode::INTERNAL_SERVER_ERROR, jar, @@ -87,9 +94,17 @@ async fn get_votes_and_voted_handler( ) -> impl IntoResponse { let (jar, voter_id) = get_or_init_voter_id(jar); match get_votes_and_voted(&slug, &voter_id, &state.db).await { - Ok(votes_and_voted) => (StatusCode::OK, jar, Json(votes_and_voted)).into_response(), + Ok(votes_and_voted) => { + info!(slug = %slug, voter_id = %voter_id, "fetched vote count and voter status successfully"); + (StatusCode::OK, jar, Json(votes_and_voted)).into_response() + } Err(err) => { - println!("{err}"); + error!( + error = %err, + slug = %slug, + voter_id = %voter_id, + "failed to fetch vote count and voter status" + ); (StatusCode::INTERNAL_SERVER_ERROR, jar, "Failed to upvote").into_response() } }