feat: enhance user registration and follow functionality, add popular tags endpoint, and update tests
This commit is contained in:
1
thoughts-backend/Cargo.lock
generated
1
thoughts-backend/Cargo.lock
generated
@@ -357,6 +357,7 @@ name = "app"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
|
"chrono",
|
||||||
"models",
|
"models",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
use axum::{extract::State, response::IntoResponse, routing::get, Json, Router};
|
use axum::{extract::State, response::IntoResponse, routing::get, Json, Router};
|
||||||
|
|
||||||
use app::{
|
use app::{
|
||||||
persistence::{follow::get_followed_ids, thought::get_feed_for_user},
|
persistence::{follow::get_following_ids, thought::get_feed_for_user},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
|
use models::schemas::thought::{ThoughtListSchema, ThoughtSchema};
|
||||||
@@ -23,8 +23,8 @@ async fn feed_get(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let followed_ids = get_followed_ids(&state.conn, auth_user.id).await?;
|
let following_ids = get_following_ids(&state.conn, auth_user.id).await?;
|
||||||
let mut thoughts_with_authors = get_feed_for_user(&state.conn, followed_ids).await?;
|
let mut thoughts_with_authors = get_feed_for_user(&state.conn, following_ids).await?;
|
||||||
|
|
||||||
let own_thoughts = get_feed_for_user(&state.conn, vec![auth_user.id]).await?;
|
let own_thoughts = get_feed_for_user(&state.conn, vec![auth_user.id]).await?;
|
||||||
thoughts_with_authors.extend(own_thoughts);
|
thoughts_with_authors.extend(own_thoughts);
|
||||||
|
@@ -1,5 +1,8 @@
|
|||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
use app::{persistence::thought::get_thoughts_by_tag_name, state::AppState};
|
use app::{
|
||||||
|
persistence::{tag, thought::get_thoughts_by_tag_name},
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
@@ -27,6 +30,20 @@ async fn get_thoughts_by_tag(
|
|||||||
Ok(Json(ThoughtListSchema::from(thoughts_schema)))
|
Ok(Json(ThoughtListSchema::from(thoughts_schema)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_tag_router() -> Router<AppState> {
|
#[utoipa::path(
|
||||||
Router::new().route("/{tag_name}", get(get_thoughts_by_tag))
|
get,
|
||||||
|
path = "/popular",
|
||||||
|
responses((status = 200, description = "List of popular tags", body = Vec<String>))
|
||||||
|
)]
|
||||||
|
async fn get_popular_tags(State(state): State<AppState>) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let tags = tag::get_popular_tags(&state.conn).await;
|
||||||
|
println!("Fetched popular tags: {:?}", tags);
|
||||||
|
let tags = tags?;
|
||||||
|
Ok(Json(tags))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_tag_router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/{tag_name}", get(get_thoughts_by_tag))
|
||||||
|
.route("/popular", get(get_popular_tags))
|
||||||
}
|
}
|
||||||
|
@@ -248,7 +248,12 @@ async fn get_user_by_param(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match get_user_by_username(&state.conn, &username).await {
|
match get_user_by_username(&state.conn, &username).await {
|
||||||
Ok(Some(user)) => Json(UserSchema::from(user)).into_response(),
|
Ok(Some(user)) => {
|
||||||
|
let top_friends = app::persistence::user::get_top_friends(&state.conn, user.id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
Json(UserSchema::from((user, top_friends))).into_response()
|
||||||
|
}
|
||||||
Ok(None) => ApiError::from(UserError::NotFound).into_response(),
|
Ok(None) => ApiError::from(UserError::NotFound).into_response(),
|
||||||
Err(e) => ApiError::from(e).into_response(),
|
Err(e) => ApiError::from(e).into_response(),
|
||||||
}
|
}
|
||||||
@@ -332,7 +337,9 @@ async fn get_me(
|
|||||||
let user = get_user(&state.conn, auth_user.id)
|
let user = get_user(&state.conn, auth_user.id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(UserError::NotFound)?;
|
.ok_or(UserError::NotFound)?;
|
||||||
Ok(axum::Json(UserSchema::from(user)))
|
let top_friends = app::persistence::user::get_top_friends(&state.conn, auth_user.id).await?;
|
||||||
|
|
||||||
|
Ok(axum::Json(UserSchema::from((user, top_friends))))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
|
@@ -14,3 +14,4 @@ models = { path = "../models" }
|
|||||||
validator = "0.20"
|
validator = "0.20"
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
sea-orm = { version = "1.1.12" }
|
sea-orm = { version = "1.1.12" }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
@@ -13,7 +13,6 @@ fn hash_password(password: &str) -> Result<String, BcryptError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn register_user(db: &DbConn, params: RegisterParams) -> Result<user::Model, UserError> {
|
pub async fn register_user(db: &DbConn, params: RegisterParams) -> Result<user::Model, UserError> {
|
||||||
// Validate the parameters
|
|
||||||
params
|
params
|
||||||
.validate()
|
.validate()
|
||||||
.map_err(|e| UserError::Validation(e.to_string()))?;
|
.map_err(|e| UserError::Validation(e.to_string()))?;
|
||||||
@@ -22,8 +21,10 @@ pub async fn register_user(db: &DbConn, params: RegisterParams) -> Result<user::
|
|||||||
hash_password(¶ms.password).map_err(|e| UserError::Internal(e.to_string()))?;
|
hash_password(¶ms.password).map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
let new_user = user::ActiveModel {
|
let new_user = user::ActiveModel {
|
||||||
username: Set(params.username),
|
username: Set(params.username.clone()),
|
||||||
password_hash: Set(Some(hashed_password)),
|
password_hash: Set(Some(hashed_password)),
|
||||||
|
email: Set(Some(params.email)),
|
||||||
|
display_name: Set(Some(params.username)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -7,7 +7,7 @@ use models::domains::follow;
|
|||||||
|
|
||||||
pub async fn add_follower(
|
pub async fn add_follower(
|
||||||
db: &DbConn,
|
db: &DbConn,
|
||||||
followed_id: Uuid,
|
following_id: Uuid,
|
||||||
follower_actor_id: &str,
|
follower_actor_id: &str,
|
||||||
) -> Result<(), UserError> {
|
) -> Result<(), UserError> {
|
||||||
let follower_username = follower_actor_id
|
let follower_username = follower_actor_id
|
||||||
@@ -20,21 +20,21 @@ pub async fn add_follower(
|
|||||||
.map_err(|e| UserError::Internal(e.to_string()))?
|
.map_err(|e| UserError::Internal(e.to_string()))?
|
||||||
.ok_or(UserError::NotFound)?;
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
follow_user(db, follower.id, followed_id)
|
follow_user(db, follower.id, following_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| UserError::Internal(e.to_string()))?;
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn follow_user(db: &DbConn, follower_id: Uuid, followed_id: Uuid) -> Result<(), DbErr> {
|
pub async fn follow_user(db: &DbConn, follower_id: Uuid, following_id: Uuid) -> Result<(), DbErr> {
|
||||||
if follower_id == followed_id {
|
if follower_id == following_id {
|
||||||
return Err(DbErr::Custom("Users cannot follow themselves".to_string()));
|
return Err(DbErr::Custom("Users cannot follow themselves".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let follow = follow::ActiveModel {
|
let follow = follow::ActiveModel {
|
||||||
follower_id: Set(follower_id),
|
follower_id: Set(follower_id),
|
||||||
followed_id: Set(followed_id),
|
following_id: Set(following_id),
|
||||||
};
|
};
|
||||||
|
|
||||||
follow.insert(db).await?;
|
follow.insert(db).await?;
|
||||||
@@ -44,11 +44,11 @@ pub async fn follow_user(db: &DbConn, follower_id: Uuid, followed_id: Uuid) -> R
|
|||||||
pub async fn unfollow_user(
|
pub async fn unfollow_user(
|
||||||
db: &DbConn,
|
db: &DbConn,
|
||||||
follower_id: Uuid,
|
follower_id: Uuid,
|
||||||
followed_id: Uuid,
|
following_id: Uuid,
|
||||||
) -> Result<(), UserError> {
|
) -> Result<(), UserError> {
|
||||||
let deleted_result = follow::Entity::delete_many()
|
let deleted_result = follow::Entity::delete_many()
|
||||||
.filter(follow::Column::FollowerId.eq(follower_id))
|
.filter(follow::Column::FollowerId.eq(follower_id))
|
||||||
.filter(follow::Column::FollowedId.eq(followed_id))
|
.filter(follow::Column::FollowingId.eq(following_id))
|
||||||
.exec(db)
|
.exec(db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| UserError::Internal(e.to_string()))?;
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
@@ -60,18 +60,18 @@ pub async fn unfollow_user(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_followed_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
|
pub async fn get_following_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
|
||||||
let followed_users = follow::Entity::find()
|
let followed_users = follow::Entity::find()
|
||||||
.filter(follow::Column::FollowerId.eq(user_id))
|
.filter(follow::Column::FollowerId.eq(user_id))
|
||||||
.all(db)
|
.all(db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(followed_users.into_iter().map(|f| f.followed_id).collect())
|
Ok(followed_users.into_iter().map(|f| f.following_id).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_follower_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
|
pub async fn get_follower_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
|
||||||
let followers = follow::Entity::find()
|
let followers = follow::Entity::find()
|
||||||
.filter(follow::Column::FollowedId.eq(user_id))
|
.filter(follow::Column::FollowingId.eq(user_id))
|
||||||
.all(db)
|
.all(db)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(followers.into_iter().map(|f| f.follower_id).collect())
|
Ok(followers.into_iter().map(|f| f.follower_id).collect())
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
use models::domains::{tag, thought_tag};
|
use chrono::{Duration, Utc};
|
||||||
|
use models::domains::{tag, thought, thought_tag};
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
sqlx::types::uuid, ColumnTrait, ConnectionTrait, DbErr, EntityTrait, QueryFilter, Set,
|
prelude::Expr, sea_query::Alias, sqlx::types::uuid, ColumnTrait, ConnectionTrait, DbErr,
|
||||||
|
EntityTrait, QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set,
|
||||||
};
|
};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
@@ -84,3 +86,34 @@ where
|
|||||||
thought_tag::Entity::insert_many(links).exec(db).await?;
|
thought_tag::Entity::insert_many(links).exec(db).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_popular_tags<C>(db: &C) -> Result<Vec<String>, DbErr>
|
||||||
|
where
|
||||||
|
C: ConnectionTrait,
|
||||||
|
{
|
||||||
|
let seven_days_ago = Utc::now() - Duration::days(7);
|
||||||
|
|
||||||
|
let popular_tags = tag::Entity::find()
|
||||||
|
.select_only()
|
||||||
|
.column(tag::Column::Name)
|
||||||
|
.column_as(Expr::col((tag::Entity, tag::Column::Id)).count(), "count")
|
||||||
|
.join(
|
||||||
|
sea_orm::JoinType::InnerJoin,
|
||||||
|
tag::Relation::ThoughtTag.def(),
|
||||||
|
)
|
||||||
|
.join(
|
||||||
|
sea_orm::JoinType::InnerJoin,
|
||||||
|
thought_tag::Relation::Thought.def(),
|
||||||
|
)
|
||||||
|
.filter(thought::Column::CreatedAt.gte(seven_days_ago))
|
||||||
|
.group_by(tag::Column::Name)
|
||||||
|
.group_by(tag::Column::Id)
|
||||||
|
.order_by_desc(Expr::col(Alias::new("count")))
|
||||||
|
.order_by_asc(tag::Column::Name)
|
||||||
|
.limit(10)
|
||||||
|
.into_tuple::<(String, i64)>()
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(popular_tags.into_iter().map(|(name, _)| name).collect())
|
||||||
|
}
|
||||||
|
@@ -69,9 +69,9 @@ pub async fn get_thoughts_by_user(
|
|||||||
|
|
||||||
pub async fn get_feed_for_user(
|
pub async fn get_feed_for_user(
|
||||||
db: &DbConn,
|
db: &DbConn,
|
||||||
followed_ids: Vec<Uuid>,
|
following_ids: Vec<Uuid>,
|
||||||
) -> Result<Vec<ThoughtWithAuthor>, UserError> {
|
) -> Result<Vec<ThoughtWithAuthor>, UserError> {
|
||||||
if followed_ids.is_empty() {
|
if following_ids.is_empty() {
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ pub async fn get_feed_for_user(
|
|||||||
.column(thought::Column::AuthorId)
|
.column(thought::Column::AuthorId)
|
||||||
.column_as(user::Column::Username, "author_username")
|
.column_as(user::Column::Username, "author_username")
|
||||||
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
.join(JoinType::InnerJoin, thought::Relation::User.def())
|
||||||
.filter(thought::Column::AuthorId.is_in(followed_ids))
|
.filter(thought::Column::AuthorId.is_in(following_ids))
|
||||||
.order_by_desc(thought::Column::CreatedAt)
|
.order_by_desc(thought::Column::CreatedAt)
|
||||||
.into_model::<ThoughtWithAuthor>()
|
.into_model::<ThoughtWithAuthor>()
|
||||||
.all(db)
|
.all(db)
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
use sea_orm::prelude::Uuid;
|
use sea_orm::prelude::Uuid;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set, TransactionTrait,
|
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, QueryFilter, QueryOrder,
|
||||||
|
QuerySelect, RelationTrait, Set, TransactionTrait,
|
||||||
};
|
};
|
||||||
|
|
||||||
use models::domains::{top_friends, user};
|
use models::domains::{top_friends, user};
|
||||||
@@ -127,3 +128,12 @@ pub async fn update_user_profile(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| UserError::Internal(e.to_string()))
|
.map_err(|e| UserError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_top_friends(db: &DbConn, user_id: Uuid) -> Result<Vec<user::Model>, DbErr> {
|
||||||
|
user::Entity::find()
|
||||||
|
.join(JoinType::InnerJoin, top_friends::Relation::User.def().rev())
|
||||||
|
.filter(top_friends::Column::UserId.eq(user_id))
|
||||||
|
.order_by_asc(top_friends::Column::Position)
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
@@ -46,12 +46,12 @@ impl MigrationTrait for Migration {
|
|||||||
.table(Follow::Table)
|
.table(Follow::Table)
|
||||||
.if_not_exists()
|
.if_not_exists()
|
||||||
.col(uuid(Follow::FollowerId).not_null())
|
.col(uuid(Follow::FollowerId).not_null())
|
||||||
.col(uuid(Follow::FollowedId).not_null())
|
.col(uuid(Follow::FollowingId).not_null())
|
||||||
// Composite Primary Key to ensure a user can only follow another once
|
// Composite Primary Key to ensure a user can only follow another once
|
||||||
.primary_key(
|
.primary_key(
|
||||||
Index::create()
|
Index::create()
|
||||||
.col(Follow::FollowerId)
|
.col(Follow::FollowerId)
|
||||||
.col(Follow::FollowedId),
|
.col(Follow::FollowingId),
|
||||||
)
|
)
|
||||||
.foreign_key(
|
.foreign_key(
|
||||||
ForeignKey::create()
|
ForeignKey::create()
|
||||||
@@ -62,8 +62,8 @@ impl MigrationTrait for Migration {
|
|||||||
)
|
)
|
||||||
.foreign_key(
|
.foreign_key(
|
||||||
ForeignKey::create()
|
ForeignKey::create()
|
||||||
.name("fk_follow_followed_id")
|
.name("fk_follow_following_id")
|
||||||
.from(Follow::Table, Follow::FollowedId)
|
.from(Follow::Table, Follow::FollowingId)
|
||||||
.to(User::Table, User::Id)
|
.to(User::Table, User::Id)
|
||||||
.on_delete(ForeignKeyAction::Cascade),
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
)
|
)
|
||||||
@@ -97,5 +97,5 @@ pub enum Follow {
|
|||||||
// The user who is initiating the follow
|
// The user who is initiating the follow
|
||||||
FollowerId,
|
FollowerId,
|
||||||
// The user who is being followed
|
// The user who is being followed
|
||||||
FollowedId,
|
FollowingId,
|
||||||
}
|
}
|
||||||
|
@@ -6,7 +6,7 @@ pub struct Model {
|
|||||||
#[sea_orm(primary_key, auto_increment = false)]
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
pub follower_id: Uuid,
|
pub follower_id: Uuid,
|
||||||
#[sea_orm(primary_key, auto_increment = false)]
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
pub followed_id: Uuid,
|
pub following_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
@@ -21,12 +21,12 @@ pub enum Relation {
|
|||||||
Follower,
|
Follower,
|
||||||
#[sea_orm(
|
#[sea_orm(
|
||||||
belongs_to = "super::user::Entity",
|
belongs_to = "super::user::Entity",
|
||||||
from = "Column::FollowedId",
|
from = "Column::FollowingId",
|
||||||
to = "super::user::Column::Id",
|
to = "super::user::Column::Id",
|
||||||
on_update = "NoAction",
|
on_update = "NoAction",
|
||||||
on_delete = "Cascade"
|
on_delete = "Cascade"
|
||||||
)]
|
)]
|
||||||
Followed,
|
Following,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Related<super::user::Entity> for Entity {
|
impl Related<super::user::Entity> for Entity {
|
||||||
|
@@ -6,6 +6,8 @@ use validator::Validate;
|
|||||||
pub struct RegisterParams {
|
pub struct RegisterParams {
|
||||||
#[validate(length(min = 3))]
|
#[validate(length(min = 3))]
|
||||||
pub username: String,
|
pub username: String,
|
||||||
|
#[validate(email)]
|
||||||
|
pub email: String,
|
||||||
#[validate(length(min = 6))]
|
#[validate(length(min = 6))]
|
||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
@@ -14,12 +14,26 @@ pub struct UserSchema {
|
|||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
pub header_url: Option<String>,
|
pub header_url: Option<String>,
|
||||||
pub custom_css: Option<String>,
|
pub custom_css: Option<String>,
|
||||||
// In a real implementation, you'd fetch and return this data.
|
pub top_friends: Vec<String>,
|
||||||
// For now, we'll omit it from the schema to keep it simple.
|
|
||||||
// pub top_friends: Vec<String>,
|
|
||||||
pub joined_at: DateTimeWithTimeZoneWrapper,
|
pub joined_at: DateTimeWithTimeZoneWrapper,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<(user::Model, Vec<user::Model>)> for UserSchema {
|
||||||
|
fn from((user, top_friends): (user::Model, Vec<user::Model>)) -> Self {
|
||||||
|
Self {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
display_name: user.display_name,
|
||||||
|
bio: user.bio,
|
||||||
|
avatar_url: user.avatar_url,
|
||||||
|
header_url: user.header_url,
|
||||||
|
custom_css: user.custom_css,
|
||||||
|
top_friends: top_friends.into_iter().map(|u| u.username).collect(),
|
||||||
|
joined_at: user.created_at.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl From<user::Model> for UserSchema {
|
impl From<user::Model> for UserSchema {
|
||||||
fn from(user: user::Model) -> Self {
|
fn from(user: user::Model) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -30,6 +44,7 @@ impl From<user::Model> for UserSchema {
|
|||||||
avatar_url: user.avatar_url,
|
avatar_url: user.avatar_url,
|
||||||
header_url: user.header_url,
|
header_url: user.header_url,
|
||||||
custom_css: user.custom_css,
|
custom_css: user.custom_css,
|
||||||
|
top_friends: vec![], // Defaults to an empty list
|
||||||
joined_at: user.created_at.into(),
|
joined_at: user.created_at.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -9,7 +9,7 @@ use utils::testing::{
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_webfinger_discovery() {
|
async fn test_webfinger_discovery() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
create_user_with_password(&app.db, "testuser", "password123").await;
|
create_user_with_password(&app.db, "testuser", "password123", "testuser@example.com").await;
|
||||||
|
|
||||||
// 1. Valid WebFinger lookup for existing user
|
// 1. Valid WebFinger lookup for existing user
|
||||||
let url = "/.well-known/webfinger?resource=acct:testuser@localhost:3000";
|
let url = "/.well-known/webfinger?resource=acct:testuser@localhost:3000";
|
||||||
@@ -36,7 +36,7 @@ async fn test_webfinger_discovery() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_user_actor_endpoint() {
|
async fn test_user_actor_endpoint() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
create_user_with_password(&app.db, "testuser", "password123").await;
|
create_user_with_password(&app.db, "testuser", "password123", "testuser@example.com").await;
|
||||||
|
|
||||||
let response = make_request_with_headers(
|
let response = make_request_with_headers(
|
||||||
app.router.clone(),
|
app.router.clone(),
|
||||||
@@ -64,9 +64,11 @@ async fn test_user_actor_endpoint() {
|
|||||||
async fn test_user_inbox_follow() {
|
async fn test_user_inbox_follow() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
// user1 will be followed
|
// user1 will be followed
|
||||||
let user1 = create_user_with_password(&app.db, "user1", "password123").await;
|
let user1 =
|
||||||
|
create_user_with_password(&app.db, "user1", "password123", "user1@example.com").await;
|
||||||
// user2 will be the follower
|
// user2 will be the follower
|
||||||
let user2 = create_user_with_password(&app.db, "user2", "password123").await;
|
let user2 =
|
||||||
|
create_user_with_password(&app.db, "user2", "password123", "user2@example.com").await;
|
||||||
|
|
||||||
// Construct a follow activity from user2, targeting user1
|
// Construct a follow activity from user2, targeting user1
|
||||||
let follow_activity = json!({
|
let follow_activity = json!({
|
||||||
@@ -90,7 +92,7 @@ async fn test_user_inbox_follow() {
|
|||||||
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
||||||
|
|
||||||
// Verify that user2 is now following user1 in the database
|
// Verify that user2 is now following user1 in the database
|
||||||
let followers = app::persistence::follow::get_followed_ids(&app.db, user2.id)
|
let followers = app::persistence::follow::get_following_ids(&app.db, user2.id)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -98,7 +100,7 @@ async fn test_user_inbox_follow() {
|
|||||||
"User2 should be following user1"
|
"User2 should be following user1"
|
||||||
);
|
);
|
||||||
|
|
||||||
let following = app::persistence::follow::get_followed_ids(&app.db, user1.id)
|
let following = app::persistence::follow::get_following_ids(&app.db, user1.id)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -111,7 +113,7 @@ async fn test_user_inbox_follow() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_user_outbox_get() {
|
async fn test_user_outbox_get() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
create_user_with_password(&app.db, "testuser", "password123").await;
|
create_user_with_password(&app.db, "testuser", "password123", "testuser@example.com").await;
|
||||||
let token = super::main::login_user(app.router.clone(), "testuser", "password123").await;
|
let token = super::main::login_user(app.router.clone(), "testuser", "password123").await;
|
||||||
|
|
||||||
// Create a thought first
|
// Create a thought first
|
||||||
|
@@ -7,7 +7,13 @@ use utils::testing::{make_jwt_request, make_request_with_headers};
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_api_key_flow() {
|
async fn test_api_key_flow() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
let _ = create_user_with_password(&app.db, "apikey_user", "password123").await;
|
let _ = create_user_with_password(
|
||||||
|
&app.db,
|
||||||
|
"apikey_user",
|
||||||
|
"password123",
|
||||||
|
"apikey_user@example.com",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let jwt = login_user(app.router.clone(), "apikey_user", "password123").await;
|
let jwt = login_user(app.router.clone(), "apikey_user", "password123").await;
|
||||||
|
|
||||||
// 1. Create a new API key using JWT auth
|
// 1. Create a new API key using JWT auth
|
||||||
|
@@ -11,6 +11,7 @@ async fn test_auth_flow() {
|
|||||||
|
|
||||||
let register_body = json!({
|
let register_body = json!({
|
||||||
"username": "testuser",
|
"username": "testuser",
|
||||||
|
"email": "testuser@example.com",
|
||||||
"password": "password123"
|
"password": "password123"
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
@@ -26,6 +27,7 @@ async fn test_auth_flow() {
|
|||||||
"/auth/register",
|
"/auth/register",
|
||||||
json!({
|
json!({
|
||||||
"username": "testuser",
|
"username": "testuser",
|
||||||
|
"email": "testuser@example.com",
|
||||||
"password": "password456"
|
"password": "password456"
|
||||||
})
|
})
|
||||||
.to_string(),
|
.to_string(),
|
||||||
@@ -48,6 +50,7 @@ async fn test_auth_flow() {
|
|||||||
|
|
||||||
let bad_login_body = json!({
|
let bad_login_body = json!({
|
||||||
"username": "testuser",
|
"username": "testuser",
|
||||||
|
"email": "testuser@example.com",
|
||||||
"password": "wrongpassword"
|
"password": "wrongpassword"
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
|
@@ -7,9 +7,9 @@ use utils::testing::make_jwt_request;
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_feed_and_user_thoughts() {
|
async fn test_feed_and_user_thoughts() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
create_user_with_password(&app.db, "user1", "password1").await;
|
create_user_with_password(&app.db, "user1", "password1", "user1@example.com").await;
|
||||||
create_user_with_password(&app.db, "user2", "password2").await;
|
create_user_with_password(&app.db, "user2", "password2", "user2@example.com").await;
|
||||||
create_user_with_password(&app.db, "user3", "password3").await;
|
create_user_with_password(&app.db, "user3", "password3", "user3@example.com").await;
|
||||||
|
|
||||||
// As user1, post a thought
|
// As user1, post a thought
|
||||||
let token = super::main::login_user(app.router.clone(), "user1", "password1").await;
|
let token = super::main::login_user(app.router.clone(), "user1", "password1").await;
|
||||||
|
@@ -7,8 +7,8 @@ async fn test_follow_endpoints() {
|
|||||||
std::env::set_var("AUTH_SECRET", "test-secret");
|
std::env::set_var("AUTH_SECRET", "test-secret");
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
|
|
||||||
create_user_with_password(&app.db, "user1", "password1").await;
|
create_user_with_password(&app.db, "user1", "password1", "user1@example.com").await;
|
||||||
create_user_with_password(&app.db, "user2", "password2").await;
|
create_user_with_password(&app.db, "user2", "password2", "user2@example.com").await;
|
||||||
|
|
||||||
let token = super::main::login_user(app.router.clone(), "user1", "password1").await;
|
let token = super::main::login_user(app.router.clone(), "user1", "password1").await;
|
||||||
|
|
||||||
|
@@ -38,10 +38,12 @@ pub async fn create_user_with_password(
|
|||||||
db: &DatabaseConnection,
|
db: &DatabaseConnection,
|
||||||
username: &str,
|
username: &str,
|
||||||
password: &str,
|
password: &str,
|
||||||
|
email: &str,
|
||||||
) -> user::Model {
|
) -> user::Model {
|
||||||
let params = RegisterParams {
|
let params = RegisterParams {
|
||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
password: password.to_string(),
|
password: password.to_string(),
|
||||||
|
email: email.to_string(),
|
||||||
};
|
};
|
||||||
app::persistence::auth::register_user(db, params)
|
app::persistence::auth::register_user(db, params)
|
||||||
.await
|
.await
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
use crate::api::main::{create_user_with_password, login_user, setup};
|
use crate::api::main::{create_user_with_password, login_user, setup, TestApp};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@@ -7,7 +7,8 @@ use utils::testing::{make_get_request, make_jwt_request};
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_hashtag_flow() {
|
async fn test_hashtag_flow() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
let user = create_user_with_password(&app.db, "taguser", "password123").await;
|
let user =
|
||||||
|
create_user_with_password(&app.db, "taguser", "password123", "taguser@example.com").await;
|
||||||
let token = login_user(app.router.clone(), "taguser", "password123").await;
|
let token = login_user(app.router.clone(), "taguser", "password123").await;
|
||||||
|
|
||||||
// 1. Post a thought with hashtags
|
// 1. Post a thought with hashtags
|
||||||
@@ -48,3 +49,43 @@ async fn test_hashtag_flow() {
|
|||||||
assert_eq!(thoughts.len(), 1);
|
assert_eq!(thoughts.len(), 1);
|
||||||
assert_eq!(thoughts[0]["id"], thought_id);
|
assert_eq!(thoughts[0]["id"], thought_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_popular_tags() {
|
||||||
|
let app = setup().await;
|
||||||
|
let _ = create_user_with_password(&app.db, "poptag_user", "password123", "poptag@example.com")
|
||||||
|
.await;
|
||||||
|
let token = login_user(app.router.clone(), "poptag_user", "password123").await;
|
||||||
|
|
||||||
|
// Helper async function to post a thought
|
||||||
|
async fn post_thought(app: &TestApp, token: &str, content: &str) {
|
||||||
|
let body = json!({ "content": content }).to_string();
|
||||||
|
let response =
|
||||||
|
make_jwt_request(app.router.clone(), "/thoughts", "POST", Some(body), token).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Post thoughts to create tag usage data
|
||||||
|
// Expected counts: rust (3), web (2), axum (2), testing (1)
|
||||||
|
post_thought(&app, &token, "My first post about #rust and the #web").await;
|
||||||
|
post_thought(&app, &token, "Another post about #rust and #axum").await;
|
||||||
|
post_thought(&app, &token, "I'm really enjoying #rust lately").await;
|
||||||
|
post_thought(&app, &token, "Let's talk about #axum and the #web").await;
|
||||||
|
post_thought(&app, &token, "Don't forget about #testing").await;
|
||||||
|
|
||||||
|
// 2. Fetch the popular tags
|
||||||
|
let response = make_get_request(app.router.clone(), "/tags/popular", None).await;
|
||||||
|
println!("Response: {:?}", response);
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v: Vec<String> = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
// 3. Assert the results
|
||||||
|
assert_eq!(v.len(), 4, "Should return the 4 unique tags used");
|
||||||
|
assert_eq!(
|
||||||
|
v,
|
||||||
|
vec!["rust", "axum", "web", "testing"],
|
||||||
|
"Tags should be ordered by popularity, then alphabetically"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@@ -10,8 +10,10 @@ use utils::testing::{make_delete_request, make_post_request};
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_thought_endpoints() {
|
async fn test_thought_endpoints() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
let user1 = create_user_with_password(&app.db, "user1", "password123").await; // AuthUser is ID 1
|
let user1 =
|
||||||
let _user2 = create_user_with_password(&app.db, "user2", "password123").await; // Other user is ID 2
|
create_user_with_password(&app.db, "user1", "password123", "user1@example.com").await; // AuthUser is ID 1
|
||||||
|
let _user2 =
|
||||||
|
create_user_with_password(&app.db, "user2", "password123", "user2@example.com").await; // Other user is ID 2
|
||||||
|
|
||||||
// 1. Post a new thought as user 1
|
// 1. Post a new thought as user 1
|
||||||
let body = json!({ "content": "My first thought!" }).to_string();
|
let body = json!({ "content": "My first thought!" }).to_string();
|
||||||
|
@@ -12,7 +12,8 @@ use crate::api::main::{create_user_with_password, login_user, setup};
|
|||||||
async fn test_post_users() {
|
async fn test_post_users() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
|
|
||||||
let body = r#"{"username": "test", "password": "password123"}"#.to_owned();
|
let body = r#"{"username": "test", "email": "test@example.com", "password": "password123"}"#
|
||||||
|
.to_owned();
|
||||||
let response = make_post_request(app.router, "/auth/register", body, None).await;
|
let response = make_post_request(app.router, "/auth/register", body, None).await;
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::CREATED);
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
@@ -21,14 +22,15 @@ async fn test_post_users() {
|
|||||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
assert_eq!(v["username"], "test");
|
assert_eq!(v["username"], "test");
|
||||||
assert!(v["display_name"].is_null());
|
assert!(v["display_name"].is_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
pub(super) async fn test_post_users_error() {
|
pub(super) async fn test_post_users_error() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
|
|
||||||
let body = r#"{"username": "1", "password": "password123"}"#.to_owned();
|
let body =
|
||||||
|
r#"{"username": "1", "email": "test@example.com", "password": "password123"}"#.to_owned();
|
||||||
let response = make_post_request(app.router, "/auth/register", body, None).await;
|
let response = make_post_request(app.router, "/auth/register", body, None).await;
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
@@ -43,7 +45,8 @@ pub(super) async fn test_post_users_error() {
|
|||||||
pub async fn test_get_users() {
|
pub async fn test_get_users() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
|
|
||||||
let body = r#"{"username": "test", "password": "password123"}"#.to_owned();
|
let body = r#"{"username": "test", "email": "test@example.com", "password": "password123"}"#
|
||||||
|
.to_owned();
|
||||||
make_post_request(app.router.clone(), "/auth/register", body, None).await;
|
make_post_request(app.router.clone(), "/auth/register", body, None).await;
|
||||||
|
|
||||||
let response = make_get_request(app.router, "/users", None).await;
|
let response = make_get_request(app.router, "/users", None).await;
|
||||||
@@ -65,6 +68,7 @@ async fn test_me_endpoints() {
|
|||||||
// 1. Register a new user
|
// 1. Register a new user
|
||||||
let register_body = json!({
|
let register_body = json!({
|
||||||
"username": "me_user",
|
"username": "me_user",
|
||||||
|
"email": "me_user@example.com",
|
||||||
"password": "password123"
|
"password": "password123"
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
@@ -82,7 +86,7 @@ async fn test_me_endpoints() {
|
|||||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(v["username"], "me_user");
|
assert_eq!(v["username"], "me_user");
|
||||||
assert!(v["bio"].is_null());
|
assert!(v["bio"].is_null());
|
||||||
assert!(v["display_name"].is_null());
|
assert!(v["display_name"].is_string());
|
||||||
|
|
||||||
// 4. PUT /users/me to update the profile
|
// 4. PUT /users/me to update the profile
|
||||||
let update_body = json!({
|
let update_body = json!({
|
||||||
@@ -119,10 +123,14 @@ async fn test_update_me_top_friends() {
|
|||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
|
|
||||||
// 1. Create users for the test
|
// 1. Create users for the test
|
||||||
let user_me = create_user_with_password(&app.db, "me_user", "password123").await;
|
let user_me =
|
||||||
let friend1 = create_user_with_password(&app.db, "friend1", "password123").await;
|
create_user_with_password(&app.db, "me_user", "password123", "me_user@example.com").await;
|
||||||
let friend2 = create_user_with_password(&app.db, "friend2", "password123").await;
|
let friend1 =
|
||||||
let _friend3 = create_user_with_password(&app.db, "friend3", "password123").await;
|
create_user_with_password(&app.db, "friend1", "password123", "friend1@example.com").await;
|
||||||
|
let friend2 =
|
||||||
|
create_user_with_password(&app.db, "friend2", "password123", "friend2@example.com").await;
|
||||||
|
let _friend3 =
|
||||||
|
create_user_with_password(&app.db, "friend3", "password123", "friend3@example.com").await;
|
||||||
|
|
||||||
// 2. Log in as "me_user"
|
// 2. Log in as "me_user"
|
||||||
let token = login_user(app.router.clone(), "me_user", "password123").await;
|
let token = login_user(app.router.clone(), "me_user", "password123").await;
|
||||||
@@ -189,7 +197,8 @@ async fn test_update_me_css_and_images() {
|
|||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
|
|
||||||
// 1. Create and log in as a user
|
// 1. Create and log in as a user
|
||||||
let _ = create_user_with_password(&app.db, "css_user", "password123").await;
|
let _ =
|
||||||
|
create_user_with_password(&app.db, "css_user", "password123", "css_user@example.com").await;
|
||||||
let token = login_user(app.router.clone(), "css_user", "password123").await;
|
let token = login_user(app.router.clone(), "css_user", "password123").await;
|
||||||
|
|
||||||
// 2. Attempt to update with an invalid avatar URL
|
// 2. Attempt to update with an invalid avatar URL
|
||||||
|
Reference in New Issue
Block a user