feat: enhance error handling and user follow functionality, update tests for user context
This commit is contained in:
@@ -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,
|
||||
|
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -38,13 +38,16 @@ 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 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(
|
||||
get,
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
@@ -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()))?;
|
||||
|
@@ -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>()
|
||||
|
@@ -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);
|
||||
|
||||
|
@@ -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")]
|
||||
|
@@ -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();
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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!");
|
||||
}
|
@@ -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);
|
||||
}
|
||||
|
@@ -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();
|
||||
|
@@ -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()
|
||||
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")
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.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()
|
||||
pub async fn make_delete_request(app: Router, url: &str, user_id: Option<i32>) -> Response {
|
||||
let mut builder = Request::builder()
|
||||
.method("DELETE")
|
||||
.uri(url)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.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()
|
||||
}
|
||||
|
Reference in New Issue
Block a user