From b83b7acf1cb8e7f2c8eb10927a9ed51c655199d0 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 6 Sep 2025 15:29:38 +0200 Subject: [PATCH] feat: Refactor user and thought models to use UUIDs instead of integers - Updated user and thought models to utilize UUIDs for primary keys. - Modified persistence functions to accommodate UUIDs for user and thought IDs. - Implemented tag functionality with new Tag and ThoughtTag models. - Added migration scripts to create new tables for tags and thought-tag relationships. - Enhanced thought creation to parse hashtags and link them to thoughts. - Updated tests to reflect changes in user and thought ID types. --- thoughts-backend/Cargo.lock | 2 + thoughts-backend/Cargo.toml | 4 +- thoughts-backend/api/src/extractor/auth.rs | 7 +- thoughts-backend/api/src/routers/mod.rs | 2 + thoughts-backend/api/src/routers/tag.rs | 38 ++++++++ thoughts-backend/api/src/routers/thought.rs | 3 +- thoughts-backend/api/src/routers/user.rs | 3 +- .../app/src/persistence/follow.rs | 16 ++-- thoughts-backend/app/src/persistence/mod.rs | 1 + thoughts-backend/app/src/persistence/tag.rs | 86 +++++++++++++++++++ .../app/src/persistence/thought.rs | 61 ++++++++++--- thoughts-backend/app/src/persistence/user.rs | 48 ++++++++--- thoughts-backend/migration/src/lib.rs | 2 + .../migration/src/m20240101_000001_init.rs | 6 +- .../migration/src/m20250905_000001_init.rs | 18 ++-- .../m20250906_100000_add_profile_fields.rs | 4 +- .../src/m20250906_130237_add_tags.rs | 74 ++++++++++++++++ thoughts-backend/models/Cargo.toml | 1 + thoughts-backend/models/src/domains/follow.rs | 14 ++- thoughts-backend/models/src/domains/mod.rs | 3 + .../models/src/domains/prelude.rs | 3 + thoughts-backend/models/src/domains/tag.rs | 27 ++++++ .../models/src/domains/thought.rs | 18 +++- .../models/src/domains/thought_tag.rs | 40 +++++++++ .../models/src/domains/top_friends.rs | 35 ++++++++ thoughts-backend/models/src/domains/user.rs | 12 ++- .../models/src/schemas/thought.rs | 7 +- thoughts-backend/models/src/schemas/user.rs | 3 +- thoughts-backend/tests/api/activitypub.rs | 21 +++-- thoughts-backend/tests/api/auth.rs | 1 - thoughts-backend/tests/api/main.rs | 22 ++--- thoughts-backend/tests/api/mod.rs | 1 + thoughts-backend/tests/api/tag.rs | 51 +++++++++++ thoughts-backend/tests/api/thought.rs | 25 ++++-- thoughts-backend/tests/api/user.rs | 76 +++++++++++++++- .../tests/app/persistence/user.rs | 1 - thoughts-backend/utils/Cargo.toml | 2 +- thoughts-backend/utils/src/testing/api/mod.rs | 7 +- 38 files changed, 638 insertions(+), 107 deletions(-) create mode 100644 thoughts-backend/api/src/routers/tag.rs create mode 100644 thoughts-backend/app/src/persistence/tag.rs create mode 100644 thoughts-backend/migration/src/m20250906_130237_add_tags.rs create mode 100644 thoughts-backend/models/src/domains/tag.rs create mode 100644 thoughts-backend/models/src/domains/thought_tag.rs create mode 100644 thoughts-backend/models/src/domains/top_friends.rs create mode 100644 thoughts-backend/tests/api/tag.rs diff --git a/thoughts-backend/Cargo.lock b/thoughts-backend/Cargo.lock index b45076a..8d8ad5c 100644 --- a/thoughts-backend/Cargo.lock +++ b/thoughts-backend/Cargo.lock @@ -2408,6 +2408,7 @@ dependencies = [ "serde", "serde_json", "utoipa", + "uuid", "validator", ] @@ -4894,6 +4895,7 @@ dependencies = [ "quote", "regex", "syn 2.0.104", + "uuid", ] [[package]] diff --git a/thoughts-backend/Cargo.toml b/thoughts-backend/Cargo.toml index e7889c8..40b7fca 100644 --- a/thoughts-backend/Cargo.toml +++ b/thoughts-backend/Cargo.toml @@ -17,11 +17,11 @@ members = ["api", "app", "doc", "models", "migration", "utils"] tower = { version = "0.5.2", default-features = false } axum = { version = "0.8.4", default-features = false } sea-orm = { version = "1.1.12" } -sea-query = { version = "0.32.6" } # Added sea-query dependency +sea-query = { version = "0.32.6" } # Added sea-query dependency serde = { version = "1.0.219", features = ["derive"] } serde_json = { version = "1.0.140", features = ["raw_value"] } tracing = "0.1.41" -utoipa = { version = "5.4.0", features = ["macros", "chrono"] } +utoipa = { version = "5.4.0", features = ["macros", "chrono", "uuid"] } validator = { version = "0.20.0", default-features = false } chrono = { version = "0.4.41", features = ["serde"] } tokio = { version = "1.45.1", features = ["full"] } diff --git a/thoughts-backend/api/src/extractor/auth.rs b/thoughts-backend/api/src/extractor/auth.rs index 629b3c8..2b5bc54 100644 --- a/thoughts-backend/api/src/extractor/auth.rs +++ b/thoughts-backend/api/src/extractor/auth.rs @@ -5,13 +5,14 @@ use axum::{ use jsonwebtoken::{decode, DecodingKey, Validation}; use once_cell::sync::Lazy; +use sea_orm::prelude::Uuid; use serde::{Deserialize, Serialize}; use app::state::AppState; #[derive(Debug, Serialize, Deserialize)] pub struct Claims { - pub sub: i32, + pub sub: Uuid, pub exp: usize, } @@ -19,7 +20,7 @@ static JWT_SECRET: Lazy = Lazy::new(|| std::env::var("AUTH_SECRET").expect("AUTH_SECRET must be set")); pub struct AuthUser { - pub id: i32, + pub id: Uuid, } impl FromRequestParts for AuthUser { @@ -31,7 +32,7 @@ impl FromRequestParts for AuthUser { ) -> Result { if let Some(user_id_header) = parts.headers.get("x-test-user-id") { let user_id_str = user_id_header.to_str().unwrap_or("0"); - let user_id = user_id_str.parse::().unwrap_or(0); + let user_id = user_id_str.parse::().unwrap_or(Uuid::nil()); return Ok(AuthUser { id: user_id }); } diff --git a/thoughts-backend/api/src/routers/mod.rs b/thoughts-backend/api/src/routers/mod.rs index edb111e..9a59d9f 100644 --- a/thoughts-backend/api/src/routers/mod.rs +++ b/thoughts-backend/api/src/routers/mod.rs @@ -3,6 +3,7 @@ use axum::Router; pub mod auth; pub mod feed; pub mod root; +pub mod tag; pub mod thought; pub mod user; pub mod well_known; @@ -25,6 +26,7 @@ pub fn create_router(state: AppState) -> Router { .nest("/users", create_user_router()) .nest("/thoughts", create_thought_router()) .nest("/feed", create_feed_router()) + .nest("/tags", tag::create_tag_router()) .with_state(state) .layer(cors) } diff --git a/thoughts-backend/api/src/routers/tag.rs b/thoughts-backend/api/src/routers/tag.rs new file mode 100644 index 0000000..b718d28 --- /dev/null +++ b/thoughts-backend/api/src/routers/tag.rs @@ -0,0 +1,38 @@ +use crate::error::ApiError; +use app::{persistence::thought::get_thoughts_by_tag_name, state::AppState}; +use axum::{ + extract::{Path, State}, + response::IntoResponse, + routing::get, + Json, Router, +}; +use models::schemas::thought::{ThoughtListSchema, ThoughtSchema}; + +#[utoipa::path( + get, + path = "{tagName}", + params(("tagName" = String, Path, description = "Tag name")), + responses((status = 200, description = "List of thoughts with a specific tag", body = ThoughtListSchema)) +)] +async fn get_thoughts_by_tag( + State(state): State, + Path(tag_name): Path, +) -> Result { + let thoughts_with_authors = get_thoughts_by_tag_name(&state.conn, &tag_name).await; + println!( + "Result from get_thoughts_by_tag_name: {:?}", + thoughts_with_authors + ); + let thoughts_with_authors = thoughts_with_authors?; + println!("Thoughts with authors: {:?}", thoughts_with_authors); + let thoughts_schema: Vec = thoughts_with_authors + .into_iter() + .map(ThoughtSchema::from) + .collect(); + println!("Thoughts schema: {:?}", thoughts_schema); + Ok(Json(ThoughtListSchema::from(thoughts_schema))) +} + +pub fn create_tag_router() -> Router { + Router::new().route("/{tag_name}", get(get_thoughts_by_tag)) +} diff --git a/thoughts-backend/api/src/routers/thought.rs b/thoughts-backend/api/src/routers/thought.rs index edc89fc..fbfab5b 100644 --- a/thoughts-backend/api/src/routers/thought.rs +++ b/thoughts-backend/api/src/routers/thought.rs @@ -12,6 +12,7 @@ use app::{ state::AppState, }; use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema}; +use sea_orm::prelude::Uuid; use crate::{ error::ApiError, @@ -74,7 +75,7 @@ async fn thoughts_post( async fn thoughts_delete( State(state): State, auth_user: AuthUser, - Path(id): Path, + Path(id): Path, ) -> Result { let thought = get_thought(&state.conn, id) .await? diff --git a/thoughts-backend/api/src/routers/user.rs b/thoughts-backend/api/src/routers/user.rs index 8b1adea..5e49dd5 100644 --- a/thoughts-backend/api/src/routers/user.rs +++ b/thoughts-backend/api/src/routers/user.rs @@ -5,6 +5,7 @@ use axum::{ routing::{get, post}, Router, }; +use sea_orm::prelude::Uuid; use serde_json::{json, Value}; use app::persistence::{ @@ -201,7 +202,7 @@ async fn get_user_by_param( Path(param): Path, ) -> Response { // First, try to handle it as a numeric ID. - if let Ok(id) = param.parse::() { + if let Ok(id) = param.parse::() { return match get_user(&state.conn, id).await { Ok(Some(user)) => Json(UserSchema::from(user)).into_response(), Ok(None) => ApiError::from(UserError::NotFound).into_response(), diff --git a/thoughts-backend/app/src/persistence/follow.rs b/thoughts-backend/app/src/persistence/follow.rs index 6034d14..624ab00 100644 --- a/thoughts-backend/app/src/persistence/follow.rs +++ b/thoughts-backend/app/src/persistence/follow.rs @@ -1,11 +1,13 @@ -use sea_orm::{ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set}; +use sea_orm::{ + prelude::Uuid, ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set, +}; use crate::{error::UserError, persistence::user::get_user_by_username}; use models::domains::follow; pub async fn add_follower( db: &DbConn, - followed_id: i32, + followed_id: Uuid, follower_actor_id: &str, ) -> Result<(), UserError> { let follower_username = follower_actor_id @@ -25,7 +27,7 @@ pub async fn add_follower( Ok(()) } -pub async fn follow_user(db: &DbConn, follower_id: i32, followed_id: i32) -> Result<(), DbErr> { +pub async fn follow_user(db: &DbConn, follower_id: Uuid, followed_id: Uuid) -> Result<(), DbErr> { if follower_id == followed_id { return Err(DbErr::Custom("Users cannot follow themselves".to_string())); } @@ -41,8 +43,8 @@ pub async fn follow_user(db: &DbConn, follower_id: i32, followed_id: i32) -> Res pub async fn unfollow_user( db: &DbConn, - follower_id: i32, - followed_id: i32, + follower_id: Uuid, + followed_id: Uuid, ) -> Result<(), UserError> { let deleted_result = follow::Entity::delete_many() .filter(follow::Column::FollowerId.eq(follower_id)) @@ -58,7 +60,7 @@ pub async fn unfollow_user( Ok(()) } -pub async fn get_followed_ids(db: &DbConn, user_id: i32) -> Result, DbErr> { +pub async fn get_followed_ids(db: &DbConn, user_id: Uuid) -> Result, DbErr> { let followed_users = follow::Entity::find() .filter(follow::Column::FollowerId.eq(user_id)) .all(db) @@ -67,7 +69,7 @@ pub async fn get_followed_ids(db: &DbConn, user_id: i32) -> Result, DbE Ok(followed_users.into_iter().map(|f| f.followed_id).collect()) } -pub async fn get_follower_ids(db: &DbConn, user_id: i32) -> Result, DbErr> { +pub async fn get_follower_ids(db: &DbConn, user_id: Uuid) -> Result, DbErr> { let followers = follow::Entity::find() .filter(follow::Column::FollowedId.eq(user_id)) .all(db) diff --git a/thoughts-backend/app/src/persistence/mod.rs b/thoughts-backend/app/src/persistence/mod.rs index 166bfc7..cb16996 100644 --- a/thoughts-backend/app/src/persistence/mod.rs +++ b/thoughts-backend/app/src/persistence/mod.rs @@ -1,4 +1,5 @@ pub mod auth; pub mod follow; +pub mod tag; pub mod thought; pub mod user; diff --git a/thoughts-backend/app/src/persistence/tag.rs b/thoughts-backend/app/src/persistence/tag.rs new file mode 100644 index 0000000..eb13737 --- /dev/null +++ b/thoughts-backend/app/src/persistence/tag.rs @@ -0,0 +1,86 @@ +use models::domains::{tag, thought_tag}; +use sea_orm::{ + sqlx::types::uuid, ColumnTrait, ConnectionTrait, DbErr, EntityTrait, QueryFilter, Set, +}; +use std::collections::HashSet; + +pub fn parse_hashtags(content: &str) -> Vec { + content + .split_whitespace() + .filter_map(|word| { + if word.starts_with('#') && word.len() > 1 { + Some(word[1..].to_lowercase().to_string()) + } else { + None + } + }) + .collect::>() + .into_iter() + .collect() +} + +pub async fn find_or_create_tags(db: &C, names: Vec) -> Result, DbErr> +where + C: ConnectionTrait, +{ + if names.is_empty() { + return Ok(vec![]); + } + let existing_tags = tag::Entity::find() + .filter(tag::Column::Name.is_in(names.clone())) + .all(db) + .await?; + + let existing_names: HashSet = existing_tags.iter().map(|t| t.name.clone()).collect(); + let new_names: Vec = names + .into_iter() + .filter(|n| !existing_names.contains(n)) + .collect(); + + if !new_names.is_empty() { + let new_tags: Vec = new_names + .clone() + .into_iter() + .map(|name| tag::ActiveModel { + name: Set(name), + ..Default::default() + }) + .collect(); + tag::Entity::insert_many(new_tags).exec(db).await?; + } + + tag::Entity::find() + .filter( + tag::Column::Name.is_in( + existing_names + .union(&new_names.into_iter().collect()) + .cloned() + .collect::>(), + ), + ) + .all(db) + .await +} + +pub async fn link_tags_to_thought( + db: &C, + thought_id: uuid::Uuid, + tags: Vec, +) -> Result<(), DbErr> +where + C: ConnectionTrait, +{ + if tags.is_empty() { + return Ok(()); + } + let links: Vec = tags + .into_iter() + .map(|tag| thought_tag::ActiveModel { + thought_id: Set(thought_id), + tag_id: Set(tag.id), + }) + .collect(); + + thought_tag::Entity::insert_many(links).exec(db).await?; + Ok(()) +} diff --git a/thoughts-backend/app/src/persistence/thought.rs b/thoughts-backend/app/src/persistence/thought.rs index 9884f04..138b80e 100644 --- a/thoughts-backend/app/src/persistence/thought.rs +++ b/thoughts-backend/app/src/persistence/thought.rs @@ -1,42 +1,56 @@ use sea_orm::{ - ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, QueryFilter, QueryOrder, - QuerySelect, RelationTrait, Set, + prelude::Uuid, ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, + QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set, TransactionTrait, }; use models::{ - domains::{thought, user}, + domains::{tag, thought, thought_tag, user}, params::thought::CreateThoughtParams, schemas::thought::ThoughtWithAuthor, }; -use crate::error::UserError; +use crate::{ + error::UserError, + persistence::tag::{find_or_create_tags, link_tags_to_thought, parse_hashtags}, +}; pub async fn create_thought( db: &DbConn, - author_id: i32, + author_id: Uuid, params: CreateThoughtParams, ) -> Result { - thought::ActiveModel { + let txn = db.begin().await?; + + let new_thought = thought::ActiveModel { author_id: Set(author_id), - content: Set(params.content), + content: Set(params.content.clone()), ..Default::default() } - .insert(db) - .await + .insert(&txn) + .await?; + + let tag_names = parse_hashtags(¶ms.content); + if !tag_names.is_empty() { + let tags = find_or_create_tags(&txn, tag_names).await?; + link_tags_to_thought(&txn, new_thought.id, tags).await?; + } + + txn.commit().await?; + Ok(new_thought) } -pub async fn get_thought(db: &DbConn, thought_id: i32) -> Result, DbErr> { +pub async fn get_thought(db: &DbConn, thought_id: Uuid) -> Result, DbErr> { thought::Entity::find_by_id(thought_id).one(db).await } -pub async fn delete_thought(db: &DbConn, thought_id: i32) -> Result<(), DbErr> { +pub async fn delete_thought(db: &DbConn, thought_id: Uuid) -> 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, + user_id: Uuid, ) -> Result, DbErr> { thought::Entity::find() .select_only() @@ -55,7 +69,7 @@ pub async fn get_thoughts_by_user( pub async fn get_feed_for_user( db: &DbConn, - followed_ids: Vec, + followed_ids: Vec, ) -> Result, UserError> { if followed_ids.is_empty() { return Ok(vec![]); @@ -76,3 +90,24 @@ pub async fn get_feed_for_user( .await .map_err(|e| UserError::Internal(e.to_string())) } + +pub async fn get_thoughts_by_tag_name( + db: &DbConn, + tag_name: &str, +) -> Result, DbErr> { + thought::Entity::find() + .select_only() + .column(thought::Column::Id) + .column(thought::Column::Content) + .column(thought::Column::CreatedAt) + .column(thought::Column::AuthorId) + .column_as(user::Column::Username, "author_username") + .join(JoinType::InnerJoin, thought::Relation::User.def()) + .join(JoinType::InnerJoin, thought::Relation::ThoughtTag.def()) + .join(JoinType::InnerJoin, thought_tag::Relation::Tag.def()) + .filter(tag::Column::Name.eq(tag_name.to_lowercase())) + .order_by_desc(thought::Column::CreatedAt) + .into_model::() + .all(db) + .await +} diff --git a/thoughts-backend/app/src/persistence/user.rs b/thoughts-backend/app/src/persistence/user.rs index 8920f95..a7eec49 100644 --- a/thoughts-backend/app/src/persistence/user.rs +++ b/thoughts-backend/app/src/persistence/user.rs @@ -1,8 +1,9 @@ +use sea_orm::prelude::Uuid; use sea_orm::{ ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set, TransactionTrait, }; -use models::domains::user; +use models::domains::{top_friends, user}; use models::params::user::{CreateUserParams, UpdateUserParams}; use models::queries::user::UserQuery; @@ -27,7 +28,7 @@ pub async fn search_users(db: &DbConn, query: UserQuery) -> Result Result, DbErr> { +pub async fn get_user(db: &DbConn, id: Uuid) -> Result, DbErr> { user::Entity::find_by_id(id).one(db).await } @@ -41,7 +42,7 @@ pub async fn get_user_by_username( .await } -pub async fn get_users_by_ids(db: &DbConn, ids: Vec) -> Result, DbErr> { +pub async fn get_users_by_ids(db: &DbConn, ids: Vec) -> Result, DbErr> { user::Entity::find() .filter(user::Column::Id.is_in(ids)) .all(db) @@ -50,7 +51,7 @@ pub async fn get_users_by_ids(db: &DbConn, ids: Vec) -> Result Result { let mut user: user::ActiveModel = get_user(db, user_id) @@ -75,26 +76,47 @@ pub async fn update_user_profile( user.custom_css = Set(Some(custom_css)); } - // This is a complex operation, so we use a transaction if let Some(friend_usernames) = params.top_friends { let txn = db .begin() .await .map_err(|e| UserError::Internal(e.to_string()))?; - // 1. Delete old top friends - // In a real app, you would create a `top_friends` entity and use it here. - // For now, we'll skip this to avoid creating the model. + top_friends::Entity::delete_many() + .filter(top_friends::Column::UserId.eq(user_id)) + .exec(&txn) + .await + .map_err(|e| UserError::Internal(e.to_string()))?; - // 2. Find new friends by username - let _friends = user::Entity::find() - .filter(user::Column::Username.is_in(friend_usernames)) + let friends = user::Entity::find() + .filter(user::Column::Username.is_in(friend_usernames.clone())) .all(&txn) .await .map_err(|e| UserError::Internal(e.to_string()))?; - // 3. Insert new friends - // This part would involve inserting into the `top_friends` table. + if friends.len() != friend_usernames.len() { + return Err(UserError::Validation( + "One or more usernames in top_friends do not exist".to_string(), + )); + } + + let new_top_friends: Vec = friends + .iter() + .enumerate() + .map(|(index, friend)| top_friends::ActiveModel { + user_id: Set(user_id), + friend_id: Set(friend.id), + position: Set((index + 1) as i16), + ..Default::default() + }) + .collect(); + + if !new_top_friends.is_empty() { + top_friends::Entity::insert_many(new_top_friends) + .exec(&txn) + .await + .map_err(|e| UserError::Internal(e.to_string()))?; + } txn.commit() .await diff --git a/thoughts-backend/migration/src/lib.rs b/thoughts-backend/migration/src/lib.rs index a5e2400..f1f2188 100644 --- a/thoughts-backend/migration/src/lib.rs +++ b/thoughts-backend/migration/src/lib.rs @@ -3,6 +3,7 @@ pub use sea_orm_migration::prelude::*; mod m20240101_000001_init; mod m20250905_000001_init; mod m20250906_100000_add_profile_fields; +mod m20250906_130237_add_tags; pub struct Migrator; @@ -13,6 +14,7 @@ impl MigratorTrait for Migrator { Box::new(m20240101_000001_init::Migration), Box::new(m20250905_000001_init::Migration), Box::new(m20250906_100000_add_profile_fields::Migration), + Box::new(m20250906_130237_add_tags::Migration), ] } } diff --git a/thoughts-backend/migration/src/m20240101_000001_init.rs b/thoughts-backend/migration/src/m20240101_000001_init.rs index c8dc6a5..f23d39c 100644 --- a/thoughts-backend/migration/src/m20240101_000001_init.rs +++ b/thoughts-backend/migration/src/m20240101_000001_init.rs @@ -13,10 +13,10 @@ impl MigrationTrait for Migration { .if_not_exists() .col( ColumnDef::new(User::Id) - .integer() + .uuid() .not_null() - .auto_increment() - .primary_key(), + .primary_key() + .default(Expr::cust("gen_random_uuid()")), ) .col( ColumnDef::new(User::Username) diff --git a/thoughts-backend/migration/src/m20250905_000001_init.rs b/thoughts-backend/migration/src/m20250905_000001_init.rs index 1c02790..e6b6ea1 100644 --- a/thoughts-backend/migration/src/m20250905_000001_init.rs +++ b/thoughts-backend/migration/src/m20250905_000001_init.rs @@ -13,8 +13,14 @@ impl MigrationTrait for Migration { Table::create() .table(Thought::Table) .if_not_exists() - .col(pk_auto(Thought::Id)) - .col(integer(Thought::AuthorId).not_null()) + .col( + ColumnDef::new(Thought::Id) + .uuid() + .not_null() + .primary_key() + .default(Expr::cust("gen_random_uuid()")), + ) + .col(uuid(Thought::AuthorId).not_null()) .foreign_key( ForeignKey::create() .name("fk_thought_author_id") @@ -39,8 +45,8 @@ impl MigrationTrait for Migration { Table::create() .table(Follow::Table) .if_not_exists() - .col(integer(Follow::FollowerId).not_null()) - .col(integer(Follow::FollowedId).not_null()) + .col(uuid(Follow::FollowerId).not_null()) + .col(uuid(Follow::FollowedId).not_null()) // Composite Primary Key to ensure a user can only follow another once .primary_key( Index::create() @@ -77,7 +83,7 @@ impl MigrationTrait for Migration { } #[derive(DeriveIden)] -enum Thought { +pub enum Thought { Table, Id, AuthorId, @@ -86,7 +92,7 @@ enum Thought { } #[derive(DeriveIden)] -enum Follow { +pub enum Follow { Table, // The user who is initiating the follow FollowerId, diff --git a/thoughts-backend/migration/src/m20250906_100000_add_profile_fields.rs b/thoughts-backend/migration/src/m20250906_100000_add_profile_fields.rs index 4212796..58749c3 100644 --- a/thoughts-backend/migration/src/m20250906_100000_add_profile_fields.rs +++ b/thoughts-backend/migration/src/m20250906_100000_add_profile_fields.rs @@ -36,8 +36,8 @@ impl MigrationTrait for Migration { Table::create() .table(TopFriends::Table) .if_not_exists() - .col(integer(TopFriends::UserId).not_null()) - .col(integer(TopFriends::FriendId).not_null()) + .col(uuid(TopFriends::UserId).not_null()) + .col(uuid(TopFriends::FriendId).not_null()) .col(small_integer(TopFriends::Position).not_null()) .primary_key( Index::create() diff --git a/thoughts-backend/migration/src/m20250906_130237_add_tags.rs b/thoughts-backend/migration/src/m20250906_130237_add_tags.rs new file mode 100644 index 0000000..99fa2b5 --- /dev/null +++ b/thoughts-backend/migration/src/m20250906_130237_add_tags.rs @@ -0,0 +1,74 @@ +use super::m20250905_000001_init::Thought; +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Tag::Table) + .if_not_exists() + .col(pk_auto(Tag::Id)) + .col(string(Tag::Name).not_null().unique_key()) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(ThoughtTag::Table) + .if_not_exists() + .col(uuid(ThoughtTag::ThoughtId).not_null()) + .col(integer(ThoughtTag::TagId).not_null()) + .primary_key( + Index::create() + .col(ThoughtTag::ThoughtId) + .col(ThoughtTag::TagId), + ) + .foreign_key( + ForeignKey::create() + .name("fk_thought_tag_thought_id") + .from(ThoughtTag::Table, ThoughtTag::ThoughtId) + .to(Thought::Table, Thought::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk_thought_tag_tag_id") + .from(ThoughtTag::Table, ThoughtTag::TagId) + .to(Tag::Table, Tag::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(ThoughtTag::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(Tag::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Tag { + Table, + Id, + Name, +} + +#[derive(DeriveIden)] +enum ThoughtTag { + Table, + ThoughtId, + TagId, +} diff --git a/thoughts-backend/models/Cargo.toml b/thoughts-backend/models/Cargo.toml index 1d70383..383df32 100644 --- a/thoughts-backend/models/Cargo.toml +++ b/thoughts-backend/models/Cargo.toml @@ -17,6 +17,7 @@ sea-orm = { workspace = true, features = [ "runtime-tokio-rustls", "macros", ] } +uuid = { version = "1.18.1", features = ["v4", "serde"] } validator = { workspace = true, features = ["derive"] } utoipa = { workspace = true } diff --git a/thoughts-backend/models/src/domains/follow.rs b/thoughts-backend/models/src/domains/follow.rs index c9a324f..ffbbc8a 100644 --- a/thoughts-backend/models/src/domains/follow.rs +++ b/thoughts-backend/models/src/domains/follow.rs @@ -3,10 +3,10 @@ use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(table_name = "follow")] pub struct Model { - #[sea_orm(primary_key)] - pub follower_id: i32, - #[sea_orm(primary_key)] - pub followed_id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub follower_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub followed_id: Uuid, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] @@ -29,4 +29,10 @@ pub enum Relation { Followed, } +impl Related for Entity { + fn to() -> RelationDef { + Relation::Follower.def() + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/thoughts-backend/models/src/domains/mod.rs b/thoughts-backend/models/src/domains/mod.rs index a034f98..5f3d41f 100644 --- a/thoughts-backend/models/src/domains/mod.rs +++ b/thoughts-backend/models/src/domains/mod.rs @@ -3,5 +3,8 @@ pub mod prelude; pub mod follow; +pub mod tag; pub mod thought; +pub mod thought_tag; +pub mod top_friends; pub mod user; diff --git a/thoughts-backend/models/src/domains/prelude.rs b/thoughts-backend/models/src/domains/prelude.rs index 575c276..21455f6 100644 --- a/thoughts-backend/models/src/domains/prelude.rs +++ b/thoughts-backend/models/src/domains/prelude.rs @@ -1,5 +1,8 @@ //! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0 pub use super::follow::Entity as Follow; +pub use super::tag::Entity as Tag; pub use super::thought::Entity as Thought; +pub use super::thought_tag::Entity as ThoughtTag; +pub use super::top_friends::Entity as TopFriends; pub use super::user::Entity as User; diff --git a/thoughts-backend/models/src/domains/tag.rs b/thoughts-backend/models/src/domains/tag.rs new file mode 100644 index 0000000..2625ee8 --- /dev/null +++ b/thoughts-backend/models/src/domains/tag.rs @@ -0,0 +1,27 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "tag")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + #[sea_orm(unique)] + pub name: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::thought_tag::Entity")] + ThoughtTag, +} + +impl Related for Entity { + fn to() -> RelationDef { + super::thought_tag::Relation::Thought.def() + } + fn via() -> Option { + Some(super::thought_tag::Relation::Tag.def().rev()) + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/thoughts-backend/models/src/domains/thought.rs b/thoughts-backend/models/src/domains/thought.rs index a05bd6a..f8c6d34 100644 --- a/thoughts-backend/models/src/domains/thought.rs +++ b/thoughts-backend/models/src/domains/thought.rs @@ -3,9 +3,9 @@ use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(table_name = "thought")] pub struct Model { - #[sea_orm(primary_key)] - pub id: i32, - pub author_id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub author_id: Uuid, pub content: String, pub created_at: DateTimeWithTimeZone, } @@ -20,6 +20,9 @@ pub enum Relation { on_delete = "Cascade" )] User, + + #[sea_orm(has_many = "super::thought_tag::Entity")] + ThoughtTag, } impl Related for Entity { @@ -28,4 +31,13 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + super::thought_tag::Relation::Tag.def() + } + fn via() -> Option { + Some(super::thought_tag::Relation::Thought.def().rev()) + } +} + impl ActiveModelBehavior for ActiveModel {} diff --git a/thoughts-backend/models/src/domains/thought_tag.rs b/thoughts-backend/models/src/domains/thought_tag.rs new file mode 100644 index 0000000..f84f0a1 --- /dev/null +++ b/thoughts-backend/models/src/domains/thought_tag.rs @@ -0,0 +1,40 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "thought_tag")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub thought_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub tag_id: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::thought::Entity", + from = "Column::ThoughtId", + to = "super::thought::Column::Id" + )] + Thought, + #[sea_orm( + belongs_to = "super::tag::Entity", + from = "Column::TagId", + to = "super::tag::Column::Id" + )] + Tag, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Thought.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Tag.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/thoughts-backend/models/src/domains/top_friends.rs b/thoughts-backend/models/src/domains/top_friends.rs new file mode 100644 index 0000000..4995888 --- /dev/null +++ b/thoughts-backend/models/src/domains/top_friends.rs @@ -0,0 +1,35 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "top_friends")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub user_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub friend_id: Uuid, + pub position: i16, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id" + )] + User, + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::FriendId", + to = "super::user::Column::Id" + )] + Friend, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/thoughts-backend/models/src/domains/user.rs b/thoughts-backend/models/src/domains/user.rs index d1bf36e..799bfbe 100644 --- a/thoughts-backend/models/src/domains/user.rs +++ b/thoughts-backend/models/src/domains/user.rs @@ -5,8 +5,8 @@ use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] #[sea_orm(table_name = "user")] pub struct Model { - #[sea_orm(primary_key)] - pub id: i32, + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, #[sea_orm(unique)] pub username: String, pub password_hash: Option, @@ -22,6 +22,12 @@ pub struct Model { } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} +pub enum Relation { + #[sea_orm(has_many = "super::thought::Entity")] + Thought, + + #[sea_orm(has_many = "super::top_friends::Entity")] + TopFriends, +} impl ActiveModelBehavior for ActiveModel {} diff --git a/thoughts-backend/models/src/schemas/thought.rs b/thoughts-backend/models/src/schemas/thought.rs index c8a1f8e..3081f7e 100644 --- a/thoughts-backend/models/src/schemas/thought.rs +++ b/thoughts-backend/models/src/schemas/thought.rs @@ -3,10 +3,11 @@ use common::DateTimeWithTimeZoneWrapper; use sea_orm::FromQueryResult; use serde::Serialize; use utoipa::ToSchema; +use uuid::Uuid; #[derive(Serialize, ToSchema, FromQueryResult, Debug)] pub struct ThoughtSchema { - pub id: i32, + pub id: Uuid, #[schema(example = "frutiger")] pub author_username: String, #[schema(example = "This is my first thought! #welcome")] @@ -38,10 +39,10 @@ impl From> for ThoughtListSchema { #[derive(Debug, FromQueryResult)] pub struct ThoughtWithAuthor { - pub id: i32, + pub id: Uuid, pub content: String, pub created_at: sea_orm::prelude::DateTimeWithTimeZone, - pub author_id: i32, + pub author_id: Uuid, pub author_username: String, } diff --git a/thoughts-backend/models/src/schemas/user.rs b/thoughts-backend/models/src/schemas/user.rs index d695bad..2bc1c33 100644 --- a/thoughts-backend/models/src/schemas/user.rs +++ b/thoughts-backend/models/src/schemas/user.rs @@ -1,12 +1,13 @@ use common::DateTimeWithTimeZoneWrapper; use serde::Serialize; use utoipa::ToSchema; +use uuid::Uuid; use crate::domains::user; #[derive(Serialize, ToSchema)] pub struct UserSchema { - pub id: i32, + pub id: Uuid, pub username: String, pub display_name: Option, pub bio: Option, diff --git a/thoughts-backend/tests/api/activitypub.rs b/thoughts-backend/tests/api/activitypub.rs index c7584b0..3dc221b 100644 --- a/thoughts-backend/tests/api/activitypub.rs +++ b/thoughts-backend/tests/api/activitypub.rs @@ -64,9 +64,9 @@ async fn test_user_actor_endpoint() { async fn test_user_inbox_follow() { let app = setup().await; // user1 will be followed - create_user_with_password(&app.db, "user1", "password123").await; + let user1 = create_user_with_password(&app.db, "user1", "password123").await; // user2 will be the follower - create_user_with_password(&app.db, "user2", "password123").await; + let user2 = create_user_with_password(&app.db, "user2", "password123").await; // Construct a follow activity from user2, targeting user1 let follow_activity = json!({ @@ -90,16 +90,19 @@ async fn test_user_inbox_follow() { assert_eq!(response.status(), StatusCode::ACCEPTED); // Verify that user2 is now following user1 in the database - let followers = app::persistence::follow::get_followed_ids(&app.db, 2) - .await - .unwrap(); - assert!(followers.contains(&1), "User2 should be following user1"); - - let following = app::persistence::follow::get_followed_ids(&app.db, 1) + let followers = app::persistence::follow::get_followed_ids(&app.db, user2.id) .await .unwrap(); assert!( - !following.contains(&2), + followers.contains(&user1.id), + "User2 should be following user1" + ); + + let following = app::persistence::follow::get_followed_ids(&app.db, user1.id) + .await + .unwrap(); + assert!( + !following.contains(&user2.id), "User1 should now be followed by user2" ); assert!(following.is_empty(), "User1 should not be following anyone"); diff --git a/thoughts-backend/tests/api/auth.rs b/thoughts-backend/tests/api/auth.rs index 1fff320..2e1d767 100644 --- a/thoughts-backend/tests/api/auth.rs +++ b/thoughts-backend/tests/api/auth.rs @@ -20,7 +20,6 @@ async fn test_auth_flow() { let body = response.into_body().collect().await.unwrap().to_bytes(); let v: Value = serde_json::from_slice(&body).unwrap(); assert_eq!(v["username"], "testuser"); - assert!(v["id"].is_number()); let response = make_post_request( app.router.clone(), diff --git a/thoughts-backend/tests/api/main.rs b/thoughts-backend/tests/api/main.rs index b75e6b8..9f778bf 100644 --- a/thoughts-backend/tests/api/main.rs +++ b/thoughts-backend/tests/api/main.rs @@ -1,8 +1,7 @@ use api::setup_router; -use app::persistence::user::create_user; use axum::Router; use http_body_util::BodyExt; -use models::params::{auth::RegisterParams, user::CreateUserParams}; +use models::{domains::user, params::auth::RegisterParams}; use sea_orm::DatabaseConnection; use serde_json::{json, Value}; use utils::testing::{make_post_request, setup_test_db}; @@ -35,25 +34,18 @@ pub async fn setup() -> TestApp { 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(), - password: "password".to_string(), - }; - create_user(db, params) - .await - .expect("Failed to create test user"); -} - -pub async fn create_user_with_password(db: &DatabaseConnection, username: &str, password: &str) { +pub async fn create_user_with_password( + db: &DatabaseConnection, + username: &str, + password: &str, +) -> user::Model { let params = RegisterParams { username: username.to_string(), password: password.to_string(), }; app::persistence::auth::register_user(db, params) .await - .expect("Failed to create test user with password"); + .expect("Failed to create test user with password") } pub async fn login_user(router: Router, username: &str, password: &str) -> String { diff --git a/thoughts-backend/tests/api/mod.rs b/thoughts-backend/tests/api/mod.rs index 7c7b3a8..88b64fc 100644 --- a/thoughts-backend/tests/api/mod.rs +++ b/thoughts-backend/tests/api/mod.rs @@ -3,5 +3,6 @@ mod auth; mod feed; mod follow; mod main; +mod tag; mod thought; mod user; diff --git a/thoughts-backend/tests/api/tag.rs b/thoughts-backend/tests/api/tag.rs new file mode 100644 index 0000000..1b57c4b --- /dev/null +++ b/thoughts-backend/tests/api/tag.rs @@ -0,0 +1,51 @@ +use crate::api::main::{create_user_with_password, login_user, setup}; +use axum::http::StatusCode; +use http_body_util::BodyExt; +use serde_json::{json, Value}; +use utils::testing::{make_get_request, make_jwt_request}; + +#[tokio::test] +async fn test_hashtag_flow() { + let app = setup().await; + let user = create_user_with_password(&app.db, "taguser", "password123").await; + let token = login_user(app.router.clone(), "taguser", "password123").await; + + // 1. Post a thought with hashtags + let body = json!({ "content": "Hello #world this is a post about #RustLang" }).to_string(); + let response = + make_jwt_request(app.router.clone(), "/thoughts", "POST", Some(body), &token).await; + assert_eq!(response.status(), StatusCode::CREATED); + let body_bytes = response.into_body().collect().await.unwrap().to_bytes(); + let thought_json: Value = serde_json::from_slice(&body_bytes).unwrap(); + let thought_id = thought_json["id"].as_str().unwrap(); + + // 2. Post another thought + let body2 = json!({ "content": "Another post about the #rustlang ecosystem" }).to_string(); + make_jwt_request(app.router.clone(), "/thoughts", "POST", Some(body2), &token).await; + + // 3. Fetch thoughts by tag "rustlang" + let response = make_get_request(app.router.clone(), "/tags/rustlang", Some(user.id)).await; + println!("Response: {:?}", response); + assert_eq!(response.status(), StatusCode::OK); + let body_bytes = response.into_body().collect().await.unwrap().to_bytes(); + let v: Value = serde_json::from_slice(&body_bytes).unwrap(); + + let thoughts = v["thoughts"].as_array().unwrap(); + assert_eq!(thoughts.len(), 2); + // Note: The most recent post appears first + assert_eq!( + thoughts[0]["content"], + "Another post about the #rustlang ecosystem" + ); + assert_eq!(thoughts[1]["id"], thought_id); + + // 4. Fetch thoughts by tag "world" + let response = make_get_request(app.router.clone(), "/tags/world", Some(user.id)).await; + assert_eq!(response.status(), StatusCode::OK); + let body_bytes = response.into_body().collect().await.unwrap().to_bytes(); + let v: Value = serde_json::from_slice(&body_bytes).unwrap(); + + let thoughts = v["thoughts"].as_array().unwrap(); + assert_eq!(thoughts.len(), 1); + assert_eq!(thoughts[0]["id"], thought_id); +} diff --git a/thoughts-backend/tests/api/thought.rs b/thoughts-backend/tests/api/thought.rs index 2fd6276..a297096 100644 --- a/thoughts-backend/tests/api/thought.rs +++ b/thoughts-backend/tests/api/thought.rs @@ -1,40 +1,47 @@ -use super::main::{create_test_user, setup}; +use crate::api::main::create_user_with_password; + +use super::main::setup; use axum::http::StatusCode; use http_body_util::BodyExt; +use sea_orm::prelude::Uuid; 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 + let user1 = create_user_with_password(&app.db, "user1", "password123").await; // AuthUser is ID 1 + let _user2 = create_user_with_password(&app.db, "user2", "password123").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, Some(1)).await; + let response = make_post_request(app.router.clone(), "/thoughts", body, Some(user1.id)).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(); + let thought_id = v["id"].as_str().unwrap().to_string(); // 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, Some(1)).await; + let response = make_post_request(app.router.clone(), "/thoughts", body, Some(user1.id)).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"), Some(1)).await; + let response = make_delete_request( + app.router.clone(), + &format!("/thoughts/{}", Uuid::new_v4()), + Some(user1.id), + ) + .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), - Some(1), + Some(user1.id), ) .await; assert_eq!(response.status(), StatusCode::NO_CONTENT); diff --git a/thoughts-backend/tests/api/user.rs b/thoughts-backend/tests/api/user.rs index da7b5b1..192af8b 100644 --- a/thoughts-backend/tests/api/user.rs +++ b/thoughts-backend/tests/api/user.rs @@ -1,10 +1,12 @@ use axum::http::StatusCode; use http_body_util::BodyExt; +use models::domains::top_friends; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use serde_json::{json, Value}; use utils::testing::{make_get_request, make_jwt_request, make_post_request}; -use crate::api::main::{login_user, setup}; +use crate::api::main::{create_user_with_password, login_user, setup}; #[tokio::test] async fn test_post_users() { @@ -18,7 +20,6 @@ async fn test_post_users() { let body = response.into_body().collect().await.unwrap().to_bytes(); let v: Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(v["id"], 1); assert_eq!(v["username"], "test"); assert!(v["display_name"].is_null()); } @@ -54,7 +55,6 @@ pub async fn test_get_users() { assert!(v["users"].is_array()); let users_array = v["users"].as_array().unwrap(); assert_eq!(users_array.len(), 1); - assert_eq!(users_array[0]["id"], 1); assert_eq!(users_array[0]["username"], "test"); } @@ -113,3 +113,73 @@ async fn test_me_endpoints() { assert_eq!(v_verify["display_name"], "Me User"); assert_eq!(v_verify["bio"], "This is my updated bio."); } + +#[tokio::test] +async fn test_update_me_top_friends() { + let app = setup().await; + + // 1. Create users for the test + let user_me = create_user_with_password(&app.db, "me_user", "password123").await; + let friend1 = create_user_with_password(&app.db, "friend1", "password123").await; + let friend2 = create_user_with_password(&app.db, "friend2", "password123").await; + let _friend3 = create_user_with_password(&app.db, "friend3", "password123").await; + + // 2. Log in as "me_user" + let token = login_user(app.router.clone(), "me_user", "password123").await; + + // 3. Update profile to set top friends + let update_body = json!({ + "top_friends": ["friend1", "friend2"] + }) + .to_string(); + + let response = make_jwt_request( + app.router.clone(), + "/users/me", + "PUT", + Some(update_body), + &token, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + + // 4. Verify the database state directly + let top_friends_list = top_friends::Entity::find() + .filter(top_friends::Column::UserId.eq(user_me.id)) + .all(&app.db) + .await + .unwrap(); + + assert_eq!(top_friends_list.len(), 2); + assert_eq!(top_friends_list[0].friend_id, friend1.id); + assert_eq!(top_friends_list[0].position, 1); + assert_eq!(top_friends_list[1].friend_id, friend2.id); + assert_eq!(top_friends_list[1].position, 2); + + // 5. Update again with a different list to test replacement + let update_body_2 = json!({ + "top_friends": ["friend2"] + }) + .to_string(); + + let response = make_jwt_request( + app.router.clone(), + "/users/me", + "PUT", + Some(update_body_2), + &token, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + + // 6. Verify the new state + let top_friends_list_2 = top_friends::Entity::find() + .filter(top_friends::Column::UserId.eq(user_me.id)) + .all(&app.db) + .await + .unwrap(); + + assert_eq!(top_friends_list_2.len(), 1); + assert_eq!(top_friends_list_2[0].friend_id, friend2.id); + assert_eq!(top_friends_list_2[0].position, 1); +} diff --git a/thoughts-backend/tests/app/persistence/user.rs b/thoughts-backend/tests/app/persistence/user.rs index 052548a..034af34 100644 --- a/thoughts-backend/tests/app/persistence/user.rs +++ b/thoughts-backend/tests/app/persistence/user.rs @@ -14,6 +14,5 @@ pub(super) async fn test_user(db: &DatabaseConnection) { .try_into_model() // Convert ActiveModel to Model for easier checks .unwrap(); - assert_eq!(user_model.id, 1); assert_eq!(user_model.username, "test"); } diff --git a/thoughts-backend/utils/Cargo.toml b/thoughts-backend/utils/Cargo.toml index be6a027..041c4c9 100644 --- a/thoughts-backend/utils/Cargo.toml +++ b/thoughts-backend/utils/Cargo.toml @@ -10,7 +10,7 @@ path = "src/lib.rs" [dependencies] migration = { path = "../migration" } -uuid = { version = "1.18.1", features = ["v4"] } +uuid = { version = "1.18.1", features = ["v4", "serde"] } sea-orm = { version = "1.1.12", features = ["sqlx-sqlite", "sqlx-postgres"] } axum = { workspace = true } diff --git a/thoughts-backend/utils/src/testing/api/mod.rs b/thoughts-backend/utils/src/testing/api/mod.rs index 9adb476..0333a97 100644 --- a/thoughts-backend/utils/src/testing/api/mod.rs +++ b/thoughts-backend/utils/src/testing/api/mod.rs @@ -5,8 +5,9 @@ use axum::{ Router, }; use tower::ServiceExt; +use uuid::Uuid; -pub async fn make_get_request(app: Router, url: &str, user_id: Option) -> Response { +pub async fn make_get_request(app: Router, url: &str, user_id: Option) -> Response { let mut builder = Request::builder() .uri(url) .header("Content-Type", "application/json"); @@ -24,7 +25,7 @@ pub async fn make_post_request( app: Router, url: &str, body: String, - user_id: Option, + user_id: Option, ) -> Response { let mut builder = Request::builder() .method("POST") @@ -40,7 +41,7 @@ pub async fn make_post_request( .unwrap() } -pub async fn make_delete_request(app: Router, url: &str, user_id: Option) -> Response { +pub async fn make_delete_request(app: Router, url: &str, user_id: Option) -> Response { let mut builder = Request::builder() .method("DELETE") .uri(url)