upvoters/src/votes/handlers.rs

320 lines
10 KiB
Rust
Raw Normal View History

use axum::{
Json, Router,
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{delete, get, post},
};
2026-03-19 13:53:03 -04:00
use axum_extra::extract::{CookieJar, cookie::Cookie};
use uuid::Uuid;
use crate::{
state::AppState,
votes::{
repository::{delete_vote, insert_new_vote},
service::get_votes_and_voted,
},
};
pub fn router() -> Router<AppState> {
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))
2026-03-19 13:53:03 -04:00
}
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<String>,
2026-03-19 13:53:03 -04:00
State(state): State<AppState>,
) -> impl IntoResponse {
let (jar, voter_id) = get_or_init_voter_id(jar);
match insert_new_vote(&slug, &voter_id, &state.db).await {
2026-03-19 13:53:03 -04:00
Ok(()) => (StatusCode::OK, jar, "Successfully upvoted"),
Err(err) => {
println!("{err}");
(StatusCode::INTERNAL_SERVER_ERROR, jar, "Failed to upvote")
}
}
}
async fn delete_vote_handler(
jar: CookieJar,
Path(slug): Path<String>,
State(state): State<AppState>,
) -> impl IntoResponse {
let (jar, voter_id) = get_or_init_voter_id(jar);
match delete_vote(&slug, &voter_id, &state.db).await {
Ok(()) => (StatusCode::OK, jar, "Successfully deleted vote"),
Err(err) => {
println!("{err}");
(
StatusCode::INTERNAL_SERVER_ERROR,
jar,
"Failed to delete vote",
)
}
}
}
async fn get_votes_and_voted_handler(
jar: CookieJar,
Path(slug): Path<String>,
State(state): State<AppState>,
) -> 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) => (StatusCode::OK, jar, Json(votes_and_voted)).into_response(),
Err(err) => {
println!("{err}");
(StatusCode::INTERNAL_SERVER_ERROR, jar, "Failed to upvote").into_response()
}
}
}
2026-03-19 13:53:03 -04:00
#[cfg(test)]
mod tests {
use axum::{
body::{Body, to_bytes},
http::{Request, StatusCode, header},
};
use serde_json::Value;
use sqlx::{PgPool, query_scalar};
2026-03-19 13:53:03 -04:00
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},
};
2026-03-19 13:53:03 -04:00
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.unwrap();
2026-03-19 13:53:03 -04:00
let app = crate::app(test_state(db.clone()));
let request = Request::builder()
.method("POST")
.uri(format!("/posts/{slug}/vote"))
2026-03-19 13:53:03 -04:00
.header(header::CONTENT_TYPE, "application/json")
.body(Body::empty())
2026-03-19 13:53:03 -04:00
.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();
2026-03-19 13:53:03 -04:00
}
#[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();
2026-03-19 13:53:03 -04:00
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"))
2026-03-19 13:53:03 -04:00
.header(header::CONTENT_TYPE, "application/json")
.header(header::COOKIE, &cookie)
.body(Body::empty())
2026-03-19 13:53:03 -04:00
.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();
2026-03-19 13:53:03 -04:00
}
#[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();
2026-03-19 13:53:03 -04:00
let app = crate::app(test_state(db.clone()));
let request = Request::builder()
.method("POST")
.uri(format!("/posts/{slug}/vote"))
2026-03-19 13:53:03 -04:00
.header(header::CONTENT_TYPE, "application/json")
.header(header::COOKIE, "voter_id=not-a-uuid")
.body(Body::empty())
2026-03-19 13:53:03 -04:00
.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();
2026-03-19 13:53:03 -04:00
}
}