feat: implement pagination for user retrieval and update feed fetching logic
All checks were successful
Build and Deploy Thoughts / build-and-deploy-local (push) Successful in 2m30s

This commit is contained in:
2025-09-09 02:53:24 +02:00
parent 863bc90c6f
commit d92c9a747e
10 changed files with 229 additions and 31 deletions

View File

@@ -1,7 +1,7 @@
use axum::{extract::State, response::IntoResponse, routing::get, Json, Router}; use axum::{extract::State, response::IntoResponse, routing::get, Json, Router};
use app::{ 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, state::AppState,
}; };
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema}; use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
@@ -24,12 +24,8 @@ async fn feed_get(
auth_user: AuthUser, auth_user: AuthUser,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let following_ids = get_following_ids(&state.conn, auth_user.id).await?; let following_ids = get_following_ids(&state.conn, auth_user.id).await?;
let mut thoughts_with_authors = let thoughts_with_authors =
get_feed_for_user(&state.conn, following_ids, Some(auth_user.id)).await?; get_feed_for_users_and_self(&state.conn, auth_user.id, following_ids).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_schema: Vec<ThoughtSchema> = thoughts_with_authors let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
.into_iter() .into_iter()

View File

@@ -17,8 +17,14 @@ use app::persistence::{
}; };
use app::state::AppState; use app::state::AppState;
use app::{error::UserError, persistence::user::get_user_by_username}; use app::{error::UserError, persistence::user::get_user_by_username};
use models::schemas::user::{MeSchema, UserListSchema, UserSchema}; use models::{
use models::{params::user::UpdateUserParams, schemas::thought::ThoughtListSchema}; 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 models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
use crate::{error::ApiError, extractor::AuthUser}; use crate::{error::ApiError, extractor::AuthUser};
@@ -418,16 +424,31 @@ async fn get_user_followers(
#[utoipa::path( #[utoipa::path(
get, get,
path = "/all", path = "/all",
params(PaginationQuery),
responses( responses(
(status = 200, description = "A public list of all users", body = UserListSchema) (status = 200, description = "A public, paginated list of all users", body = PaginatedResponse<UserSchema>)
), ),
tag = "user" tag = "user"
)] )]
async fn get_all_users_public( async fn get_all_users_public(
State(state): State<AppState>, State(state): State<AppState>,
Query(pagination): Query<PaginationQuery>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let users = get_all_users(&state.conn).await?; let (users, total_items) = get_all_users(&state.conn, &pagination).await?;
Ok(Json(UserListSchema::from(users)))
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<AppState> { pub fn create_user_router() -> Router<AppState> {

View File

@@ -156,6 +156,37 @@ pub async fn get_feed_for_user(
.map_err(|e| UserError::Internal(e.to_string())) .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<Uuid>,
) -> Result<Vec<ThoughtWithAuthor>, 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::<ThoughtWithAuthor>()
.all(db)
.await
}
pub async fn get_thoughts_by_tag_name( pub async fn get_thoughts_by_tag_name(
db: &DbConn, db: &DbConn,
tag_name: &str, tag_name: &str,

View File

@@ -1,7 +1,8 @@
use models::queries::pagination::PaginationQuery;
use sea_orm::prelude::Uuid; use sea_orm::prelude::Uuid;
use sea_orm::{ use sea_orm::{
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, QueryFilter, QueryOrder, ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, PaginatorTrait,
QuerySelect, RelationTrait, Set, TransactionTrait, QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set, TransactionTrait,
}; };
use models::domains::{top_friends, user}; use models::domains::{top_friends, user};
@@ -166,9 +167,16 @@ pub async fn get_followers(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model
get_users_by_ids(db, follower_ids).await get_users_by_ids(db, follower_ids).await
} }
pub async fn get_all_users(db: &DbConn) -> Result<Vec<user::Model>, DbErr> { pub async fn get_all_users(
user::Entity::find() db: &DbConn,
pagination: &PaginationQuery,
) -> Result<(Vec<user::Model>, u64), DbErr> {
let paginator = user::Entity::find()
.order_by_desc(user::Column::CreatedAt) .order_by_desc(user::Column::CreatedAt)
.all(db) .paginate(db, pagination.page_size());
.await
let total_items = paginator.num_items().await?;
let users = paginator.fetch_page(pagination.page() - 1).await?;
Ok((users, total_items))
} }

View File

@@ -1 +1,2 @@
pub mod pagination;
pub mod user; pub mod user;

View File

@@ -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<u64>,
#[param(nullable = true, example = 20)]
page_size: Option<u64>,
}
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()
}
}

View File

@@ -1,4 +1,5 @@
pub mod api_key; pub mod api_key;
pub mod pagination;
pub mod search; pub mod search;
pub mod thought; pub mod thought;
pub mod user; pub mod user;

View File

@@ -0,0 +1,12 @@
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct PaginatedResponse<T> {
pub items: Vec<T>,
pub page: u64,
pub page_size: u64,
pub total_pages: u64,
pub total_items: u64,
}

View File

@@ -1,7 +1,10 @@
use std::time::Duration;
use super::main::{create_user_with_password, setup}; use super::main::{create_user_with_password, setup};
use axum::http::StatusCode; use axum::http::StatusCode;
use http_body_util::BodyExt; use http_body_util::BodyExt;
use serde_json::json; use serde_json::json;
use tokio::time::sleep;
use utils::testing::make_jwt_request; use utils::testing::make_jwt_request;
#[tokio::test] #[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]["authorUsername"], "user1");
assert_eq!(v["thoughts"][1]["content"], "A thought from 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");
}

View File

@@ -247,23 +247,44 @@ async fn test_update_me_css_and_images() {
} }
#[tokio::test] #[tokio::test]
async fn test_get_all_users_public() { async fn test_get_all_users_paginated() {
let app = setup().await; let app = setup().await;
create_user_with_password(&app.db, "userA", "password123", "a@example.com").await; for i in 0..25 {
create_user_with_password(&app.db, "userB", "password123", "b@example.com").await; create_user_with_password(
create_user_with_password(&app.db, "userC", "password123", "c@example.com").await; &app.db,
&format!("user{}", i),
"password123",
&format!("u{}@e.com", i),
)
.await;
}
let response = make_get_request(app.router.clone(), "/users/all", None).await; let response_p1 = make_get_request(app.router.clone(), "/users/all", None).await;
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response_p1.status(), StatusCode::OK);
let body_p1 = response_p1.into_body().collect().await.unwrap().to_bytes();
let body = response.into_body().collect().await.unwrap().to_bytes(); let v_p1: Value = serde_json::from_slice(&body_p1).unwrap();
let v: Value = serde_json::from_slice(&body).unwrap();
let users_list = v["users"].as_array().unwrap();
assert_eq!( assert_eq!(
users_list.len(), v_p1["items"].as_array().unwrap().len(),
3, 20,
"Should return a list of all 3 registered users" "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);
} }