From 728bf0e23177aa28820ee32a2cda5b54727b4c88 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 6 Sep 2025 16:49:38 +0200 Subject: [PATCH] feat: enhance user registration and follow functionality, add popular tags endpoint, and update tests --- thoughts-backend/Cargo.lock | 1 + thoughts-backend/api/src/routers/feed.rs | 6 +-- thoughts-backend/api/src/routers/tag.rs | 23 ++++++++-- thoughts-backend/api/src/routers/user.rs | 11 ++++- thoughts-backend/app/Cargo.toml | 1 + thoughts-backend/app/src/persistence/auth.rs | 5 ++- .../app/src/persistence/follow.rs | 20 ++++----- thoughts-backend/app/src/persistence/tag.rs | 37 ++++++++++++++- .../app/src/persistence/thought.rs | 6 +-- thoughts-backend/app/src/persistence/user.rs | 12 ++++- .../migration/src/m20250905_000001_init.rs | 10 ++--- thoughts-backend/models/src/domains/follow.rs | 6 +-- thoughts-backend/models/src/params/auth.rs | 2 + thoughts-backend/models/src/schemas/user.rs | 21 +++++++-- thoughts-backend/tests/api/activitypub.rs | 16 ++++--- thoughts-backend/tests/api/api_key.rs | 8 +++- thoughts-backend/tests/api/auth.rs | 3 ++ thoughts-backend/tests/api/feed.rs | 6 +-- thoughts-backend/tests/api/follow.rs | 4 +- thoughts-backend/tests/api/main.rs | 2 + thoughts-backend/tests/api/tag.rs | 45 ++++++++++++++++++- thoughts-backend/tests/api/thought.rs | 6 ++- thoughts-backend/tests/api/user.rs | 29 +++++++----- 23 files changed, 216 insertions(+), 64 deletions(-) diff --git a/thoughts-backend/Cargo.lock b/thoughts-backend/Cargo.lock index 0a7889f..22d5602 100644 --- a/thoughts-backend/Cargo.lock +++ b/thoughts-backend/Cargo.lock @@ -357,6 +357,7 @@ name = "app" version = "0.1.0" dependencies = [ "bcrypt", + "chrono", "models", "rand 0.8.5", "sea-orm", diff --git a/thoughts-backend/api/src/routers/feed.rs b/thoughts-backend/api/src/routers/feed.rs index d06ec58..a92d03a 100644 --- a/thoughts-backend/api/src/routers/feed.rs +++ b/thoughts-backend/api/src/routers/feed.rs @@ -1,7 +1,7 @@ use axum::{extract::State, response::IntoResponse, routing::get, Json, Router}; use app::{ - persistence::{follow::get_followed_ids, thought::get_feed_for_user}, + persistence::{follow::get_following_ids, thought::get_feed_for_user}, state::AppState, }; use models::schemas::thought::{ThoughtListSchema, ThoughtSchema}; @@ -23,8 +23,8 @@ async fn feed_get( State(state): State, auth_user: AuthUser, ) -> Result { - let followed_ids = get_followed_ids(&state.conn, auth_user.id).await?; - let mut thoughts_with_authors = get_feed_for_user(&state.conn, followed_ids).await?; + let following_ids = get_following_ids(&state.conn, auth_user.id).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?; thoughts_with_authors.extend(own_thoughts); diff --git a/thoughts-backend/api/src/routers/tag.rs b/thoughts-backend/api/src/routers/tag.rs index c3d7db6..2f47f16 100644 --- a/thoughts-backend/api/src/routers/tag.rs +++ b/thoughts-backend/api/src/routers/tag.rs @@ -1,5 +1,8 @@ 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::{ extract::{Path, State}, response::IntoResponse, @@ -27,6 +30,20 @@ async fn get_thoughts_by_tag( Ok(Json(ThoughtListSchema::from(thoughts_schema))) } -pub fn create_tag_router() -> Router { - Router::new().route("/{tag_name}", get(get_thoughts_by_tag)) +#[utoipa::path( + get, + path = "/popular", + responses((status = 200, description = "List of popular tags", body = Vec)) +)] +async fn get_popular_tags(State(state): State) -> Result { + 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 { + Router::new() + .route("/{tag_name}", get(get_thoughts_by_tag)) + .route("/popular", get(get_popular_tags)) } diff --git a/thoughts-backend/api/src/routers/user.rs b/thoughts-backend/api/src/routers/user.rs index a471a0e..81b560d 100644 --- a/thoughts-backend/api/src/routers/user.rs +++ b/thoughts-backend/api/src/routers/user.rs @@ -248,7 +248,12 @@ async fn get_user_by_param( } } else { 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(), Err(e) => ApiError::from(e).into_response(), } @@ -332,7 +337,9 @@ async fn get_me( let user = get_user(&state.conn, auth_user.id) .await? .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( diff --git a/thoughts-backend/app/Cargo.toml b/thoughts-backend/app/Cargo.toml index 329c6f6..09e0daa 100644 --- a/thoughts-backend/app/Cargo.toml +++ b/thoughts-backend/app/Cargo.toml @@ -14,3 +14,4 @@ models = { path = "../models" } validator = "0.20" rand = "0.8.5" sea-orm = { version = "1.1.12" } +chrono = { workspace = true } diff --git a/thoughts-backend/app/src/persistence/auth.rs b/thoughts-backend/app/src/persistence/auth.rs index 7d095d0..dad2716 100644 --- a/thoughts-backend/app/src/persistence/auth.rs +++ b/thoughts-backend/app/src/persistence/auth.rs @@ -13,7 +13,6 @@ fn hash_password(password: &str) -> Result { } pub async fn register_user(db: &DbConn, params: RegisterParams) -> Result { - // Validate the parameters params .validate() .map_err(|e| UserError::Validation(e.to_string()))?; @@ -22,8 +21,10 @@ pub async fn register_user(db: &DbConn, params: RegisterParams) -> Result Result<(), UserError> { let follower_username = follower_actor_id @@ -20,21 +20,21 @@ pub async fn add_follower( .map_err(|e| UserError::Internal(e.to_string()))? .ok_or(UserError::NotFound)?; - follow_user(db, follower.id, followed_id) + follow_user(db, follower.id, following_id) .await .map_err(|e| UserError::Internal(e.to_string()))?; Ok(()) } -pub async fn follow_user(db: &DbConn, follower_id: Uuid, followed_id: Uuid) -> Result<(), DbErr> { - if follower_id == followed_id { +pub async fn follow_user(db: &DbConn, follower_id: Uuid, following_id: Uuid) -> Result<(), DbErr> { + if follower_id == following_id { return Err(DbErr::Custom("Users cannot follow themselves".to_string())); } let follow = follow::ActiveModel { follower_id: Set(follower_id), - followed_id: Set(followed_id), + following_id: Set(following_id), }; 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( db: &DbConn, follower_id: Uuid, - followed_id: Uuid, + following_id: Uuid, ) -> Result<(), UserError> { let deleted_result = follow::Entity::delete_many() .filter(follow::Column::FollowerId.eq(follower_id)) - .filter(follow::Column::FollowedId.eq(followed_id)) + .filter(follow::Column::FollowingId.eq(following_id)) .exec(db) .await .map_err(|e| UserError::Internal(e.to_string()))?; @@ -60,18 +60,18 @@ pub async fn unfollow_user( Ok(()) } -pub async fn get_followed_ids(db: &DbConn, user_id: Uuid) -> Result, DbErr> { +pub async fn get_following_ids(db: &DbConn, user_id: Uuid) -> Result, 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()) + Ok(followed_users.into_iter().map(|f| f.following_id).collect()) } 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)) + .filter(follow::Column::FollowingId.eq(user_id)) .all(db) .await?; Ok(followers.into_iter().map(|f| f.follower_id).collect()) diff --git a/thoughts-backend/app/src/persistence/tag.rs b/thoughts-backend/app/src/persistence/tag.rs index eb13737..ddd92c0 100644 --- a/thoughts-backend/app/src/persistence/tag.rs +++ b/thoughts-backend/app/src/persistence/tag.rs @@ -1,6 +1,8 @@ -use models::domains::{tag, thought_tag}; +use chrono::{Duration, Utc}; +use models::domains::{tag, thought, thought_tag}; 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; @@ -84,3 +86,34 @@ where thought_tag::Entity::insert_many(links).exec(db).await?; Ok(()) } + +pub async fn get_popular_tags(db: &C) -> Result, 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()) +} diff --git a/thoughts-backend/app/src/persistence/thought.rs b/thoughts-backend/app/src/persistence/thought.rs index 138b80e..ec1c400 100644 --- a/thoughts-backend/app/src/persistence/thought.rs +++ b/thoughts-backend/app/src/persistence/thought.rs @@ -69,9 +69,9 @@ pub async fn get_thoughts_by_user( pub async fn get_feed_for_user( db: &DbConn, - followed_ids: Vec, + following_ids: Vec, ) -> Result, UserError> { - if followed_ids.is_empty() { + if following_ids.is_empty() { return Ok(vec![]); } @@ -83,7 +83,7 @@ pub async fn get_feed_for_user( .column(thought::Column::AuthorId) .column_as(user::Column::Username, "author_username") .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) .into_model::() .all(db) diff --git a/thoughts-backend/app/src/persistence/user.rs b/thoughts-backend/app/src/persistence/user.rs index a7eec49..ce3e166 100644 --- a/thoughts-backend/app/src/persistence/user.rs +++ b/thoughts-backend/app/src/persistence/user.rs @@ -1,6 +1,7 @@ use sea_orm::prelude::Uuid; 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}; @@ -127,3 +128,12 @@ pub async fn update_user_profile( .await .map_err(|e| UserError::Internal(e.to_string())) } + +pub async fn get_top_friends(db: &DbConn, user_id: Uuid) -> Result, 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 +} diff --git a/thoughts-backend/migration/src/m20250905_000001_init.rs b/thoughts-backend/migration/src/m20250905_000001_init.rs index e6b6ea1..c31470b 100644 --- a/thoughts-backend/migration/src/m20250905_000001_init.rs +++ b/thoughts-backend/migration/src/m20250905_000001_init.rs @@ -46,12 +46,12 @@ impl MigrationTrait for Migration { .table(Follow::Table) .if_not_exists() .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 .primary_key( Index::create() .col(Follow::FollowerId) - .col(Follow::FollowedId), + .col(Follow::FollowingId), ) .foreign_key( ForeignKey::create() @@ -62,8 +62,8 @@ impl MigrationTrait for Migration { ) .foreign_key( ForeignKey::create() - .name("fk_follow_followed_id") - .from(Follow::Table, Follow::FollowedId) + .name("fk_follow_following_id") + .from(Follow::Table, Follow::FollowingId) .to(User::Table, User::Id) .on_delete(ForeignKeyAction::Cascade), ) @@ -97,5 +97,5 @@ pub enum Follow { // The user who is initiating the follow FollowerId, // The user who is being followed - FollowedId, + FollowingId, } diff --git a/thoughts-backend/models/src/domains/follow.rs b/thoughts-backend/models/src/domains/follow.rs index ffbbc8a..99fb41a 100644 --- a/thoughts-backend/models/src/domains/follow.rs +++ b/thoughts-backend/models/src/domains/follow.rs @@ -6,7 +6,7 @@ pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub follower_id: Uuid, #[sea_orm(primary_key, auto_increment = false)] - pub followed_id: Uuid, + pub following_id: Uuid, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] @@ -21,12 +21,12 @@ pub enum Relation { Follower, #[sea_orm( belongs_to = "super::user::Entity", - from = "Column::FollowedId", + from = "Column::FollowingId", to = "super::user::Column::Id", on_update = "NoAction", on_delete = "Cascade" )] - Followed, + Following, } impl Related for Entity { diff --git a/thoughts-backend/models/src/params/auth.rs b/thoughts-backend/models/src/params/auth.rs index 857c050..56694b3 100644 --- a/thoughts-backend/models/src/params/auth.rs +++ b/thoughts-backend/models/src/params/auth.rs @@ -6,6 +6,8 @@ use validator::Validate; pub struct RegisterParams { #[validate(length(min = 3))] pub username: String, + #[validate(email)] + pub email: String, #[validate(length(min = 6))] pub password: String, } diff --git a/thoughts-backend/models/src/schemas/user.rs b/thoughts-backend/models/src/schemas/user.rs index 2bc1c33..fc862a5 100644 --- a/thoughts-backend/models/src/schemas/user.rs +++ b/thoughts-backend/models/src/schemas/user.rs @@ -14,12 +14,26 @@ pub struct UserSchema { pub avatar_url: Option, pub header_url: Option, pub custom_css: Option, - // In a real implementation, you'd fetch and return this data. - // For now, we'll omit it from the schema to keep it simple. - // pub top_friends: Vec, + pub top_friends: Vec, pub joined_at: DateTimeWithTimeZoneWrapper, } +impl From<(user::Model, Vec)> for UserSchema { + fn from((user, top_friends): (user::Model, Vec)) -> 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 for UserSchema { fn from(user: user::Model) -> Self { Self { @@ -30,6 +44,7 @@ impl From for UserSchema { avatar_url: user.avatar_url, header_url: user.header_url, custom_css: user.custom_css, + top_friends: vec![], // Defaults to an empty list joined_at: user.created_at.into(), } } diff --git a/thoughts-backend/tests/api/activitypub.rs b/thoughts-backend/tests/api/activitypub.rs index 3dc221b..1325bb2 100644 --- a/thoughts-backend/tests/api/activitypub.rs +++ b/thoughts-backend/tests/api/activitypub.rs @@ -9,7 +9,7 @@ use utils::testing::{ #[tokio::test] async fn test_webfinger_discovery() { 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 let url = "/.well-known/webfinger?resource=acct:testuser@localhost:3000"; @@ -36,7 +36,7 @@ async fn test_webfinger_discovery() { #[tokio::test] async fn test_user_actor_endpoint() { 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( app.router.clone(), @@ -64,9 +64,11 @@ async fn test_user_actor_endpoint() { async fn test_user_inbox_follow() { let app = setup().await; // 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 - 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 let follow_activity = json!({ @@ -90,7 +92,7 @@ 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, user2.id) + let followers = app::persistence::follow::get_following_ids(&app.db, user2.id) .await .unwrap(); assert!( @@ -98,7 +100,7 @@ async fn test_user_inbox_follow() { "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 .unwrap(); assert!( @@ -111,7 +113,7 @@ async fn test_user_inbox_follow() { #[tokio::test] async fn test_user_outbox_get() { 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; // Create a thought first diff --git a/thoughts-backend/tests/api/api_key.rs b/thoughts-backend/tests/api/api_key.rs index 60e0f34..05a1380 100644 --- a/thoughts-backend/tests/api/api_key.rs +++ b/thoughts-backend/tests/api/api_key.rs @@ -7,7 +7,13 @@ use utils::testing::{make_jwt_request, make_request_with_headers}; #[tokio::test] async fn test_api_key_flow() { 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; // 1. Create a new API key using JWT auth diff --git a/thoughts-backend/tests/api/auth.rs b/thoughts-backend/tests/api/auth.rs index 2e1d767..aeec01b 100644 --- a/thoughts-backend/tests/api/auth.rs +++ b/thoughts-backend/tests/api/auth.rs @@ -11,6 +11,7 @@ async fn test_auth_flow() { let register_body = json!({ "username": "testuser", + "email": "testuser@example.com", "password": "password123" }) .to_string(); @@ -26,6 +27,7 @@ async fn test_auth_flow() { "/auth/register", json!({ "username": "testuser", + "email": "testuser@example.com", "password": "password456" }) .to_string(), @@ -48,6 +50,7 @@ async fn test_auth_flow() { let bad_login_body = json!({ "username": "testuser", + "email": "testuser@example.com", "password": "wrongpassword" }) .to_string(); diff --git a/thoughts-backend/tests/api/feed.rs b/thoughts-backend/tests/api/feed.rs index d2364a0..234d251 100644 --- a/thoughts-backend/tests/api/feed.rs +++ b/thoughts-backend/tests/api/feed.rs @@ -7,9 +7,9 @@ use utils::testing::make_jwt_request; #[tokio::test] async fn test_feed_and_user_thoughts() { let app = setup().await; - create_user_with_password(&app.db, "user1", "password1").await; - create_user_with_password(&app.db, "user2", "password2").await; - create_user_with_password(&app.db, "user3", "password3").await; + create_user_with_password(&app.db, "user1", "password1", "user1@example.com").await; + create_user_with_password(&app.db, "user2", "password2", "user2@example.com").await; + create_user_with_password(&app.db, "user3", "password3", "user3@example.com").await; // As user1, post a thought let token = super::main::login_user(app.router.clone(), "user1", "password1").await; diff --git a/thoughts-backend/tests/api/follow.rs b/thoughts-backend/tests/api/follow.rs index fd46d1a..696830f 100644 --- a/thoughts-backend/tests/api/follow.rs +++ b/thoughts-backend/tests/api/follow.rs @@ -7,8 +7,8 @@ async fn test_follow_endpoints() { std::env::set_var("AUTH_SECRET", "test-secret"); let app = setup().await; - create_user_with_password(&app.db, "user1", "password1").await; - create_user_with_password(&app.db, "user2", "password2").await; + create_user_with_password(&app.db, "user1", "password1", "user1@example.com").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; diff --git a/thoughts-backend/tests/api/main.rs b/thoughts-backend/tests/api/main.rs index 9f778bf..79bf5d9 100644 --- a/thoughts-backend/tests/api/main.rs +++ b/thoughts-backend/tests/api/main.rs @@ -38,10 +38,12 @@ pub async fn create_user_with_password( db: &DatabaseConnection, username: &str, password: &str, + email: &str, ) -> user::Model { let params = RegisterParams { username: username.to_string(), password: password.to_string(), + email: email.to_string(), }; app::persistence::auth::register_user(db, params) .await diff --git a/thoughts-backend/tests/api/tag.rs b/thoughts-backend/tests/api/tag.rs index 68ee392..e68bcd2 100644 --- a/thoughts-backend/tests/api/tag.rs +++ b/thoughts-backend/tests/api/tag.rs @@ -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 http_body_util::BodyExt; use serde_json::{json, Value}; @@ -7,7 +7,8 @@ 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 user = + create_user_with_password(&app.db, "taguser", "password123", "taguser@example.com").await; let token = login_user(app.router.clone(), "taguser", "password123").await; // 1. Post a thought with hashtags @@ -48,3 +49,43 @@ async fn test_hashtag_flow() { assert_eq!(thoughts.len(), 1); 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 = 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" + ); +} diff --git a/thoughts-backend/tests/api/thought.rs b/thoughts-backend/tests/api/thought.rs index a297096..7e16981 100644 --- a/thoughts-backend/tests/api/thought.rs +++ b/thoughts-backend/tests/api/thought.rs @@ -10,8 +10,10 @@ use utils::testing::{make_delete_request, make_post_request}; #[tokio::test] async fn test_thought_endpoints() { let app = setup().await; - 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 + let user1 = + 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 let body = json!({ "content": "My first thought!" }).to_string(); diff --git a/thoughts-backend/tests/api/user.rs b/thoughts-backend/tests/api/user.rs index 321015c..d489d4f 100644 --- a/thoughts-backend/tests/api/user.rs +++ b/thoughts-backend/tests/api/user.rs @@ -12,7 +12,8 @@ use crate::api::main::{create_user_with_password, login_user, setup}; async fn test_post_users() { 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; assert_eq!(response.status(), StatusCode::CREATED); @@ -21,14 +22,15 @@ async fn test_post_users() { let v: Value = serde_json::from_slice(&body).unwrap(); assert_eq!(v["username"], "test"); - assert!(v["display_name"].is_null()); + assert!(v["display_name"].is_string()); } #[tokio::test] pub(super) async fn test_post_users_error() { 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; 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() { 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; let response = make_get_request(app.router, "/users", None).await; @@ -65,6 +68,7 @@ async fn test_me_endpoints() { // 1. Register a new user let register_body = json!({ "username": "me_user", + "email": "me_user@example.com", "password": "password123" }) .to_string(); @@ -82,7 +86,7 @@ async fn test_me_endpoints() { let v: Value = serde_json::from_slice(&body).unwrap(); assert_eq!(v["username"], "me_user"); 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 let update_body = json!({ @@ -119,10 +123,14 @@ 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; + let user_me = + create_user_with_password(&app.db, "me_user", "password123", "me_user@example.com").await; + let friend1 = + 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" 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; // 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; // 2. Attempt to update with an invalid avatar URL