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;

View File

@@ -1,103 +1,158 @@
import Image from "next/image";
"use client";
import { useState, useEffect, FormEvent } from "react";
interface Thought {
id: number;
author_id: number;
content: string;
created_at: string;
}
export default function Home() {
return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
// State to store the list of thoughts for the feed
const [thoughts, setThoughts] = useState<Thought[]>([]);
// State for the content of the new thought being typed
const [newThoughtContent, setNewThoughtContent] = useState("");
// State to manage loading status
const [isLoading, setIsLoading] = useState(true);
// State to hold any potential errors during API calls
const [error, setError] = useState<string | null>(null);
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
// Function to fetch the feed from the backend API
const fetchFeed = async () => {
try {
setError(null);
const response = await fetch("http://localhost:8000/api/feed");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// The API returns { thoughts: [...] }, so we access the nested array
setThoughts(data.thoughts || []);
} catch (e: unknown) {
console.error("Failed to fetch feed:", e);
setError(
"Could not load the feed. The backend might be busy. Please try refreshing."
);
} finally {
setIsLoading(false);
}
};
// useEffect hook to fetch the feed when the component first loads
useEffect(() => {
fetchFeed();
}, []);
// Handler for submitting the new thought form
const handleSubmitThought = async (e: FormEvent) => {
e.preventDefault();
if (!newThoughtContent.trim()) return; // Prevent empty posts
try {
const response = await fetch("http://localhost:8000/api/thoughts", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
// We are hardcoding author_id: 1 as we don't have auth yet
body: JSON.stringify({ content: newThoughtContent, author_id: 1 }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Clear the input box
setNewThoughtContent("");
// Refresh the feed to show the new post
fetchFeed();
} catch (e: unknown) {
console.error("Failed to post thought:", e);
setError("Failed to post your thought. Please try again.");
}
};
return (
<div className="font-sans bg-gradient-to-br from-sky-200 via-teal-100 to-green-200 min-h-screen text-gray-800">
<div className="container mx-auto max-w-2xl p-4 sm:p-6">
{/* Header */}
<header className="text-center my-6">
<h1
className="text-5xl font-bold text-white"
style={{ textShadow: "2px 2px 4px rgba(0,0,0,0.2)" }}
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
Thoughts
</h1>
<p className="text-white/80 mt-2">
Your space on the decentralized web.
</p>
</header>
{/* New Thought Form */}
<div className="bg-white/70 backdrop-blur-lg rounded-xl shadow-lg p-5 mb-8">
<form onSubmit={handleSubmitThought}>
<textarea
value={newThoughtContent}
onChange={(e) => setNewThoughtContent(e.target.value)}
className="w-full h-24 p-3 rounded-lg border border-gray-300 focus:ring-2 focus:ring-sky-400 focus:outline-none resize-none transition-shadow"
placeholder="What's on your mind?"
maxLength={128}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
<div className="flex justify-between items-center mt-3">
<span className="text-sm text-gray-500">
{128 - newThoughtContent.length} characters remaining
</span>
<button
type="submit"
className="px-6 py-2 bg-sky-500 text-white font-semibold rounded-full shadow-md hover:bg-sky-600 active:scale-95 transition-all duration-150 ease-in-out disabled:bg-gray-400"
disabled={!newThoughtContent.trim()}
>
Post
</button>
</div>
</form>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
{/* Feed Section */}
<main>
{isLoading ? (
<p className="text-center text-gray-600">Loading feed...</p>
) : error ? (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded-lg text-center">
<p>{error}</p>
</div>
) : thoughts.length === 0 ? (
<p className="text-center text-gray-600">
The feed is empty. Follow some users to see their thoughts!
</p>
) : (
<div className="space-y-4">
{thoughts.map((thought) => (
<div
key={thought.id}
className="bg-white/80 backdrop-blur-lg rounded-xl shadow-lg p-4 transition-transform hover:scale-[1.02]"
>
<div className="flex items-center mb-2">
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-green-300 to-sky-400 flex items-center justify-center font-bold text-white mr-3">
{/* Placeholder for avatar */}
{thought.author_id}
</div>
<div>
<p className="font-bold">User {thought.author_id}</p>
<p className="text-xs text-gray-500">
{new Date(thought.created_at).toLocaleString()}
</p>
</div>
</div>
<p className="text-gray-800 break-words">{thought.content}</p>
</div>
))}
</div>
)}
</main>
</div>
</div>
);
}