From 3ee97b5d4315f018715a71961305c9d4caba854b Mon Sep 17 00:00:00 2001 From: Alex Selimov Date: Mon, 15 Jun 2026 13:21:50 -0400 Subject: [PATCH] Add route for getting most popular posts --- src/env.rs | 3 +- src/main.rs | 6 ++-- src/votes/handlers.rs | 80 +++++++++++++++++++++++++++++++++++++++++-- src/votes/model.rs | 3 +- 4 files changed, 84 insertions(+), 8 deletions(-) diff --git a/src/env.rs b/src/env.rs index 5b44f38..273e07f 100644 --- a/src/env.rs +++ b/src/env.rs @@ -18,8 +18,7 @@ impl Env { .map(|s| s.trim().to_string()) .collect(); - let port = env::var("PORT") - .unwrap_or_else(|_| "3000".to_string()); + let port = env::var("PORT").unwrap_or_else(|_| "3000".to_string()); Env { postgres_connection_string, diff --git a/src/main.rs b/src/main.rs index f7280c3..91cb63b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,9 +14,9 @@ async fn main() { let state = AppState::new().await; let port = &state.env.port; - let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await.unwrap(); - tracing::info!("listening on {}", listener.local_addr().unwrap()); - axum::serve(listener, upvoters::app(state)) + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")) .await .unwrap(); + tracing::info!("listening on {}", listener.local_addr().unwrap()); + axum::serve(listener, upvoters::app(state)).await.unwrap(); } diff --git a/src/votes/handlers.rs b/src/votes/handlers.rs index ced29a9..21c15ed 100644 --- a/src/votes/handlers.rs +++ b/src/votes/handlers.rs @@ -1,18 +1,19 @@ use axum::{ Json, Router, - extract::{Path, State}, + 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, insert_new_vote}, + repository::{delete_vote, get_top_n_slugs, insert_new_vote}, service::get_votes_and_voted, }, }; @@ -22,6 +23,7 @@ pub fn router() -> Router { .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) { @@ -110,6 +112,30 @@ async fn get_votes_and_voted_handler( } } +#[derive(Serialize, Deserialize)] +struct BestVotesQuery { + top: usize, +} + +async fn get_best_handler( + jar: CookieJar, + State(state): State, + query: Query, +) -> 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::{ @@ -333,4 +359,54 @@ mod tests { 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(); + } + } } diff --git a/src/votes/model.rs b/src/votes/model.rs index df5e2e0..4993c5d 100644 --- a/src/votes/model.rs +++ b/src/votes/model.rs @@ -1,4 +1,5 @@ use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; use sqlx::prelude::FromRow; use uuid::Uuid; @@ -11,7 +12,7 @@ pub struct Vote { } /// Struct representing the best slug posts -#[derive(Debug, FromRow)] +#[derive(Debug, FromRow, Serialize, Deserialize)] pub struct BestSlugs { pub slug: String, pub vote_count: i64,