use axum::{ Router, extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::post, }; use axum_extra::extract::{CookieJar, cookie::Cookie}; use uuid::Uuid; use crate::{state::AppState, votes::repository::insert_new_vote}; pub fn router() -> Router { Router::new().route("/posts/{slug}/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) } } } async fn upvote_handler( jar: CookieJar, Path(slug): Path, State(state): State, ) -> impl IntoResponse { 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"), 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(format!("/posts/{slug}/vote")) .header(header::CONTENT_TYPE, "application/json") .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(); 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(format!("/posts/{slug}/vote")) .header(header::CONTENT_TYPE, "application/json") .header(header::COOKIE, &cookie) .body(Body::empty()) .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(format!("/posts/{slug}/vote")) .header(header::CONTENT_TYPE, "application/json") .header(header::COOKIE, "voter_id=not-a-uuid") .body(Body::empty()) .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; } }