From 0abd27594604bf3299945d59b826bf6c31de1adb Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sat, 6 Sep 2025 16:58:11 +0200 Subject: [PATCH] feat: add reply functionality to thoughts, including database migration and tests --- .../app/src/persistence/thought.rs | 4 ++ thoughts-backend/migration/src/lib.rs | 4 ++ .../m20250906_145148_add_reply_to_thoughts.rs | 46 +++++++++++++++++++ .../models/src/domains/thought.rs | 1 + thoughts-backend/models/src/params/thought.rs | 3 ++ .../models/src/schemas/thought.rs | 4 ++ thoughts-backend/tests/api/thought.rs | 35 +++++++++++++- 7 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 thoughts-backend/migration/src/m20250906_145148_add_reply_to_thoughts.rs diff --git a/thoughts-backend/app/src/persistence/thought.rs b/thoughts-backend/app/src/persistence/thought.rs index ec1c400..84b0e35 100644 --- a/thoughts-backend/app/src/persistence/thought.rs +++ b/thoughts-backend/app/src/persistence/thought.rs @@ -24,6 +24,7 @@ pub async fn create_thought( let new_thought = thought::ActiveModel { author_id: Set(author_id), content: Set(params.content.clone()), + reply_to_id: Set(params.reply_to_id), ..Default::default() } .insert(&txn) @@ -56,6 +57,7 @@ pub async fn get_thoughts_by_user( .select_only() .column(thought::Column::Id) .column(thought::Column::Content) + .column(thought::Column::ReplyToId) .column(thought::Column::CreatedAt) .column(thought::Column::AuthorId) .column_as(user::Column::Username, "author_username") @@ -79,6 +81,7 @@ pub async fn get_feed_for_user( .select_only() .column(thought::Column::Id) .column(thought::Column::Content) + .column(thought::Column::ReplyToId) .column(thought::Column::CreatedAt) .column(thought::Column::AuthorId) .column_as(user::Column::Username, "author_username") @@ -99,6 +102,7 @@ pub async fn get_thoughts_by_tag_name( .select_only() .column(thought::Column::Id) .column(thought::Column::Content) + .column(thought::Column::ReplyToId) .column(thought::Column::CreatedAt) .column(thought::Column::AuthorId) .column_as(user::Column::Username, "author_username") diff --git a/thoughts-backend/migration/src/lib.rs b/thoughts-backend/migration/src/lib.rs index 05af7a4..0d9eb93 100644 --- a/thoughts-backend/migration/src/lib.rs +++ b/thoughts-backend/migration/src/lib.rs @@ -5,6 +5,8 @@ mod m20250905_000001_init; mod m20250906_100000_add_profile_fields; mod m20250906_130237_add_tags; mod m20250906_134056_add_api_keys; +mod m20250906_145148_add_reply_to_thoughts; +mod m20250906_145755_add_visibility_to_thoughts; pub struct Migrator; @@ -17,6 +19,8 @@ impl MigratorTrait for Migrator { Box::new(m20250906_100000_add_profile_fields::Migration), Box::new(m20250906_130237_add_tags::Migration), Box::new(m20250906_134056_add_api_keys::Migration), + Box::new(m20250906_145148_add_reply_to_thoughts::Migration), + Box::new(m20250906_145755_add_visibility_to_thoughts::Migration), ] } } diff --git a/thoughts-backend/migration/src/m20250906_145148_add_reply_to_thoughts.rs b/thoughts-backend/migration/src/m20250906_145148_add_reply_to_thoughts.rs new file mode 100644 index 0000000..fe92835 --- /dev/null +++ b/thoughts-backend/migration/src/m20250906_145148_add_reply_to_thoughts.rs @@ -0,0 +1,46 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +use crate::m20250905_000001_init::Thought; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Thought::Table) + .add_column(uuid_null(ThoughtExtension::ReplyToId)) + .add_foreign_key( + TableForeignKey::new() + .name("fk_thought_reply_to_id") + .from_tbl(Thought::Table) + .from_col(ThoughtExtension::ReplyToId) + .to_tbl(Thought::Table) + .to_col(Thought::Id) + .on_delete(ForeignKeyAction::SetNull), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Thought::Table) + .drop_foreign_key(Alias::new("fk_thought_reply_to_id")) + .drop_column(ThoughtExtension::ReplyToId) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum ThoughtExtension { + ReplyToId, +} diff --git a/thoughts-backend/models/src/domains/thought.rs b/thoughts-backend/models/src/domains/thought.rs index f8c6d34..f0c9f84 100644 --- a/thoughts-backend/models/src/domains/thought.rs +++ b/thoughts-backend/models/src/domains/thought.rs @@ -7,6 +7,7 @@ pub struct Model { pub id: Uuid, pub author_id: Uuid, pub content: String, + pub reply_to_id: Option, pub created_at: DateTimeWithTimeZone, } diff --git a/thoughts-backend/models/src/params/thought.rs b/thoughts-backend/models/src/params/thought.rs index e1c0953..495f9e0 100644 --- a/thoughts-backend/models/src/params/thought.rs +++ b/thoughts-backend/models/src/params/thought.rs @@ -1,5 +1,6 @@ use serde::Deserialize; use utoipa::ToSchema; +use uuid::Uuid; use validator::Validate; #[derive(Deserialize, Validate, ToSchema)] @@ -10,4 +11,6 @@ pub struct CreateThoughtParams { message = "Content must be between 1 and 128 characters" ))] pub content: String, + + pub reply_to_id: Option, } diff --git a/thoughts-backend/models/src/schemas/thought.rs b/thoughts-backend/models/src/schemas/thought.rs index 3081f7e..e8c74ca 100644 --- a/thoughts-backend/models/src/schemas/thought.rs +++ b/thoughts-backend/models/src/schemas/thought.rs @@ -12,6 +12,7 @@ pub struct ThoughtSchema { pub author_username: String, #[schema(example = "This is my first thought! #welcome")] pub content: String, + pub reply_to_id: Option, pub created_at: DateTimeWithTimeZoneWrapper, } @@ -21,6 +22,7 @@ impl ThoughtSchema { id: thought.id, author_username: author.username.clone(), content: thought.content.clone(), + reply_to_id: thought.reply_to_id, created_at: thought.created_at.into(), } } @@ -44,6 +46,7 @@ pub struct ThoughtWithAuthor { pub created_at: sea_orm::prelude::DateTimeWithTimeZone, pub author_id: Uuid, pub author_username: String, + pub reply_to_id: Option, } impl From for ThoughtSchema { @@ -53,6 +56,7 @@ impl From for ThoughtSchema { author_username: model.author_username, content: model.content, created_at: model.created_at.into(), + reply_to_id: model.reply_to_id, } } } diff --git a/thoughts-backend/tests/api/thought.rs b/thoughts-backend/tests/api/thought.rs index 7e16981..f22fa57 100644 --- a/thoughts-backend/tests/api/thought.rs +++ b/thoughts-backend/tests/api/thought.rs @@ -4,7 +4,7 @@ use super::main::setup; use axum::http::StatusCode; use http_body_util::BodyExt; use sea_orm::prelude::Uuid; -use serde_json::json; +use serde_json::{json, Value}; use utils::testing::{make_delete_request, make_post_request}; #[tokio::test] @@ -48,3 +48,36 @@ async fn test_thought_endpoints() { .await; assert_eq!(response.status(), StatusCode::NO_CONTENT); } + +#[tokio::test] +async fn test_thought_replies() { + let app = setup().await; + let user1 = + create_user_with_password(&app.db, "user1", "password123", "user1@example.com").await; + let user2 = + create_user_with_password(&app.db, "user2", "password123", "user2@example.com").await; + + // 1. User 1 posts an original thought + let body = json!({ "content": "This is the original post!" }).to_string(); + 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 original_thought: Value = serde_json::from_slice(&body).unwrap(); + let original_thought_id = original_thought["id"].as_str().unwrap(); + + // 2. User 2 replies to the original thought + let reply_body = json!({ + "content": "This is a reply.", + "reply_to_id": original_thought_id + }) + .to_string(); + let response = + make_post_request(app.router.clone(), "/thoughts", reply_body, Some(user2.id)).await; + assert_eq!(response.status(), StatusCode::CREATED); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let reply_thought: Value = serde_json::from_slice(&body).unwrap(); + + // 3. Verify the reply is linked correctly + assert_eq!(reply_thought["reply_to_id"], original_thought_id); + assert_eq!(reply_thought["author_username"], "user2"); +}