412 lines
14 KiB
Rust
412 lines
14 KiB
Rust
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<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))
|
|
.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<String>,
|
|
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 {
|
|
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<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(()) => {
|
|
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<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) => {
|
|
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<AppState>,
|
|
query: Query<BestVotesQuery>,
|
|
) -> 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();
|
|
}
|
|
}
|
|
}
|