feat: enhance error handling and user follow functionality, update tests for user context

This commit is contained in:
2025-09-05 21:44:46 +02:00
parent decf81e535
commit 0e6c072387
13 changed files with 172 additions and 81 deletions

View File

@@ -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,

View File

@@ -15,7 +15,7 @@ impl FromRequestParts<AppState> for AuthUser {
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(
_parts: &mut Parts,
parts: &mut Parts,
_state: &AppState,
) -> Result<Self, Self::Rejection> {
// For now, we'll just return a hardcoded user.
@@ -24,6 +24,12 @@ impl FromRequestParts<AppState> 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::<i32>().unwrap_or(1);
return Ok(AuthUser { id: user_id });
} else {
return Ok(AuthUser { id: 1 });
}
}
}

View File

@@ -38,12 +38,15 @@ async fn users_post(
state: State<AppState>,
Valid(Json(params)): Valid<Json<CreateUserParams>>,
) -> Result<impl IntoResponse, ApiError> {
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<ThoughtSchema> = 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()),
}
}

View File

@@ -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()))?;

View File

@@ -39,8 +39,13 @@ pub async fn get_thoughts_by_user(
user_id: i32,
) -> Result<Vec<ThoughtWithAuthor>, 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::<ThoughtWithAuthor>()
@@ -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::<ThoughtWithAuthor>()

View File

@@ -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);

View File

@@ -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")]

View File

@@ -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();

View File

@@ -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);
}

View File

@@ -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!");
}

View File

@@ -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);
}

View File

@@ -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();

View File

@@ -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<i32>) -> 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<i32>,
) -> 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<i32>) -> 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()
}