diff --git a/.forgejo/workflows/publish.yml b/.forgejo/workflows/publish.yml deleted file mode 100644 index 5bf46a0..0000000 --- a/.forgejo/workflows/publish.yml +++ /dev/null @@ -1,67 +0,0 @@ -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 174acf8..6cda382 100644 --- a/src/votes/dto.rs +++ b/src/votes/dto.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize)] -pub struct VotesAndDidVote { - pub vote_count: i64, - pub voted: bool, -} +#[derive(Deserialize)] +pub struct CreateVoteRequest {} + +#[derive(Serialize)] +pub struct VoteResponse {} diff --git a/src/votes/handlers.rs b/src/votes/handlers.rs index 3eeb713..7deb489 100644 --- a/src/votes/handlers.rs +++ b/src/votes/handlers.rs @@ -1,334 +1,13 @@ use axum::{ - Json, Router, + Router, extract::{Path, State}, - http::StatusCode, - response::IntoResponse, - routing::{delete, get, post}, + routing::{get, post}, }; -use axum_extra::extract::{CookieJar, cookie::Cookie}; -use tracing::{error, info}; -use uuid::Uuid; -use crate::{ - state::AppState, - votes::{ - repository::{delete_vote, insert_new_vote}, - service::get_votes_and_voted, - }, -}; +use crate::state::AppState; + +use super::dto::CreateVoteRequest; 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 df5e2e0..52d2039 100644 --- a/src/votes/model.rs +++ b/src/votes/model.rs @@ -1,18 +1 @@ -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, -} +pub struct Vote {} diff --git a/src/votes/repository.rs b/src/votes/repository.rs index 98be0ce..42e969f 100644 --- a/src/votes/repository.rs +++ b/src/votes/repository.rs @@ -1,279 +1,3 @@ -use anyhow::Result; -use sqlx::{PgPool, query, query_as, query_scalar}; -use uuid::Uuid; +use sqlx::PgPool; -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()); - } -} +use super::model::Vote; diff --git a/src/votes/service.rs b/src/votes/service.rs index db4842f..440858a 100644 --- a/src/votes/service.rs +++ b/src/votes/service.rs @@ -1,19 +1,3 @@ -use anyhow::Result; -use sqlx::PgPool; -use uuid::Uuid; +use crate::state::AppState; -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 }) -} +use super::dto::{CreateVoteRequest, VoteResponse};