From 3bff31ff98d717a13aedf1ffc3a6631181756565 Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Thu, 19 Mar 2026 13:53:03 -0400 Subject: [PATCH] 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![(