feat: implement user follow/unfollow functionality and thought retrieval by user

- Added follow and unfollow endpoints for users.
- Implemented logic to retrieve thoughts by a specific user.
- Updated user error handling to include cases for already following and not following.
- Created persistence layer for follow relationships.
- Enhanced user and thought schemas to support new features.
- Added tests for follow/unfollow endpoints and thought retrieval.
- Updated frontend to display thoughts and allow posting new thoughts.
This commit is contained in:
2025-09-05 19:08:37 +02:00
parent 912259ef54
commit decf81e535
31 changed files with 872 additions and 155 deletions

View File

@@ -642,6 +642,7 @@ name = "common"
version = "0.1.0"
dependencies = [
"sea-orm",
"sea-query",
"serde",
"utoipa",
]

View File

@@ -17,6 +17,7 @@ members = ["api", "app", "doc", "models", "migration", "utils"]
axum = { version = "0.8.4", default-features = false }
tower = { version = "0.5.2", default-features = false }
sea-orm = { version = "1.1.12" }
sea-query = { version = "0.32.6" } # Added sea-query dependency
serde = { version = "1.0.219", features = ["derive"] }
serde_json = { version = "1.0.140" }
tracing = "0.1.41"

View File

@@ -27,6 +27,11 @@ impl HTTPError for UserError {
fn to_status_code(&self) -> StatusCode {
match self {
UserError::NotFound => StatusCode::NOT_FOUND,
UserError::NotFollowing => StatusCode::BAD_REQUEST,
UserError::Forbidden => StatusCode::FORBIDDEN,
UserError::UsernameTaken => StatusCode::BAD_REQUEST,
UserError::AlreadyFollowing => StatusCode::BAD_REQUEST,
UserError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}

View File

@@ -0,0 +1,29 @@
use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
};
use app::state::AppState;
// A dummy struct to represent an authenticated user.
// In a real app, this would contain user details from a validated JWT.
pub struct AuthUser {
pub id: i32,
}
impl FromRequestParts<AppState> for AuthUser {
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(
_parts: &mut Parts,
_state: &AppState,
) -> Result<Self, Self::Rejection> {
// For now, we'll just return a hardcoded user.
// In a real implementation, you would:
// 1. Extract the `Authorization: Bearer <token>` header.
// 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.
}
}

View File

@@ -1,5 +1,7 @@
mod auth;
mod json;
mod valid;
pub use auth::AuthUser;
pub use json::Json;
pub use valid::Valid;

View File

@@ -0,0 +1,39 @@
use axum::{extract::State, response::IntoResponse, routing::get, Json, Router};
use app::{
persistence::{follow::get_followed_ids, thought::get_feed_for_user},
state::AppState,
};
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
use crate::{error::ApiError, extractor::AuthUser};
#[utoipa::path(
get,
path = "/feed",
responses(
(status = 200, description = "Authenticated user's feed", body = ThoughtListSchema)
),
security(
("api_key" = []),
("bearer_auth" = [])
)
)]
async fn feed_get(
State(state): State<AppState>,
auth_user: AuthUser,
) -> Result<impl IntoResponse, ApiError> {
let followed_ids = get_followed_ids(&state.conn, auth_user.id).await?;
let thoughts_with_authors = get_feed_for_user(&state.conn, followed_ids).await?;
let thoughts_schema: Vec<ThoughtSchema> = thoughts_with_authors
.into_iter()
.map(ThoughtSchema::from)
.collect();
Ok(Json(ThoughtListSchema::from(thoughts_schema)))
}
pub fn create_feed_router() -> Router<AppState> {
Router::new().route("/", get(feed_get))
}

View File

