From e525427f3e1582122a59856282ad38d3da0dbd19 Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Thu, 19 Mar 2026 14:56:04 -0400 Subject: [PATCH] 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 }) +}