use axum::{ Json, Router, extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, routing::{delete, get, post}, }; use axum_extra::extract::{CookieJar, cookie::Cookie}; use serde::{Deserialize, Serialize}; use tracing::{error, info}; use uuid::Uuid; use crate::{ state::AppState, votes::{ repository::{delete_vote, get_top_n_slugs, insert_new_vote}, service::get_votes_and_voted, }, }; pub fn router() -> Router { Router::new() .route("/posts/{slug}/vote", post(upvote_handler)) .route("/posts/{slug}/vote", delete(delete_vote_handler)) .route("/posts/{slug}/votes", get(get_votes_and_voted_handler)) .route("/posts/best", get(get_best_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(()) => { info!(slug = %slug, voter_id = %voter_id, "upvoted successfully"); (StatusCode::OK, jar, "Successfully upvoted") } Err(err) => { error!(error = %err, slug = %slug, voter_id = %voter_id, "failed to insert vote"); (StatusCode::INTERNAL_SERVER_ERROR, jar, "Failed to upvote") } } } async fn delete_vote_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(()) => { info!(slug = %slug, voter_id = %voter_id, "vote deleted successfully"); (StatusCode::OK, jar, "Successfully deleted vote") } Err(err) => { error!(error = %err, slug = %slug, voter_id = %voter_id, "failed to delete vote"); ( StatusCode::INTERNAL_SERVER_ERROR, jar, "Failed to delete vote", ) } } } 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) => { 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) => { 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() } } } #[derive(Serialize, Deserialize)] struct BestVotesQuery { top: usize, } async fn get_best_handler( jar: CookieJar, State(state): State, query: Query, ) -> impl IntoResponse { match get_top_n_slugs(query.top as i64, &state.db).await { Ok(top_slugs) => { info!(n=%query.top, "fetched most popular slugs successfully"); (StatusCode::OK, jar, Json(top_slugs)).into_response() } Err(err) => { error!( error = %err, "failed to fetch popular slugs" ); (StatusCode::INTERNAL_SERVER_ERROR, jar, "Failed to upvote").into_response() } } } #[cfg(test)] mod tests { use axum::{ body::{Body, to_bytes}, http::{Request, StatusCode, header}, }; 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, 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") .bind(slug) .fetch_one(db) .await .unwrap() } fn test_state(db: PgPool) -> AppState { AppState { db, env: Env { postgres_connection_string: String::new(), allowed_origins: vec![], port: "3000".to_string(), }, } } #[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.unwrap(); 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.unwrap(); } #[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.unwrap(); 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.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.unwrap(); 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.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(); } #[tokio::test] async fn get_best_returns_top_slugs_limited_by_query() { let db = test_pool().await; let slugs = [ "get_best_handler_top_post_1", "get_best_handler_top_post_2", "get_best_handler_top_post_3", ]; for slug in slugs { delete_votes_for_slug(slug, &db).await.unwrap(); } for voter_id in [ Uuid::from_u128(0x100), Uuid::from_u128(0x101), Uuid::from_u128(0x102), ] { insert_new_vote(slugs[0], &voter_id, &db).await.unwrap(); } for voter_id in [Uuid::from_u128(0x200), Uuid::from_u128(0x201)] { insert_new_vote(slugs[1], &voter_id, &db).await.unwrap(); } insert_new_vote(slugs[2], &Uuid::from_u128(0x300), &db) .await .unwrap(); let app = crate::app(test_state(db.clone())); let request = Request::builder() .method("GET") .uri("/posts/best?top=2") .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.as_array().unwrap().len(), 2); assert_eq!(json[0]["slug"], slugs[0]); assert_eq!(json[0]["vote_count"], 3); assert_eq!(json[1]["slug"], slugs[1]); assert_eq!(json[1]["vote_count"], 2); for slug in slugs { delete_votes_for_slug(slug, &db).await.unwrap(); } } }