@@ -1,15 +1,21 @@
use axum::Router;
pub mod feed;
pub mod root;
pub mod thought;
pub mod user;
use app::state::AppState;
use root::create_root_router;
use user::create_user_router;
use crate::routers::{feed::create_feed_router, thought::create_thought_router};
pub fn create_router(state: AppState) -> Router {
Router::new()
.merge(create_root_router())
.nest("/users", create_user_router())
.nest("/thoughts", create_thought_router())
.nest("/feed", create_feed_router())
.with_state(state)
}

View File

@@ -0,0 +1,87 @@
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::{delete, post},
Router,
};
use app::{
error::UserError,
persistence::thought::{create_thought, delete_thought, get_thought},
state::AppState,
};
use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema};
use crate::{
error::ApiError,
extractor::{AuthUser, Json, Valid},
models::{ApiErrorResponse, ParamsErrorResponse},
};
#[utoipa::path(
post,
path = "/thoughts",
request_body = CreateThoughtParams,
responses(
(status = 201, description = "Thought created", body = ThoughtSchema),
(status = 400, description = "Bad request", body = ApiErrorResponse),
(status = 422, description = "Validation error", body = ParamsErrorResponse)
),
security(
("api_key" = []),
("bearer_auth" = [])
)
)]
async fn thoughts_post(
State(state): State<AppState>,
auth_user: AuthUser,
Valid(Json(params)): Valid<Json<CreateThoughtParams>>,
) -> Result<impl IntoResponse, ApiError> {
let thought = create_thought(&state.conn, auth_user.id, params).await?;
let author = app::persistence::user::get_user(&state.conn, auth_user.id)
.await?
.ok_or(UserError::NotFound)?; // Should not happen if auth is valid
let schema = ThoughtSchema::from_models(&thought, &author);
Ok((StatusCode::CREATED, Json(schema)))
}
#[utoipa::path(
delete,
path = "/thoughts/{id}",
params(
("id" = i32, Path, description = "Thought ID")
),
responses(
(status = 204, description = "Thought deleted"),
(status = 403, description = "Forbidden", body = ApiErrorResponse),
(status = 404, description = "Not Found", body = ApiErrorResponse)
),
security(
("api_key" = []),
("bearer_auth" = [])
)
)]
async fn thoughts_delete(
State(state): State<AppState>,
auth_user: AuthUser,
Path(id): Path<i32>,
) -> Result<impl IntoResponse, ApiError> {
let thought = get_thought(&state.conn, id)
.await?
.ok_or(UserError::NotFound)?;
if thought.author_id != auth_user.id {
return Err(UserError::Forbidden.into());
}
delete_thought(&state.conn, id).await?;
Ok(StatusCode::NO_CONTENT)
}
pub fn create_thought_router() -> Router<AppState> {
Router::new()
.route("/", post(thoughts_post))
.route("/{id}", delete(thoughts_delete))
}

View File

