diff --git a/thoughts-backend/api/src/routers/feed.rs b/thoughts-backend/api/src/routers/feed.rs index b3bcf51..cc3a9ad 100644 --- a/thoughts-backend/api/src/routers/feed.rs +++ b/thoughts-backend/api/src/routers/feed.rs @@ -1,7 +1,7 @@ use axum::{extract::State, response::IntoResponse, routing::get, Json, Router}; use app::{ - persistence::{follow::get_following_ids, thought::get_feed_for_user}, + persistence::{follow::get_following_ids, thought::get_feed_for_users_and_self}, state::AppState, }; use models::schemas::thought::{ThoughtListSchema, ThoughtSchema}; @@ -24,12 +24,8 @@ async fn feed_get( auth_user: AuthUser, ) -> Result { let following_ids = get_following_ids(&state.conn, auth_user.id).await?; - let mut thoughts_with_authors = - get_feed_for_user(&state.conn, following_ids, Some(auth_user.id)).await?; - - let own_thoughts = - get_feed_for_user(&state.conn, vec![auth_user.id], Some(auth_user.id)).await?; - thoughts_with_authors.extend(own_thoughts); + let thoughts_with_authors = + get_feed_for_users_and_self(&state.conn, auth_user.id, following_ids).await?; let thoughts_schema: Vec = thoughts_with_authors .into_iter() diff --git a/thoughts-backend/api/src/routers/user.rs b/thoughts-backend/api/src/routers/user.rs index 58ef599..7a14210 100644 --- a/thoughts-backend/api/src/routers/user.rs +++ b/thoughts-backend/api/src/routers/user.rs @@ -17,8 +17,14 @@ use app::persistence::{ }; use app::state::AppState; use app::{error::UserError, persistence::user::get_user_by_username}; -use models::schemas::user::{MeSchema, UserListSchema, UserSchema}; -use models::{params::user::UpdateUserParams, schemas::thought::ThoughtListSchema}; +use models::{ + params::user::UpdateUserParams, + schemas::{pagination::PaginatedResponse, thought::ThoughtListSchema}, +}; +use models::{ + queries::pagination::PaginationQuery, + schemas::user::{MeSchema, UserListSchema, UserSchema}, +}; use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema}; use crate::{error::ApiError, extractor::AuthUser}; @@ -418,16 +424,31 @@ async fn get_user_followers( #[utoipa::path( get, path = "/all", + params(PaginationQuery), responses( - (status = 200, description = "A public list of all users", body = UserListSchema) + (status = 200, description = "A public, paginated list of all users", body = PaginatedResponse) ), tag = "user" )] async fn get_all_users_public( State(state): State, + Query(pagination): Query, ) -> Result { - let users = get_all_users(&state.conn).await?; - Ok(Json(UserListSchema::from(users))) + let (users, total_items) = get_all_users(&state.conn, &pagination).await?; + + let page = pagination.page(); + let page_size = pagination.page_size(); + let total_pages = (total_items as f64 / page_size as f64).ceil() as u64; + + let response = PaginatedResponse { + items: users.into_iter().map(UserSchema::from).collect(), + page, + page_size, + total_pages, + total_items, + }; + + Ok(Json(response)) } pub fn create_user_router() -> Router { diff --git a/thoughts-backend/app/src/persistence/thought.rs b/thoughts-backend/app/src/persistence/thought.rs index d60680f..9a2f8a8 100644 --- a/thoughts-backend/app/src/persistence/thought.rs +++ b/thoughts-backend/app/src/persistence/thought.rs @@ -156,6 +156,37 @@ pub async fn get_feed_for_user( .map_err(|e| UserError::Internal(e.to_string())) } +pub async fn get_feed_for_users_and_self( + db: &DbConn, + user_id: Uuid, + following_ids: Vec, +) -> Result, DbErr> { + let mut authors_to_include = following_ids; + authors_to_include.push(user_id); + + thought::Entity::find() + .select_only() + .column(thought::Column::Id) + .column(thought::Column::Content) + .column(thought::Column::ReplyToId) + .column(thought::Column::CreatedAt) + .column(thought::Column::Visibility) + .column(thought::Column::AuthorId) + .column_as(user::Column::Username, "author_username") + .column_as(user::Column::DisplayName, "author_display_name") + .join(JoinType::InnerJoin, thought::Relation::User.def()) + .filter(thought::Column::AuthorId.is_in(authors_to_include)) + .filter( + Condition::any() + .add(thought::Column::Visibility.eq(thought::Visibility::Public)) + .add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly)), + ) + .order_by_desc(thought::Column::CreatedAt) + .into_model::() + .all(db) + .await +} + pub async fn get_thoughts_by_tag_name( db: &DbConn, tag_name: &str, diff --git a/thoughts-backend/app/src/persistence/user.rs b/thoughts-backend/app/src/persistence/user.rs index 0bc678f..ff40b66 100644 --- a/thoughts-backend/app/src/persistence/user.rs +++ b/thoughts-backend/app/src/persistence/user.rs @@ -1,7 +1,8 @@ +use models::queries::pagination::PaginationQuery; use sea_orm::prelude::Uuid; use sea_orm::{ - ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, QueryFilter, QueryOrder, - QuerySelect, RelationTrait, Set, TransactionTrait, + ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, PaginatorTrait, + QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set, TransactionTrait, }; use models::domains::{top_friends, user}; @@ -166,9 +167,16 @@ pub async fn get_followers(db: &DbConn, user_id: Uuid) -> Result Result, DbErr> { - user::Entity::find() +pub async fn get_all_users( + db: &DbConn, + pagination: &PaginationQuery, +) -> Result<(Vec, u64), DbErr> { + let paginator = user::Entity::find() .order_by_desc(user::Column::CreatedAt) - .all(db) - .await + .paginate(db, pagination.page_size()); + + let total_items = paginator.num_items().await?; + let users = paginator.fetch_page(pagination.page() - 1).await?; + + Ok((users, total_items)) } diff --git a/thoughts-backend/models/src/queries/mod.rs b/thoughts-backend/models/src/queries/mod.rs index 22d12a3..b038f7f 100644 --- a/thoughts-backend/models/src/queries/mod.rs +++ b/thoughts-backend/models/src/queries/mod.rs @@ -1 +1,2 @@ +pub mod pagination; pub mod user; diff --git a/thoughts-backend/models/src/queries/pagination.rs b/thoughts-backend/models/src/queries/pagination.rs new file mode 100644 index 0000000..d0a79eb --- /dev/null +++ b/thoughts-backend/models/src/queries/pagination.rs @@ -0,0 +1,27 @@ +use serde::Deserialize; +use utoipa::IntoParams; + +const DEFAULT_PAGE: u64 = 1; +const DEFAULT_PAGE_SIZE: u64 = 20; + +#[derive(Deserialize, IntoParams)] +pub struct PaginationQuery { + #[param(nullable = true, example = 1)] + page: Option, + #[param(nullable = true, example = 20)] + page_size: Option, +} + +impl PaginationQuery { + pub fn page(&self) -> u64 { + self.page.unwrap_or(DEFAULT_PAGE).max(1) + } + + pub fn page_size(&self) -> u64 { + self.page_size.unwrap_or(DEFAULT_PAGE_SIZE).max(1) + } + + pub fn offset(&self) -> u64 { + (self.page() - 1) * self.page_size() + } +} diff --git a/thoughts-backend/models/src/schemas/mod.rs b/thoughts-backend/models/src/schemas/mod.rs index e0cfe5c..2703119 100644 --- a/thoughts-backend/models/src/schemas/mod.rs +++ b/thoughts-backend/models/src/schemas/mod.rs @@ -1,4 +1,5 @@ pub mod api_key; +pub mod pagination; pub mod search; pub mod thought; pub mod user; diff --git a/thoughts-backend/models/src/schemas/pagination.rs b/thoughts-backend/models/src/schemas/pagination.rs new file mode 100644 index 0000000..5bcf608 --- /dev/null +++ b/thoughts-backend/models/src/schemas/pagination.rs @@ -0,0 +1,12 @@ +use serde::Serialize; +use utoipa::ToSchema; + +#[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PaginatedResponse { + pub items: Vec, + pub page: u64, + pub page_size: u64, + pub total_pages: u64, + pub total_items: u64, +} diff --git a/thoughts-backend/tests/api/feed.rs b/thoughts-backend/tests/api/feed.rs index b9cbb40..0e81a13 100644 --- a/thoughts-backend/tests/api/feed.rs +++ b/thoughts-backend/tests/api/feed.rs @@ -1,7 +1,10 @@ +use std::time::Duration; + use super::main::{create_user_with_password, setup}; use axum::http::StatusCode; use http_body_util::BodyExt; use serde_json::json; +use tokio::time::sleep; use utils::testing::make_jwt_request; #[tokio::test] @@ -84,3 +87,80 @@ async fn test_feed_and_user_thoughts() { assert_eq!(v["thoughts"][1]["authorUsername"], "user1"); assert_eq!(v["thoughts"][1]["content"], "A thought from user1"); } + +#[tokio::test] +async fn test_feed_strict_chronological_order() { + let app = setup().await; + create_user_with_password(&app.db, "user1", "password123", "u1@e.com").await; + create_user_with_password(&app.db, "user2", "password123", "u2@e.com").await; + create_user_with_password(&app.db, "user3", "password123", "u3@e.com").await; + + let token1 = super::main::login_user(app.router.clone(), "user1", "password123").await; + let token2 = super::main::login_user(app.router.clone(), "user2", "password123").await; + let token3 = super::main::login_user(app.router.clone(), "user3", "password123").await; + + make_jwt_request( + app.router.clone(), + "/users/user2/follow", + "POST", + None, + &token1, + ) + .await; + make_jwt_request( + app.router.clone(), + "/users/user3/follow", + "POST", + None, + &token1, + ) + .await; + + let body_t1 = json!({ "content": "Thought 1 from user2" }).to_string(); + make_jwt_request( + app.router.clone(), + "/thoughts", + "POST", + Some(body_t1), + &token2, + ) + .await; + sleep(Duration::from_millis(10)).await; + + let body_t2 = json!({ "content": "Thought 2 from user3" }).to_string(); + make_jwt_request( + app.router.clone(), + "/thoughts", + "POST", + Some(body_t2), + &token3, + ) + .await; + sleep(Duration::from_millis(10)).await; + + let body_t3 = json!({ "content": "Thought 3 from user2" }).to_string(); + make_jwt_request( + app.router.clone(), + "/thoughts", + "POST", + Some(body_t3), + &token2, + ) + .await; + + let response = make_jwt_request(app.router.clone(), "/feed", "GET", None, &token1).await; + assert_eq!(response.status(), StatusCode::OK); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); + let thoughts = v["thoughts"].as_array().unwrap(); + + assert_eq!( + thoughts.len(), + 3, + "Feed should contain 3 thoughts from followed users" + ); + assert_eq!(thoughts[0]["content"], "Thought 3 from user2"); + assert_eq!(thoughts[1]["content"], "Thought 2 from user3"); + assert_eq!(thoughts[2]["content"], "Thought 1 from user2"); +} diff --git a/thoughts-backend/tests/api/user.rs b/thoughts-backend/tests/api/user.rs index db94bf8..7da4a10 100644 --- a/thoughts-backend/tests/api/user.rs +++ b/thoughts-backend/tests/api/user.rs @@ -247,23 +247,44 @@ async fn test_update_me_css_and_images() { } #[tokio::test] -async fn test_get_all_users_public() { +async fn test_get_all_users_paginated() { let app = setup().await; - create_user_with_password(&app.db, "userA", "password123", "a@example.com").await; - create_user_with_password(&app.db, "userB", "password123", "b@example.com").await; - create_user_with_password(&app.db, "userC", "password123", "c@example.com").await; + for i in 0..25 { + create_user_with_password( + &app.db, + &format!("user{}", i), + "password123", + &format!("u{}@e.com", i), + ) + .await; + } - let response = make_get_request(app.router.clone(), "/users/all", None).await; - assert_eq!(response.status(), StatusCode::OK); - - let body = response.into_body().collect().await.unwrap().to_bytes(); - let v: Value = serde_json::from_slice(&body).unwrap(); - let users_list = v["users"].as_array().unwrap(); + let response_p1 = make_get_request(app.router.clone(), "/users/all", None).await; + assert_eq!(response_p1.status(), StatusCode::OK); + let body_p1 = response_p1.into_body().collect().await.unwrap().to_bytes(); + let v_p1: Value = serde_json::from_slice(&body_p1).unwrap(); assert_eq!( - users_list.len(), - 3, - "Should return a list of all 3 registered users" + v_p1["items"].as_array().unwrap().len(), + 20, + "First page should have 20 items" ); + assert_eq!(v_p1["page"], 1); + assert_eq!(v_p1["pageSize"], 20); + assert_eq!(v_p1["totalPages"], 2); + assert_eq!(v_p1["totalItems"], 25); + + let response_p2 = make_get_request(app.router.clone(), "/users/all?page=2", None).await; + assert_eq!(response_p2.status(), StatusCode::OK); + let body_p2 = response_p2.into_body().collect().await.unwrap().to_bytes(); + let v_p2: Value = serde_json::from_slice(&body_p2).unwrap(); + + assert_eq!( + v_p2["items"].as_array().unwrap().len(), + 5, + "Second page should have 5 items" + ); + assert_eq!(v_p2["page"], 2); + assert_eq!(v_p2["totalPages"], 2); }