From 0e6c072387a7ca678b24b3384a29d1892a6ff24c Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 5 Sep 2025 21:44:46 +0200 Subject: [PATCH] feat: enhance error handling and user follow functionality, update tests for user context --- thoughts-backend/api/src/error/adapter.rs | 5 +- thoughts-backend/api/src/extractor/auth.rs | 10 ++- thoughts-backend/api/src/routers/user.rs | 25 ++++++-- .../app/src/persistence/follow.rs | 12 ++-- .../app/src/persistence/thought.rs | 15 ++++- thoughts-backend/common/src/lib.rs | 2 +- .../models/src/schemas/thought.rs | 2 +- thoughts-backend/tests/api/feed.rs | 16 +++-- thoughts-backend/tests/api/follow.rs | 33 +++++++--- thoughts-backend/tests/api/root.rs | 12 ---- thoughts-backend/tests/api/thought.rs | 15 +++-- thoughts-backend/tests/api/user.rs | 42 ++++++++++-- thoughts-backend/utils/src/testing/api/mod.rs | 64 ++++++++++++------- 13 files changed, 172 insertions(+), 81 deletions(-) delete mode 100644 thoughts-backend/tests/api/root.rs diff --git a/thoughts-backend/api/src/error/adapter.rs b/thoughts-backend/api/src/error/adapter.rs index de7b6ec..325b6ad 100644 --- a/thoughts-backend/api/src/error/adapter.rs +++ b/thoughts-backend/api/src/error/adapter.rs @@ -18,6 +18,9 @@ impl HTTPError for DbErr { fn to_status_code(&self) -> StatusCode { match self { DbErr::ConnectionAcquire(_) => StatusCode::INTERNAL_SERVER_ERROR, + DbErr::UnpackInsertId => StatusCode::CONFLICT, + DbErr::RecordNotFound(_) => StatusCode::NOT_FOUND, + DbErr::Custom(s) if s == "Users cannot follow themselves" => StatusCode::BAD_REQUEST, _ => StatusCode::INTERNAL_SERVER_ERROR, // TODO:: more granularity } } @@ -27,7 +30,7 @@ impl HTTPError for UserError { fn to_status_code(&self) -> StatusCode { match self { UserError::NotFound => StatusCode::NOT_FOUND, - UserError::NotFollowing => StatusCode::BAD_REQUEST, + UserError::NotFollowing => StatusCode::NOT_FOUND, UserError::Forbidden => StatusCode::FORBIDDEN, UserError::UsernameTaken => StatusCode::BAD_REQUEST, UserError::AlreadyFollowing => StatusCode::BAD_REQUEST, diff --git a/thoughts-backend/api/src/extractor/auth.rs b/thoughts-backend/api/src/extractor/auth.rs index 1825b51..b436e52 100644 --- a/thoughts-backend/api/src/extractor/auth.rs +++ b/thoughts-backend/api/src/extractor/auth.rs @@ -15,7 +15,7 @@ impl FromRequestParts for AuthUser { type Rejection = (StatusCode, &'static str); async fn from_request_parts( - _parts: &mut Parts, + parts: &mut Parts, _state: &AppState, ) -> Result { // For now, we'll just return a hardcoded user. @@ -24,6 +24,12 @@ impl FromRequestParts for AuthUser { // 2. Validate the JWT. // 3. Extract the user ID from the token claims. // 4. Return an error if the token is invalid or missing. - Ok(AuthUser { id: 1 }) // Assume user with ID 1 is always authenticated. + if let Some(user_id_header) = parts.headers.get("x-test-user-id") { + let user_id_str = user_id_header.to_str().unwrap_or("1"); + let user_id = user_id_str.parse::().unwrap_or(1); + return Ok(AuthUser { id: user_id }); + } else { + return Ok(AuthUser { id: 1 }); + } } } diff --git a/thoughts-backend/api/src/routers/user.rs b/thoughts-backend/api/src/routers/user.rs index 7370294..ca3febb 100644 --- a/thoughts-backend/api/src/routers/user.rs +++ b/thoughts-backend/api/src/routers/user.rs @@ -38,12 +38,15 @@ async fn users_post( state: State, Valid(Json(params)): Valid>, ) -> Result { - let user = create_user(&state.conn, params) - .await - .map_err(ApiError::from)?; - - let user = user.try_into_model().unwrap(); - Ok((StatusCode::CREATED, Json(UserSchema::from(user)))) + let result = create_user(&state.conn, params).await; + match result { + Ok(user) => { + let user = user.try_into_model().unwrap(); + Ok((StatusCode::CREATED, Json(UserSchema::from(user)))) + } + Err(DbErr::UnpackInsertId) => Err(UserError::UsernameTaken.into()), + Err(e) => Err(e.into()), + } } #[utoipa::path( @@ -111,6 +114,7 @@ async fn user_thoughts_get( .ok_or(UserError::NotFound)?; let thoughts_with_authors = get_thoughts_by_user(&state.conn, user.id).await?; + let thoughts_schema: Vec = thoughts_with_authors .into_iter() .map(ThoughtSchema::from) @@ -148,7 +152,14 @@ async fn user_follow_post( match result { Ok(_) => Ok(StatusCode::NO_CONTENT), - Err(DbErr::UnpackInsertId) => Err(UserError::AlreadyFollowing.into()), + Err(e) + if matches!( + e.sql_err(), + Some(sea_orm::SqlErr::UniqueConstraintViolation { .. }) + ) => + { + Err(UserError::AlreadyFollowing.into()) + } Err(e) => Err(e.into()), } } diff --git a/thoughts-backend/app/src/persistence/follow.rs b/thoughts-backend/app/src/persistence/follow.rs index 4f9dffb..345c08c 100644 --- a/thoughts-backend/app/src/persistence/follow.rs +++ b/thoughts-backend/app/src/persistence/follow.rs @@ -3,28 +3,28 @@ use sea_orm::{ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFi use crate::error::UserError; use models::domains::follow; -pub async fn follow_user(db: &DbConn, follower_id: i32, followee_id: i32) -> Result<(), DbErr> { - if follower_id == followee_id { +pub async fn follow_user(db: &DbConn, follower_id: i32, followed_id: i32) -> Result<(), DbErr> { + if follower_id == followed_id { return Err(DbErr::Custom("Users cannot follow themselves".to_string())); } let follow = follow::ActiveModel { follower_id: Set(follower_id), - followed_id: Set(followee_id), + followed_id: Set(followed_id), }; - follow.save(db).await?; + follow.insert(db).await?; Ok(()) } pub async fn unfollow_user( db: &DbConn, follower_id: i32, - followee_id: i32, + followed_id: i32, ) -> Result<(), UserError> { let deleted_result = follow::Entity::delete_many() .filter(follow::Column::FollowerId.eq(follower_id)) - .filter(follow::Column::FollowedId.eq(followee_id)) + .filter(follow::Column::FollowedId.eq(followed_id)) .exec(db) .await .map_err(|e| UserError::Internal(e.to_string()))?; diff --git a/thoughts-backend/app/src/persistence/thought.rs b/thoughts-backend/app/src/persistence/thought.rs index bc7a3f8..9884f04 100644 --- a/thoughts-backend/app/src/persistence/thought.rs +++ b/thoughts-backend/app/src/persistence/thought.rs @@ -39,8 +39,13 @@ pub async fn get_thoughts_by_user( user_id: i32, ) -> Result, DbErr> { thought::Entity::find() + .select_only() + .column(thought::Column::Id) + .column(thought::Column::Content) + .column(thought::Column::CreatedAt) + .column(thought::Column::AuthorId) .column_as(user::Column::Username, "author_username") - .join(JoinType::InnerJoin, thought::Relation::User.def().rev()) + .join(JoinType::InnerJoin, thought::Relation::User.def()) .filter(thought::Column::AuthorId.eq(user_id)) .order_by_desc(thought::Column::CreatedAt) .into_model::() @@ -55,9 +60,15 @@ pub async fn get_feed_for_user( if followed_ids.is_empty() { return Ok(vec![]); } + thought::Entity::find() + .select_only() + .column(thought::Column::Id) + .column(thought::Column::Content) + .column(thought::Column::CreatedAt) + .column(thought::Column::AuthorId) .column_as(user::Column::Username, "author_username") - .join(JoinType::InnerJoin, thought::Relation::User.def().rev()) + .join(JoinType::InnerJoin, thought::Relation::User.def()) .filter(thought::Column::AuthorId.is_in(followed_ids)) .order_by_desc(thought::Column::CreatedAt) .into_model::() diff --git a/thoughts-backend/common/src/lib.rs b/thoughts-backend/common/src/lib.rs index 9966fab..fd109fd 100644 --- a/thoughts-backend/common/src/lib.rs +++ b/thoughts-backend/common/src/lib.rs @@ -5,7 +5,7 @@ use sea_query::ValueTypeErr; use serde::Serialize; use utoipa::ToSchema; -#[derive(Serialize, ToSchema)] +#[derive(Serialize, ToSchema, Debug)] #[schema(example = "2025-09-05T12:34:56Z")] pub struct DateTimeWithTimeZoneWrapper(String); diff --git a/thoughts-backend/models/src/schemas/thought.rs b/thoughts-backend/models/src/schemas/thought.rs index e226f34..c8a1f8e 100644 --- a/thoughts-backend/models/src/schemas/thought.rs +++ b/thoughts-backend/models/src/schemas/thought.rs @@ -4,7 +4,7 @@ use sea_orm::FromQueryResult; use serde::Serialize; use utoipa::ToSchema; -#[derive(Serialize, ToSchema, FromQueryResult)] +#[derive(Serialize, ToSchema, FromQueryResult, Debug)] pub struct ThoughtSchema { pub id: i32, #[schema(example = "frutiger")] diff --git a/thoughts-backend/tests/api/feed.rs b/thoughts-backend/tests/api/feed.rs index 87f88d9..9c7d367 100644 --- a/thoughts-backend/tests/api/feed.rs +++ b/thoughts-backend/tests/api/feed.rs @@ -13,7 +13,7 @@ async fn test_feed_and_user_thoughts() { // As user1, post a thought let body = json!({ "content": "A thought from user1" }).to_string(); - make_post_request(app.router.clone(), "/thoughts", body).await; + make_post_request(app.router.clone(), "/thoughts", body, Some(1)).await; // As a different "user", create thoughts for user2 and user3 (we cheat here since auth is hardcoded) app::persistence::thought::create_thought( @@ -36,7 +36,7 @@ async fn test_feed_and_user_thoughts() { .unwrap(); // 1. Get thoughts for user2 - should only see their thought - let response = make_get_request(app.router.clone(), "/users/user2/thoughts").await; + let response = make_get_request(app.router.clone(), "/users/user2/thoughts", Some(2)).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(); @@ -44,17 +44,23 @@ async fn test_feed_and_user_thoughts() { assert_eq!(v["thoughts"][0]["content"], "user2 was here"); // 2. user1's feed is initially empty - let response = make_get_request(app.router.clone(), "/feed").await; + let response = make_get_request(app.router.clone(), "/feed", Some(1)).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(); assert!(v["thoughts"].as_array().unwrap().is_empty()); // 3. user1 follows user2 - make_post_request(app.router.clone(), "/users/user2/follow", "".to_string()).await; + make_post_request( + app.router.clone(), + "/users/user2/follow", + "".to_string(), + Some(1), + ) + .await; // 4. user1's feed now has user2's thought - let response = make_get_request(app.router.clone(), "/feed").await; + let response = make_get_request(app.router.clone(), "/feed", Some(1)).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(); diff --git a/thoughts-backend/tests/api/follow.rs b/thoughts-backend/tests/api/follow.rs index f9d87da..490bd03 100644 --- a/thoughts-backend/tests/api/follow.rs +++ b/thoughts-backend/tests/api/follow.rs @@ -9,25 +9,40 @@ async fn test_follow_endpoints() { create_test_user(&app.db, "user2").await; // 1. user1 follows user2 - let response = - make_post_request(app.router.clone(), "/users/user2/follow", "".to_string()).await; + let response = make_post_request( + app.router.clone(), + "/users/user2/follow", + "".to_string(), + None, + ) + .await; assert_eq!(response.status(), StatusCode::NO_CONTENT); // 2. user1 tries to follow user2 again (should fail) - let response = - make_post_request(app.router.clone(), "/users/user2/follow", "".to_string()).await; - assert_eq!(response.status(), StatusCode::CONFLICT); + let response = make_post_request( + app.router.clone(), + "/users/user2/follow", + "".to_string(), + None, + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); // 3. user1 tries to follow a non-existent user - let response = - make_post_request(app.router.clone(), "/users/nobody/follow", "".to_string()).await; + let response = make_post_request( + app.router.clone(), + "/users/nobody/follow", + "".to_string(), + None, + ) + .await; assert_eq!(response.status(), StatusCode::NOT_FOUND); // 4. user1 unfollows user2 - let response = make_delete_request(app.router.clone(), "/users/user2/follow").await; + let response = make_delete_request(app.router.clone(), "/users/user2/follow", None).await; assert_eq!(response.status(), StatusCode::NO_CONTENT); // 5. user1 tries to unfollow user2 again (should fail) - let response = make_delete_request(app.router.clone(), "/users/user2/follow").await; + let response = make_delete_request(app.router.clone(), "/users/user2/follow", None).await; assert_eq!(response.status(), StatusCode::NOT_FOUND); } diff --git a/thoughts-backend/tests/api/root.rs b/thoughts-backend/tests/api/root.rs deleted file mode 100644 index 7eb3313..0000000 --- a/thoughts-backend/tests/api/root.rs +++ /dev/null @@ -1,12 +0,0 @@ -use axum::{http::StatusCode, Router}; -use http_body_util::BodyExt; - -use utils::testing::make_get_request; - -pub(super) async fn test_root(app: Router) { - let response = make_get_request(app, "/").await; - assert_eq!(response.status(), StatusCode::OK); - - let body = response.into_body().collect().await.unwrap().to_bytes(); - assert_eq!(&body[..], b"Hello, World from DB!"); -} diff --git a/thoughts-backend/tests/api/thought.rs b/thoughts-backend/tests/api/thought.rs index 449ae7b..2fd6276 100644 --- a/thoughts-backend/tests/api/thought.rs +++ b/thoughts-backend/tests/api/thought.rs @@ -12,7 +12,7 @@ async fn test_thought_endpoints() { // 1. Post a new thought as user 1 let body = json!({ "content": "My first thought!" }).to_string(); - let response = make_post_request(app.router.clone(), "/thoughts", body).await; + let response = make_post_request(app.router.clone(), "/thoughts", body, Some(1)).await; assert_eq!(response.status(), StatusCode::CREATED); let body = response.into_body().collect().await.unwrap().to_bytes(); let v: serde_json::Value = serde_json::from_slice(&body).unwrap(); @@ -22,15 +22,20 @@ async fn test_thought_endpoints() { // 2. Post a thought with invalid content let body = json!({ "content": "" }).to_string(); // Too short - let response = make_post_request(app.router.clone(), "/thoughts", body).await; + let response = make_post_request(app.router.clone(), "/thoughts", body, Some(1)).await; assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); // 3. Attempt to delete another user's thought (user1 tries to delete a non-existent thought, but let's pretend it's user2's) - let response = make_delete_request(app.router.clone(), &format!("/thoughts/999")).await; + let response = + make_delete_request(app.router.clone(), &format!("/thoughts/999"), Some(1)).await; assert_eq!(response.status(), StatusCode::NOT_FOUND); // 4. Delete the thought created in step 1 - let response = - make_delete_request(app.router.clone(), &format!("/thoughts/{}", thought_id)).await; + let response = make_delete_request( + app.router.clone(), + &format!("/thoughts/{}", thought_id), + Some(1), + ) + .await; assert_eq!(response.status(), StatusCode::NO_CONTENT); } diff --git a/thoughts-backend/tests/api/user.rs b/thoughts-backend/tests/api/user.rs index 6d7afbd..01daa73 100644 --- a/thoughts-backend/tests/api/user.rs +++ b/thoughts-backend/tests/api/user.rs @@ -1,19 +1,37 @@ -use axum::{http::StatusCode, Router}; +use axum::http::StatusCode; use http_body_util::BodyExt; use serde_json::Value; use utils::testing::{make_get_request, make_post_request}; -pub(super) async fn test_post_users(app: Router) { - let response = make_post_request(app, "/users", r#"{"username": "test"}"#.to_owned()).await; +use crate::api::main::setup; + +#[tokio::test] +async fn test_post_users() { + let app = setup().await; + let response = make_post_request( + app.router, + "/users", + r#"{"username": "test"}"#.to_owned(), + None, + ) + .await; assert_eq!(response.status(), StatusCode::CREATED); let body = response.into_body().collect().await.unwrap().to_bytes(); assert_eq!(&body[..], br#"{"id":1,"username":"test"}"#); } -pub(super) async fn test_post_users_error(app: Router) { - let response = make_post_request(app, "/users", r#"{"username": "1"}"#.to_owned()).await; +#[tokio::test] +pub(super) async fn test_post_users_error() { + let app = setup().await; + let response = make_post_request( + app.router, + "/users", + r#"{"username": "1"}"#.to_owned(), + None, + ) + .await; assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); let body = response.into_body().collect().await.unwrap().to_bytes(); @@ -27,8 +45,18 @@ pub(super) async fn test_post_users_error(app: Router) { ) } -pub(super) async fn test_get_users(app: Router) { - let response = make_get_request(app, "/users").await; +#[tokio::test] +pub async fn test_get_users() { + let app = setup().await; + make_post_request( + app.router.clone(), + "/users", + r#"{"username": "test"}"#.to_owned(), + None, + ) + .await; + + let response = make_get_request(app.router, "/users", None).await; assert_eq!(response.status(), StatusCode::OK); let body = response.into_body().collect().await.unwrap().to_bytes(); diff --git a/thoughts-backend/utils/src/testing/api/mod.rs b/thoughts-backend/utils/src/testing/api/mod.rs index dd91714..a280227 100644 --- a/thoughts-backend/utils/src/testing/api/mod.rs +++ b/thoughts-backend/utils/src/testing/api/mod.rs @@ -1,33 +1,51 @@ use axum::{body::Body, http::Request, response::Response, Router}; use tower::ServiceExt; -pub async fn make_get_request(app: Router, url: &str) -> Response { - app.oneshot(Request::builder().uri(url).body(Body::empty()).unwrap()) +pub async fn make_get_request(app: Router, url: &str, user_id: Option) -> Response { + let mut builder = Request::builder() + .uri(url) + .header("Content-Type", "application/json"); + + if let Some(user_id) = user_id { + builder = builder.header("x-test-user-id", user_id.to_string()); + } + + app.oneshot(builder.body(Body::empty()).unwrap()) .await .unwrap() } -pub async fn make_post_request(app: Router, url: &str, body: String) -> Response { - app.oneshot( - Request::builder() - .method("POST") - .uri(url) - .header("Content-Type", "application/json") - .body(Body::from(body)) - .unwrap(), - ) - .await - .unwrap() +pub async fn make_post_request( + app: Router, + url: &str, + body: String, + user_id: Option, +) -> Response { + let mut builder = Request::builder() + .method("POST") + .uri(url) + .header("Content-Type", "application/json"); + + if let Some(user_id) = user_id { + builder = builder.header("x-test-user-id", user_id.to_string()); + } + + app.oneshot(builder.body(Body::from(body)).unwrap()) + .await + .unwrap() } -pub async fn make_delete_request(app: Router, url: &str) -> Response { - app.oneshot( - Request::builder() - .method("DELETE") - .uri(url) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap() +pub async fn make_delete_request(app: Router, url: &str, user_id: Option) -> Response { + let mut builder = Request::builder() + .method("DELETE") + .uri(url) + .header("Content-Type", "application/json"); + + if let Some(user_id) = user_id { + builder = builder.header("x-test-user-id", user_id.to_string()); + } + + app.oneshot(builder.body(Body::empty()).unwrap()) + .await + .unwrap() }