@@ -5,18 +5,22 @@ use axum::{
routing::{get, post},
Router,
};
use sea_orm::TryIntoModel;
use sea_orm::{DbErr, TryIntoModel};
use app::error::UserError;
use app::persistence::user::{create_user, get_user, search_users};
use app::persistence::{
follow,
thought::get_thoughts_by_user,
user::{create_user, get_user, search_users},
};
use app::state::AppState;
use models::params::user::CreateUserParams;
use models::queries::user::UserQuery;
use app::{error::UserError, persistence::user::get_user_by_username};
use models::schemas::user::{UserListSchema, UserSchema};
use models::{params::user::CreateUserParams, schemas::thought::ThoughtListSchema};
use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
use crate::error::ApiError;
use crate::extractor::{Json, Valid};
use crate::models::{ApiErrorResponse, ParamsErrorResponse};
use crate::{error::ApiError, extractor::AuthUser};
#[utoipa::path(
post,
@@ -25,6 +29,7 @@ use crate::models::{ApiErrorResponse, ParamsErrorResponse};
responses(
(status = 201, description = "User created", body = UserSchema),
(status = 400, description = "Bad request", body = ApiErrorResponse),
(status = 409, description = "Username already exists", body = ApiErrorResponse),
(status = 422, description = "Validation error", body = ParamsErrorResponse),
(status = 500, description = "Internal server error", body = ApiErrorResponse),
)
@@ -86,8 +91,104 @@ async fn users_id_get(
.ok_or_else(|| UserError::NotFound.into())
}
#[utoipa::path(
get,
path = "/{username}/thoughts",
params(
("username" = String, Path, description = "Username")
),
responses(
(status = 200, description = "List of user's thoughts", body = ThoughtListSchema),
(status = 404, description = "User not found", body = ApiErrorResponse)
)
)]
async fn user_thoughts_get(
State(state): State<AppState>,
Path(username): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
let user = get_user_by_username(&state.conn, &username)
.await?
.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)
.collect();
Ok(Json(ThoughtListSchema::from(thoughts_schema)))
}
#[utoipa::path(
post,
path = "/{username}/follow",
params(
("username" = String, Path, description = "Username to follow")
),
responses(
(status = 204, description = "User followed successfully"),
(status = 404, description = "User not found", body = ApiErrorResponse),
(status = 409, description = "Already following", body = ApiErrorResponse)
),
security(
("api_key" = []),
("bearer_auth" = [])
)
)]
async fn user_follow_post(
State(state): State<AppState>,
auth_user: AuthUser,
Path(username): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
let user_to_follow = get_user_by_username(&state.conn, &username)
.await?
.ok_or(UserError::NotFound)?;
let result = follow::follow_user(&state.conn, auth_user.id, user_to_follow.id).await;
match result {
Ok(_) => Ok(StatusCode::NO_CONTENT),
Err(DbErr::UnpackInsertId) => Err(UserError::AlreadyFollowing.into()),
Err(e) => Err(e.into()),
}
}
#[utoipa::path(
delete,
path = "/{username}/follow",
params(
("username" = String, Path, description = "Username to unfollow")
),
responses(
(status = 204, description = "User unfollowed successfully"),
(status = 404, description = "User not found or not being followed", body = ApiErrorResponse)
),
security(
("api_key" = []),
("bearer_auth" = [])
)
)]
async fn user_follow_delete(
State(state): State<AppState>,
auth_user: AuthUser,
Path(username): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
let user_to_unfollow = get_user_by_username(&state.conn, &username)
.await?
.ok_or(UserError::NotFound)?;
follow::unfollow_user(&state.conn, auth_user.id, user_to_unfollow.id).await?;
Ok(StatusCode::NO_CONTENT)
}
pub fn create_user_router() -> Router<AppState> {
Router::new()
.route("/", post(users_post).get(users_get))
.route("/{id}", get(users_id_get))
.route("/{username}/thoughts", get(user_thoughts_get))
.route(
"/{username}/follow",
post(user_follow_post).delete(user_follow_delete),
)
}

View File

@@ -1,12 +1,22 @@
#[derive(Debug)]
pub enum UserError {
NotFound,
NotFollowing,
Forbidden,
UsernameTaken,
AlreadyFollowing,
Internal(String),
}
impl std::fmt::Display for UserError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UserError::NotFound => write!(f, "User not found"),
UserError::NotFollowing => write!(f, "You are not following this user"),
UserError::Forbidden => write!(f, "You do not have permission to perform this action"),
UserError::UsernameTaken => write!(f, "Username is already taken"),
UserError::AlreadyFollowing => write!(f, "You are already following this user"),
UserError::Internal(msg) => write!(f, "Internal server error: {}", msg),
}
}
}

View File

