Add upvote handler
This commit is contained in:
parent
ef3c5c7fe8
commit
3bff31ff98
3 changed files with 167 additions and 13 deletions
|
|
@ -1,13 +1,171 @@
|
||||||
use axum::{
|
use axum::{Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::post};
|
||||||
Router,
|
use axum_extra::extract::{CookieJar, cookie::Cookie};
|
||||||
extract::{Path, State},
|
use serde::Deserialize;
|
||||||
routing::{get, post},
|
use uuid::Uuid;
|
||||||
};
|
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::{state::AppState, votes::repository::insert_new_vote};
|
||||||
|
|
||||||
use super::dto::CreateVoteRequest;
|
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
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<AppState>,
|
||||||
|
Json(body): Json<UpvotePayload>,
|
||||||
|
) -> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,3 @@ pub mod dto;
|
||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
pub mod service;
|
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,6 @@ mod postgres_tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore]
|
|
||||||
pub async fn postgres_tests() {
|
pub async fn postgres_tests() {
|
||||||
let db = test_pool().await;
|
let db = test_pool().await;
|
||||||
let votes = vec![
|
let votes = vec![
|
||||||
|
|
@ -174,7 +173,6 @@ mod postgres_tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore]
|
|
||||||
pub async fn insert_idempotency_test() {
|
pub async fn insert_idempotency_test() {
|
||||||
let db = test_pool().await;
|
let db = test_pool().await;
|
||||||
let votes = vec![(
|
let votes = vec![(
|
||||||
|
|
@ -210,7 +208,6 @@ mod postgres_tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore]
|
|
||||||
pub async fn delete_idempotency_test() {
|
pub async fn delete_idempotency_test() {
|
||||||
let db = test_pool().await;
|
let db = test_pool().await;
|
||||||
let votes = vec![(
|
let votes = vec![(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue