diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml new file mode 100644 index 0000000..5bf46a0 --- /dev/null +++ b/.forgejo/workflows/publish.yml @@ -0,0 +1,67 @@ +name: Publish Cargo Package + +on: + push: + tags: + - "v*" + workflow_dispatch: + +jobs: + publish: + runs-on: docker + env: + CARGO_TERM_COLOR: always + CARGO_REGISTRIES_FORGEJO_TOKEN: Bearer ${{ secrets.FORGEJO_CARGO_TOKEN }} + FORGEJO_CARGO_INDEX: ${{ github.server_url }}/${{ github.repository_owner }}/_cargo-index.git + steps: + - name: Check out repository + uses: https://data.forgejo.org/actions/checkout@v4 + + - name: Install Rust + run: | + if ! command -v cargo >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal + fi + + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + . "$HOME/.cargo/env" + rustup toolchain install stable --profile minimal + rustup default stable + cargo --version + + - name: Configure Cargo registry + run: | + mkdir -p "$HOME/.cargo" + cat > "$HOME/.cargo/config.toml" < PgPool { + let conn = std::env::var("POSTGRES_CONNECTION_STRING") + .unwrap_or_else(|_| DEFAULT_CONNECTION_STRING.to_string()); + + PgPool::connect(&conn) + .await + .expect("failed to connect to test database") + } +} diff --git a/src/votes/dto.rs b/src/votes/dto.rs index 6cda382..174acf8 100644 --- a/src/votes/dto.rs +++ b/src/votes/dto.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; -#[derive(Deserialize)] -pub struct CreateVoteRequest {} - -#[derive(Serialize)] -pub struct VoteResponse {} +#[derive(Serialize, Deserialize)] +pub struct VotesAndDidVote { + pub vote_count: i64, + pub voted: bool, +} diff --git a/src/votes/handlers.rs b/src/votes/handlers.rs index 7deb489..3eeb713 100644 --- a/src/votes/handlers.rs +++ b/src/votes/handlers.rs @@ -1,13 +1,334 @@ use axum::{ - Router, + Json, Router, extract::{Path, State}, - routing::{get, post}, + http::StatusCode, + response::IntoResponse, + routing::{delete, get, post}, }; +use axum_extra::extract::{CookieJar, cookie::Cookie}; +use tracing::{error, info}; +use uuid::Uuid; -use crate::state::AppState; - -use super::dto::CreateVoteRequest; +use crate::{ + state::AppState, + votes::{ + repository::{delete_vote, 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)) +} + +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() + } + } +} + +#[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(), + }, + } + } + + #[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(); + } } diff --git a/src/votes/model.rs b/src/votes/model.rs index 52d2039..df5e2e0 100644 --- a/src/votes/model.rs +++ b/src/votes/model.rs @@ -1 +1,18 @@ -pub struct Vote {} +use chrono::{DateTime, Utc}; +use sqlx::prelude::FromRow; +use uuid::Uuid; + +/// Struct representing a single vote (row) in the votes table +#[derive(Debug, FromRow)] +pub struct Vote { + pub slug: String, + pub voter_id: Uuid, + pub created_at: DateTime, +} + +/// Struct representing the best slug posts +#[derive(Debug, FromRow)] +pub struct BestSlugs { + pub slug: String, + pub vote_count: i64, +} diff --git a/src/votes/repository.rs b/src/votes/repository.rs index 42e969f..98be0ce 100644 --- a/src/votes/repository.rs +++ b/src/votes/repository.rs @@ -1,3 +1,279 @@ -use sqlx::PgPool; +use anyhow::Result; +use sqlx::{PgPool, query, query_as, query_scalar}; +use uuid::Uuid; -use super::model::Vote; +use crate::votes::model::BestSlugs; + +pub async fn delete_votes_for_slug(slug: &str, db: &PgPool) -> Result<()> { + query("delete from votes where slug = $1") + .bind(slug) + .execute(db) + .await?; + Ok(()) +} +pub async fn vote_exists(slug: &str, voter_id: &Uuid, db: &PgPool) -> Result { + let count: i64 = query_scalar("select count(*) from votes where slug=$1 and voter_id=$2") + .bind(slug) + .bind(voter_id) + .fetch_one(db) + .await?; + Ok(count > 0) +} +pub async fn insert_new_vote(slug: &str, voter_id: &Uuid, db: &PgPool) -> Result<()> { + query( + r#"insert into votes (slug, voter_id) + values ($1, $2) + on conflict (slug, voter_id) do nothing"#, + ) + .bind(slug) + .bind(voter_id) + .execute(db) + .await?; + Ok(()) +} + +pub async fn get_vote_count_for_slug(slug: &str, db: &PgPool) -> Result { + let count: i64 = query_scalar("select count(*) from votes where slug=$1") + .bind(slug) + .fetch_one(db) + .await?; + Ok(count) +} + +pub async fn get_top_n_slugs(n: i64, db: &PgPool) -> Result> { + if n > 0 { + let top_slugs = query_as::<_, BestSlugs>( + r#"select slug, count(*) AS vote_count + from votes + group by slug + order by vote_count desc + limit $1 + "#, + ) + .bind(n) + .fetch_all(db) + .await?; + Ok(top_slugs) + } else { + Ok(vec![]) + } +} + +pub async fn delete_vote(slug: &str, voter_id: &Uuid, db: &PgPool) -> Result<()> { + query("delete from votes where slug=$1 and voter_id=$2") + .bind(slug) + .bind(voter_id) + .execute(db) + .await?; + Ok(()) +} + +#[cfg(test)] +mod postgres_tests { + use sqlx::PgPool; + use uuid::Uuid; + + use crate::{ + test_helpers::db::test_pool, + votes::repository::{ + delete_vote, get_top_n_slugs, get_vote_count_for_slug, insert_new_vote, vote_exists, + }, + }; + + async fn cleanup(db: &PgPool, votes: &[(String, Uuid)]) { + for (slug, voter_id) in votes { + delete_vote(slug, voter_id, db).await.unwrap() + } + } + + #[tokio::test] + pub async fn postgres_tests() { + let db = test_pool().await; + let votes = vec![ + ( + "postgres_tests_blog_post1".to_string(), + Uuid::from_u128(0x1), + ), + ( + "postgres_tests_blog_post1".to_string(), + Uuid::from_u128(0x2), + ), + ( + "postgres_tests_blog_post2".to_string(), + Uuid::from_u128(0x3), + ), + ( + "postgres_tests_blog_post2".to_string(), + Uuid::from_u128(0x4), + ), + ( + "postgres_tests_blog_post3".to_string(), + Uuid::from_u128(0x5), + ), + ( + "postgres_tests_blog_post3".to_string(), + Uuid::from_u128(0x6), + ), + ( + "postgres_tests_blog_post1".to_string(), + Uuid::from_u128(0x7), + ), + ( + "postgres_tests_blog_post1".to_string(), + Uuid::from_u128(0x8), + ), + ( + "postgres_tests_blog_post3".to_string(), + Uuid::from_u128(0x9), + ), + ]; + cleanup(&db, &votes).await; + + for (slug, voter_id) in votes.iter() { + insert_new_vote(slug, voter_id, &db) + .await + .expect("Insertions to db failed"); + } + + assert_eq!( + get_vote_count_for_slug("postgres_tests_blog_post1", &db) + .await + .unwrap(), + 4 + ); + assert_eq!( + get_vote_count_for_slug("postgres_tests_blog_post2", &db) + .await + .unwrap(), + 2 + ); + assert_eq!( + get_vote_count_for_slug("postgres_tests_blog_post3", &db) + .await + .unwrap(), + 3 + ); + + let top_2 = get_top_n_slugs(2, &db).await.unwrap(); + assert_eq!(top_2[0].slug, "postgres_tests_blog_post1"); + assert_eq!(top_2[1].slug, "postgres_tests_blog_post3"); + + delete_vote(&votes[4].0, &votes[4].1, &db).await.unwrap(); + delete_vote(&votes[5].0, &votes[5].1, &db).await.unwrap(); + + assert_eq!( + get_vote_count_for_slug("postgres_tests_blog_post1", &db) + .await + .unwrap(), + 4 + ); + assert_eq!( + get_vote_count_for_slug("postgres_tests_blog_post2", &db) + .await + .unwrap(), + 2 + ); + assert_eq!( + get_vote_count_for_slug("postgres_tests_blog_post3", &db) + .await + .unwrap(), + 1 + ); + + let top_2 = get_top_n_slugs(2, &db).await.unwrap(); + assert_eq!(top_2[0].slug, "postgres_tests_blog_post1"); + assert_eq!(top_2[1].slug, "postgres_tests_blog_post2"); + + cleanup(&db, &votes).await; + } + + #[tokio::test] + pub async fn insert_idempotency_test() { + let db = test_pool().await; + let votes = vec![( + "insert_idempotency_test_blog_post1".to_string(), + Uuid::from_u128(0x1), + )]; + cleanup(&db, &votes).await; + + insert_new_vote(&votes[0].0, &votes[0].1, &db) + .await + .unwrap(); + insert_new_vote(&votes[0].0, &votes[0].1, &db) + .await + .unwrap(); + insert_new_vote(&votes[0].0, &votes[0].1, &db) + .await + .unwrap(); + insert_new_vote(&votes[0].0, &votes[0].1, &db) + .await + .unwrap(); + insert_new_vote(&votes[0].0, &votes[0].1, &db) + .await + .unwrap(); + insert_new_vote(&votes[0].0, &votes[0].1, &db) + .await + .unwrap(); + + let votes_count = get_vote_count_for_slug(&votes[0].0, &db).await.unwrap(); + + assert_eq!(votes_count, 1); + + cleanup(&db, &votes).await; + } + + #[tokio::test] + pub async fn delete_idempotency_test() { + let db = test_pool().await; + let votes = vec![( + "delete_idempotency_test_blog_post1".to_string(), + Uuid::from_u128(0x1), + )]; + cleanup(&db, &votes).await; + + insert_new_vote(&votes[0].0, &votes[0].1, &db) + .await + .unwrap(); + + delete_vote(&votes[0].0, &votes[0].1, &db).await.unwrap(); + delete_vote(&votes[0].0, &votes[0].1, &db).await.unwrap(); + delete_vote(&votes[0].0, &votes[0].1, &db).await.unwrap(); + + let votes_count = get_vote_count_for_slug(&votes[0].0, &db).await.unwrap(); + + assert_eq!(votes_count, 0); + + cleanup(&db, &votes).await; + } + + #[tokio::test] + pub async fn vote_exists_test() { + let db = test_pool().await; + let votes = vec![ + ( + "vote_exists_test_blog_post1".to_string(), + Uuid::from_u128(0x1), + ), + ( + "vote_exists_test_blog_post2".to_string(), + Uuid::from_u128(0x2), + ), + ( + "vote_exists_test_blog_post3".to_string(), + Uuid::from_u128(0x3), + ), + ]; + cleanup(&db, &votes).await; + + insert_new_vote(&votes[0].0, &votes[0].1, &db) + .await + .unwrap(); + insert_new_vote(&votes[1].0, &votes[1].1, &db) + .await + .unwrap(); + + assert!(vote_exists(&votes[0].0, &votes[0].1, &db).await.unwrap()); + assert!(vote_exists(&votes[1].0, &votes[1].1, &db).await.unwrap()); + assert!(!vote_exists(&votes[2].0, &votes[2].1, &db).await.unwrap()); + } +} diff --git a/src/votes/service.rs b/src/votes/service.rs index 440858a..db4842f 100644 --- a/src/votes/service.rs +++ b/src/votes/service.rs @@ -1,3 +1,19 @@ -use crate::state::AppState; +use anyhow::Result; +use sqlx::PgPool; +use uuid::Uuid; -use super::dto::{CreateVoteRequest, VoteResponse}; +use crate::votes::{ + dto::VotesAndDidVote, + repository::{get_vote_count_for_slug, vote_exists}, +}; + +pub async fn get_votes_and_voted( + slug: &str, + voter_id: &Uuid, + db: &PgPool, +) -> Result { + let vote_count = get_vote_count_for_slug(slug, db).await?; + let voted = vote_exists(slug, voter_id, db).await?; + + Ok(VotesAndDidVote { vote_count, voted }) +}