@@ -0,0 +1,46 @@
use sea_orm::{ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set};
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 {
return Err(DbErr::Custom("Users cannot follow themselves".to_string()));
}
let follow = follow::ActiveModel {
follower_id: Set(follower_id),
followed_id: Set(followee_id),
};
follow.save(db).await?;
Ok(())
}
pub async fn unfollow_user(
db: &DbConn,
follower_id: i32,
followee_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))
.exec(db)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
if deleted_result.rows_affected == 0 {
return Err(UserError::NotFollowing);
}
Ok(())
}
pub async fn get_followed_ids(db: &DbConn, user_id: i32) -> Result<Vec<i32>, DbErr> {
let followed_users = follow::Entity::find()
.filter(follow::Column::FollowerId.eq(user_id))
.all(db)
.await?;
Ok(followed_users.into_iter().map(|f| f.followed_id).collect())
}

View File

@@ -1 +1,3 @@
pub mod follow;
pub mod thought;
pub mod user;

View File

@@ -0,0 +1,67 @@
use sea_orm::{
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, QueryFilter, QueryOrder,
QuerySelect, RelationTrait, Set,
};
use models::{
domains::{thought, user},
params::thought::CreateThoughtParams,
schemas::thought::ThoughtWithAuthor,
};
use crate::error::UserError;
pub async fn create_thought(
db: &DbConn,
author_id: i32,
params: CreateThoughtParams,
) -> Result<thought::Model, DbErr> {
thought::ActiveModel {
author_id: Set(author_id),
content: Set(params.content),
..Default::default()
}
.insert(db)
.await
}
pub async fn get_thought(db: &DbConn, thought_id: i32) -> Result<Option<thought::Model>, DbErr> {
thought::Entity::find_by_id(thought_id).one(db).await
}
pub async fn delete_thought(db: &DbConn, thought_id: i32) -> Result<(), DbErr> {
thought::Entity::delete_by_id(thought_id).exec(db).await?;
Ok(())
}
pub async fn get_thoughts_by_user(
db: &DbConn,
user_id: i32,
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
thought::Entity::find()
.column_as(user::Column::Username, "author_username")
.join(JoinType::InnerJoin, thought::Relation::User.def().rev())
.filter(thought::Column::AuthorId.eq(user_id))
.order_by_desc(thought::Column::CreatedAt)
.into_model::<ThoughtWithAuthor>()
.all(db)
.await
}
pub async fn get_feed_for_user(
db: &DbConn,
followed_ids: Vec<i32>,
) -> Result<Vec<ThoughtWithAuthor>, UserError> {
if followed_ids.is_empty() {
return Ok(vec![]);
}
thought::Entity::find()
.column_as(user::Column::Username, "author_username")
.join(JoinType::InnerJoin, thought::Relation::User.def().rev())
.filter(thought::Column::AuthorId.is_in(followed_ids))
.order_by_desc(thought::Column::CreatedAt)
.into_model::<ThoughtWithAuthor>()
.all(db)
.await
.map_err(|e| UserError::Internal(e.to_string()))
}

View File

@@ -26,3 +26,13 @@ pub async fn search_users(db: &DbConn, query: UserQuery) -> Result<Vec<user::Mod
pub async fn get_user(db: &DbConn, id: i32) -> Result<Option<user::Model>, DbErr> {
user::Entity::find_by_id(id).one(db).await
}
pub async fn get_user_by_username(
db: &DbConn,
username: &str,
) -> Result<Option<user::Model>, DbErr> {
user::Entity::find()
.filter(user::Column::Username.eq(username))
.one(db)
.await
}

View File

@@ -7,3 +7,4 @@ edition = "2021"
serde = { workspace = true }
utoipa = { workspace = true }
sea-orm = { workspace = true }
sea-query = { workspace = true }

View File

