feat: add reply functionality to thoughts, including database migration and tests
This commit is contained in:
@@ -24,6 +24,7 @@ pub async fn create_thought(
|
|||||||
let new_thought = thought::ActiveModel {
|
let new_thought = thought::ActiveModel {
|
||||||
author_id: Set(author_id),
|
author_id: Set(author_id),
|
||||||
content: Set(params.content.clone()),
|
content: Set(params.content.clone()),
|
||||||
|
reply_to_id: Set(params.reply_to_id),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.insert(&txn)
|
.insert(&txn)
|
||||||
@@ -56,6 +57,7 @@ pub async fn get_thoughts_by_user(
|
|||||||
.select_only()
|
.select_only()
|
||||||
.column(thought::Column::Id)
|
.column(thought::Column::Id)
|
||||||
.column(thought::Column::Content)
|
.column(thought::Column::Content)
|
||||||
|
.column(thought::Column::ReplyToId)
|
||||||
.column(thought::Column::CreatedAt)
|
.column(thought::Column::CreatedAt)
|
||||||
.column(thought::Column::AuthorId)
|
.column(thought::Column::AuthorId)
|
||||||
.column_as(user::Column::Username, "author_username")
|
.column_as(user::Column::Username, "author_username")
|
||||||
@@ -79,6 +81,7 @@ pub async fn get_feed_for_user(
|
|||||||
.select_only()
|
.select_only()
|
||||||
.column(thought::Column::Id)
|
.column(thought::Column::Id)
|
||||||
.column(thought::Column::Content)
|
.column(thought::Column::Content)
|
||||||
|
.column(thought::Column::ReplyToId)
|
||||||
.column(thought::Column::CreatedAt)
|
.column(thought::Column::CreatedAt)
|
||||||
.column(thought::Column::AuthorId)
|
.column(thought::Column::AuthorId)
|
||||||
.column_as(user::Column::Username, "author_username")
|
.column_as(user::Column::Username, "author_username")
|
||||||
@@ -99,6 +102,7 @@ pub async fn get_thoughts_by_tag_name(
|
|||||||
.select_only()
|
.select_only()
|
||||||
.column(thought::Column::Id)
|
.column(thought::Column::Id)
|
||||||
.column(thought::Column::Content)
|
.column(thought::Column::Content)
|
||||||
|
.column(thought::Column::ReplyToId)
|
||||||
.column(thought::Column::CreatedAt)
|
.column(thought::Column::CreatedAt)
|
||||||
.column(thought::Column::AuthorId)
|
.column(thought::Column::AuthorId)
|
||||||
.column_as(user::Column::Username, "author_username")
|
.column_as(user::Column::Username, "author_username")
|
||||||
|
@@ -5,6 +5,8 @@ mod m20250905_000001_init;
|
|||||||
mod m20250906_100000_add_profile_fields;
|
mod m20250906_100000_add_profile_fields;
|
||||||
mod m20250906_130237_add_tags;
|
mod m20250906_130237_add_tags;
|
||||||
mod m20250906_134056_add_api_keys;
|
mod m20250906_134056_add_api_keys;
|
||||||
|
mod m20250906_145148_add_reply_to_thoughts;
|
||||||
|
mod m20250906_145755_add_visibility_to_thoughts;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -17,6 +19,8 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20250906_100000_add_profile_fields::Migration),
|
Box::new(m20250906_100000_add_profile_fields::Migration),
|
||||||
Box::new(m20250906_130237_add_tags::Migration),
|
Box::new(m20250906_130237_add_tags::Migration),
|
||||||
Box::new(m20250906_134056_add_api_keys::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),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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,
|
||||||
|
}
|
@@ -7,6 +7,7 @@ pub struct Model {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub author_id: Uuid,
|
pub author_id: Uuid,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
pub reply_to_id: Option<Uuid>,
|
||||||
pub created_at: DateTimeWithTimeZone,
|
pub created_at: DateTimeWithTimeZone,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,5 +1,6 @@
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
use validator::Validate;
|
use validator::Validate;
|
||||||
|
|
||||||
#[derive(Deserialize, Validate, ToSchema)]
|
#[derive(Deserialize, Validate, ToSchema)]
|
||||||
@@ -10,4 +11,6 @@ pub struct CreateThoughtParams {
|
|||||||
message = "Content must be between 1 and 128 characters"
|
message = "Content must be between 1 and 128 characters"
|
||||||
))]
|
))]
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
|
||||||
|
pub reply_to_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
@@ -12,6 +12,7 @@ pub struct ThoughtSchema {
|
|||||||
pub author_username: String,
|
pub author_username: String,
|
||||||
#[schema(example = "This is my first thought! #welcome")]
|
#[schema(example = "This is my first thought! #welcome")]
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
pub reply_to_id: Option<Uuid>,
|
||||||
pub created_at: DateTimeWithTimeZoneWrapper,
|
pub created_at: DateTimeWithTimeZoneWrapper,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ impl ThoughtSchema {
|
|||||||
id: thought.id,
|
id: thought.id,
|
||||||
author_username: author.username.clone(),
|
author_username: author.username.clone(),
|
||||||
content: thought.content.clone(),
|
content: thought.content.clone(),
|
||||||
|
reply_to_id: thought.reply_to_id,
|
||||||
created_at: thought.created_at.into(),
|
created_at: thought.created_at.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,6 +46,7 @@ pub struct ThoughtWithAuthor {
|
|||||||
pub created_at: sea_orm::prelude::DateTimeWithTimeZone,
|
pub created_at: sea_orm::prelude::DateTimeWithTimeZone,
|
||||||
pub author_id: Uuid,
|
pub author_id: Uuid,
|
||||||
pub author_username: String,
|
pub author_username: String,
|
||||||
|
pub reply_to_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ThoughtWithAuthor> for ThoughtSchema {
|
impl From<ThoughtWithAuthor> for ThoughtSchema {
|
||||||
@@ -53,6 +56,7 @@ impl From<ThoughtWithAuthor> for ThoughtSchema {
|
|||||||
author_username: model.author_username,
|
author_username: model.author_username,
|
||||||
content: model.content,
|
content: model.content,
|
||||||
created_at: model.created_at.into(),
|
created_at: model.created_at.into(),
|
||||||
|
reply_to_id: model.reply_to_id,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,7 @@ use super::main::setup;
|
|||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use sea_orm::prelude::Uuid;
|
use sea_orm::prelude::Uuid;
|
||||||
use serde_json::json;
|
use serde_json::{json, Value};
|
||||||
use utils::testing::{make_delete_request, make_post_request};
|
use utils::testing::{make_delete_request, make_post_request};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -48,3 +48,36 @@ async fn test_thought_endpoints() {
|
|||||||
.await;
|
.await;
|
||||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
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");
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user