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, votes::repository::insert_new_vote}; pub fn router() -> Router { 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; } }