@@ -1,10 +1,12 @@
use sea_orm::prelude::DateTimeWithTimeZone;
use sea_orm::TryGetError;
use sea_orm::{sea_query::ColumnType, sea_query::Value, sea_query::ValueType, TryGetable};
use sea_query::ValueTypeErr;
use serde::Serialize;
use utoipa::ToSchema;
// Wrapper type for DateTimeWithTimeZone
#[derive(Serialize, ToSchema)]
#[schema(example = "2025-09-05T12:34:56Z")] // Example for OpenAPI
#[schema(example = "2025-09-05T12:34:56Z")]
pub struct DateTimeWithTimeZoneWrapper(String);
impl From<DateTimeWithTimeZone> for DateTimeWithTimeZoneWrapper {
@@ -12,3 +14,40 @@ impl From<DateTimeWithTimeZone> for DateTimeWithTimeZoneWrapper {
DateTimeWithTimeZoneWrapper(value.to_rfc3339())
}
}
impl TryGetable for DateTimeWithTimeZoneWrapper {
fn try_get_by<I: sea_orm::ColIdx>(
res: &sea_orm::QueryResult,
index: I,
) -> Result<Self, TryGetError> {
let value: String = res.try_get_by(index)?;
Ok(DateTimeWithTimeZoneWrapper(value))
}
fn try_get(res: &sea_orm::QueryResult, pre: &str, col: &str) -> Result<Self, TryGetError> {
let value: String = res.try_get(pre, col)?;
Ok(DateTimeWithTimeZoneWrapper(value))
}
}
impl ValueType for DateTimeWithTimeZoneWrapper {
fn try_from(v: Value) -> Result<Self, ValueTypeErr> {
if let Value::String(Some(string)) = v {
Ok(DateTimeWithTimeZoneWrapper(*string))
} else {
Err(ValueTypeErr)
}
}
fn array_type() -> sea_query::ArrayType {
sea_query::ArrayType::String
}
fn column_type() -> ColumnType {
ColumnType::String(sea_query::StringLen::Max)
}
fn type_name() -> String {
"DateTimeWithTimeZoneWrapper".to_string()
}
}

View File

@@ -0,0 +1,10 @@
use api::{models::ApiErrorResponse, routers::feed::*};
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(feed_get),
components(schemas(ThoughtSchema, ThoughtListSchema, ApiErrorResponse))
)]
pub(super) struct FeedApi;

View File

@@ -3,7 +3,9 @@ use utoipa::OpenApi;
use utoipa_scalar::{Scalar, Servable as ScalarServable};
use utoipa_swagger_ui::SwaggerUi;
mod feed;
mod root;
mod thought;
mod user;
#[derive(OpenApi)]
@@ -11,11 +13,15 @@ mod user;
nest(
(path = "/", api = root::RootApi),
(path = "/users", api = user::UserApi),
(path = "/thoughts", api = thought::ThoughtApi),
(path = "/feed", api = feed::FeedApi),
),
tags(
(name = "root", description = "Root API"),
(name = "user", description = "User API"),
)
(name = "user", description = "User & Social API"),
(name = "thought", description = "Thoughts API"),
(name = "feed", description = "Feed API"),
),
)]
struct _ApiDoc;

View File

@@ -0,0 +1,18 @@
use api::{
models::{ApiErrorResponse, ParamsErrorResponse},
routers::thought::*,
};
use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(thoughts_post, thoughts_delete),
components(schemas(
CreateThoughtParams,
ThoughtSchema,
ApiErrorResponse,
ParamsErrorResponse
))
)]
pub(super) struct ThoughtApi;

View File

@@ -1,18 +1,29 @@
use utoipa::OpenApi;
use models::params::user::CreateUserParams;
use models::schemas::user::{UserListSchema, UserSchema};
use api::models::{ApiErrorResponse, ParamsErrorResponse};
use api::routers::user::*;
use models::params::user::CreateUserParams;
use models::schemas::{
thought::{ThoughtListSchema, ThoughtSchema},
user::{UserListSchema, UserSchema},
};
#[derive(OpenApi)]
#[openapi(
paths(users_get, users_id_get, users_post),
paths(
users_get,
users_id_get,
users_post,
user_thoughts_get,
user_follow_post,
user_follow_delete
),
components(schemas(
CreateUserParams,
UserListSchema,
UserSchema,
ThoughtSchema,
ThoughtListSchema,
ApiErrorResponse,
ParamsErrorResponse,
))

View File

@@ -4,8 +4,10 @@ use validator::Validate;
#[derive(Deserialize, Validate, ToSchema)]
pub struct CreateThoughtParams {
pub author_id: i32,
#[validate(length(min = 1, max = 128))]
#[validate(length(
min = 1,
max = 128,
message = "Content must be between 1 and 128 characters"
))]
pub content: String,
}

View File

@@ -1,23 +1,26 @@
use crate::domains::thought;
use crate::domains::{thought, user};
use common::DateTimeWithTimeZoneWrapper;
use sea_orm::FromQueryResult;
use serde::Serialize;
use utoipa::ToSchema;
#[derive(Serialize, ToSchema)]
#[derive(Serialize, ToSchema, FromQueryResult)]
pub struct ThoughtSchema {
pub id: i32,
pub author_id: i32,
#[schema(example = "frutiger")]
pub author_username: String,
#[schema(example = "This is my first thought! #welcome")]
pub content: String,
pub created_at: DateTimeWithTimeZoneWrapper,
}
impl From<thought::Model> for ThoughtSchema {
fn from(model: thought::Model) -> Self {
impl ThoughtSchema {
pub fn from_models(thought: &thought::Model, author: &user::Model) -> Self {
Self {
id: model.id,
author_id: model.author_id,
content: model.content,
created_at: model.created_at.into(),
id: thought.id,
author_username: author.username.clone(),
content: thought.content.clone(),
created_at: thought.created_at.into(),
}
}
}
@@ -27,10 +30,28 @@ pub struct ThoughtListSchema {
pub thoughts: Vec<ThoughtSchema>,
}
impl From<Vec<thought::Model>> for ThoughtListSchema {
fn from(models: Vec<thought::Model>) -> Self {
impl From<Vec<ThoughtSchema>> for ThoughtListSchema {
fn from(thoughts: Vec<ThoughtSchema>) -> Self {
Self { thoughts }
}
}
#[derive(Debug, FromQueryResult)]
pub struct ThoughtWithAuthor {
pub id: i32,
pub content: String,
pub created_at: sea_orm::prelude::DateTimeWithTimeZone,
pub author_id: i32,
pub author_username: String,
}
impl From<ThoughtWithAuthor> for ThoughtSchema {
fn from(model: ThoughtWithAuthor) -> Self {
Self {
thoughts: models.into_iter().map(ThoughtSchema::from).collect(),
id: model.id,
author_username: model.author_username,
content: model.content,
created_at: model.created_at.into(),
}
}
}

View File

@@ -5,14 +5,14 @@ use crate::domains::user;
#[derive(Serialize, ToSchema)]
pub struct UserSchema {
pub id: u32,
pub id: i32,
pub username: String,
}
impl From<user::Model> for UserSchema {
fn from(user: user::Model) -> Self {
Self {
id: user.id as u32,
id: user.id,
username: user.username,
}
}

View File

@@ -0,0 +1,63 @@
use super::main::{create_test_user, setup};
use axum::http::StatusCode;
use http_body_util::BodyExt;
use serde_json::json;
use utils::testing::{make_get_request, make_post_request};
#[tokio::test]
async fn test_feed_and_user_thoughts() {
let app = setup().await;
create_test_user(&app.db, "user1").await; // AuthUser is ID 1
create_test_user(&app.db, "user2").await;
create_test_user(&app.db, "user3").await;
// As user1, post a thought
let body = json!({ "content": "A thought from user1" }).to_string();
make_post_request(app.router.clone(), "/thoughts", body).await;
// As a different "user", create thoughts for user2 and user3 (we cheat here since auth is hardcoded)
app::persistence::thought::create_thought(
&app.db,
2,
models::params::thought::CreateThoughtParams {
content: "user2 was here".to_string(),
},
)
.await
.unwrap();
app::persistence::thought::create_thought(
&app.db,
3,
models::params::thought::CreateThoughtParams {
content: "user3 checking in".to_string(),
},
)
.await
.unwrap();
// 1. Get thoughts for user2 - should only see their thought
let response = make_get_request(app.router.clone(), "/users/user2/thoughts").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_eq!(v["thoughts"].as_array().unwrap().len(), 1);
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;
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;
// 4. user1's feed now has user2's thought
let response = make_get_request(app.router.clone(), "/feed").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_eq!(v["thoughts"].as_array().unwrap().len(), 1);
assert_eq!(v["thoughts"][0]["author_username"], "user2");
}

View File

@@ -0,0 +1,33 @@
use super::main::{create_test_user, setup};
use axum::http::StatusCode;
use utils::testing::{make_delete_request, make_post_request};
#[tokio::test]
async fn test_follow_endpoints() {
let app = setup().await;
create_test_user(&app.db, "user1").await; // AuthUser is ID 1
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;
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);
// 3. user1 tries to follow a non-existent user
let response =
make_post_request(app.router.clone(), "/users/nobody/follow", "".to_string()).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
// 4. user1 unfollows user2
let response = make_delete_request(app.router.clone(), "/users/user2/follow").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;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}

View File

@@ -0,0 +1,29 @@
use api::setup_router;
use app::persistence::user::create_user;
use axum::Router;
use models::params::user::CreateUserParams;
use sea_orm::DatabaseConnection;
use utils::testing::setup_test_db;
pub struct TestApp {
pub router: Router,
pub db: DatabaseConnection,
}
pub async fn setup() -> TestApp {
let db = setup_test_db("sqlite::memory:")
.await
.expect("Failed to set up test db");
let router = setup_router(db.clone());
TestApp { router, db }
}
// Helper to create users for tests
pub async fn create_test_user(db: &DatabaseConnection, username: &str) {
let params = CreateUserParams {
username: username.to_string(),
};
create_user(db, params)
.await
.expect("Failed to create test user");
}

View File

@@ -1,30 +1,5 @@
use api::setup_router;
use utils::testing::setup_test_db;
mod root;
mod feed;
mod follow;
mod main;
mod thought;
mod user;
use root::*;
use user::*;
#[tokio::test]
async fn root_main() {
let db = setup_test_db("sqlite::root?mode=memory&cache=shared")
.await
.expect("Set up db failed!");
let app = setup_router(db);
test_root(app).await;
}
#[tokio::test]
async fn user_main() {
let db = setup_test_db("sqlite::user?mode=memory&cache=shared")
.await
.expect("Set up db failed!");
let app = setup_router(db);
test_post_users(app.clone()).await;
test_post_users_error(app.clone()).await;
test_get_users(app).await;
}

View File

@@ -0,0 +1,36 @@
use super::main::{create_test_user, setup};
use axum::http::StatusCode;
use http_body_util::BodyExt;
use serde_json::json;
use utils::testing::{make_delete_request, make_post_request};
#[tokio::test]
async fn test_thought_endpoints() {
let app = setup().await;
create_test_user(&app.db, "user1").await; // AuthUser is ID 1
create_test_user(&app.db, "user2").await; // Other user is ID 2
// 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;
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();
assert_eq!(v["content"], "My first thought!");
assert_eq!(v["author_username"], "user1");
let thought_id = v["id"].as_i64().unwrap();
// 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;
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;
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;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
}

View File

@@ -19,3 +19,15 @@ pub async fn make_post_request(app: Router, url: &str, body: String) -> Response
.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()
}

View File

@@ -1,5 +1,5 @@
mod api;
mod db;
pub use api::{make_get_request, make_post_request};
pub use api::{make_delete_request, make_get_request, make_post_request};
pub use db::setup_test_db;