feat: implement thought thread retrieval with replies and visibility filtering
This commit is contained in:
2
thoughts-backend/Cargo.lock
generated
2
thoughts-backend/Cargo.lock
generated
@@ -1273,9 +1273,9 @@ dependencies = [
|
|||||||
name = "doc"
|
name = "doc"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"api",
|
|
||||||
"axum 0.8.4",
|
"axum 0.8.4",
|
||||||
"models",
|
"models",
|
||||||
|
"tracing",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
"utoipa-scalar",
|
"utoipa-scalar",
|
||||||
"utoipa-swagger-ui",
|
"utoipa-swagger-ui",
|
||||||
|
@@ -8,7 +8,6 @@ use app::state::AppState;
|
|||||||
|
|
||||||
use crate::routers::create_router;
|
use crate::routers::create_router;
|
||||||
|
|
||||||
// TODO: middleware, logging, authentication
|
|
||||||
pub fn setup_router(conn: DatabaseConnection, config: &Config) -> Router {
|
pub fn setup_router(conn: DatabaseConnection, config: &Config) -> Router {
|
||||||
create_router(AppState {
|
create_router(AppState {
|
||||||
conn,
|
conn,
|
||||||
|
@@ -11,7 +11,10 @@ use app::{
|
|||||||
persistence::thought::{create_thought, delete_thought, get_thought},
|
persistence::thought::{create_thought, delete_thought, get_thought},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema};
|
use models::{
|
||||||
|
params::thought::CreateThoughtParams,
|
||||||
|
schemas::thought::{ThoughtSchema, ThoughtThreadSchema},
|
||||||
|
};
|
||||||
use sea_orm::prelude::Uuid;
|
use sea_orm::prelude::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -118,8 +121,33 @@ async fn thoughts_delete(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/{id}/thread",
|
||||||
|
params(
|
||||||
|
("id" = Uuid, Path, description = "Thought ID")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Thought thread found", body = ThoughtThreadSchema),
|
||||||
|
(status = 404, description = "Not Found", body = ApiErrorResponse)
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn get_thought_thread(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
viewer: OptionalAuthUser,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let viewer_id = viewer.0.map(|u| u.id);
|
||||||
|
let thread = app::persistence::thought::get_thought_with_replies(&state.conn, id, viewer_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
|
||||||
|
Ok(Json(thread))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_thought_router() -> Router<AppState> {
|
pub fn create_thought_router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", post(thoughts_post))
|
.route("/", post(thoughts_post))
|
||||||
|
.route("/{id}/thread", get(get_thought_thread))
|
||||||
.route("/{id}", get(get_thought_by_id).delete(thoughts_delete))
|
.route("/{id}", get(get_thought_by_id).delete(thoughts_delete))
|
||||||
}
|
}
|
||||||
|
@@ -345,10 +345,17 @@ async fn get_me(
|
|||||||
let following = get_following(&state.conn, auth_user.id).await?;
|
let following = get_following(&state.conn, auth_user.id).await?;
|
||||||
|
|
||||||
let response = MeSchema {
|
let response = MeSchema {
|
||||||
user: UserSchema::from((user, top_friends)),
|
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(),
|
||||||
following: following.into_iter().map(UserSchema::from).collect(),
|
following: following.into_iter().map(UserSchema::from).collect(),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(axum::Json(response))
|
Ok(axum::Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -7,7 +7,7 @@ use sea_orm::{
|
|||||||
use models::{
|
use models::{
|
||||||
domains::{tag, thought, thought_tag, user},
|
domains::{tag, thought, thought_tag, user},
|
||||||
params::thought::CreateThoughtParams,
|
params::thought::CreateThoughtParams,
|
||||||
schemas::thought::ThoughtWithAuthor,
|
schemas::thought::{ThoughtSchema, ThoughtThreadSchema, ThoughtWithAuthor},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -210,17 +210,103 @@ pub fn apply_visibility_filter(
|
|||||||
Condition::any().add(thought::Column::Visibility.eq(thought::Visibility::Public));
|
Condition::any().add(thought::Column::Visibility.eq(thought::Visibility::Public));
|
||||||
|
|
||||||
if let Some(viewer) = viewer_id {
|
if let Some(viewer) = viewer_id {
|
||||||
// Viewers can see their own thoughts of any visibility
|
|
||||||
if user_id == viewer {
|
if user_id == viewer {
|
||||||
condition = condition
|
condition = condition
|
||||||
.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly))
|
.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly))
|
||||||
.add(thought::Column::Visibility.eq(thought::Visibility::Private));
|
.add(thought::Column::Visibility.eq(thought::Visibility::Private));
|
||||||
}
|
} else if !friend_ids.is_empty() && friend_ids.contains(&user_id) {
|
||||||
// If the thought's author is a friend of the viewer, they can see it
|
|
||||||
else if !friend_ids.is_empty() && friend_ids.contains(&user_id) {
|
|
||||||
condition =
|
condition =
|
||||||
condition.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly));
|
condition.add(thought::Column::Visibility.eq(thought::Visibility::FriendsOnly));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
condition.into()
|
condition.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_thought_with_replies(
|
||||||
|
db: &DbConn,
|
||||||
|
thought_id: Uuid,
|
||||||
|
viewer_id: Option<Uuid>,
|
||||||
|
) -> Result<Option<ThoughtThreadSchema>, DbErr> {
|
||||||
|
let root_thought = match get_thought(db, thought_id, viewer_id).await? {
|
||||||
|
Some(t) => t,
|
||||||
|
None => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut all_thoughts_in_thread = vec![root_thought.clone()];
|
||||||
|
let mut ids_to_fetch = vec![root_thought.id];
|
||||||
|
let mut friend_ids = vec![];
|
||||||
|
if let Some(viewer) = viewer_id {
|
||||||
|
friend_ids = follow::get_friend_ids(db, viewer).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
while !ids_to_fetch.is_empty() {
|
||||||
|
let replies = thought::Entity::find()
|
||||||
|
.filter(thought::Column::ReplyToId.is_in(ids_to_fetch))
|
||||||
|
.all(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if replies.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
ids_to_fetch = replies.iter().map(|r| r.id).collect();
|
||||||
|
all_thoughts_in_thread.extend(replies);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut thought_schemas = vec![];
|
||||||
|
for thought in all_thoughts_in_thread {
|
||||||
|
if let Some(author) = user::Entity::find_by_id(thought.author_id).one(db).await? {
|
||||||
|
let is_visible = match thought.visibility {
|
||||||
|
thought::Visibility::Public => true,
|
||||||
|
thought::Visibility::Private => viewer_id.map_or(false, |v| v == thought.author_id),
|
||||||
|
thought::Visibility::FriendsOnly => viewer_id.map_or(false, |v| {
|
||||||
|
v == thought.author_id || friend_ids.contains(&thought.author_id)
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_visible {
|
||||||
|
thought_schemas.push(ThoughtSchema::from_models(&thought, &author));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_thread(
|
||||||
|
thought_id: Uuid,
|
||||||
|
schemas_map: &std::collections::HashMap<Uuid, ThoughtSchema>,
|
||||||
|
replies_map: &std::collections::HashMap<Uuid, Vec<Uuid>>,
|
||||||
|
) -> Option<ThoughtThreadSchema> {
|
||||||
|
schemas_map.get(&thought_id).map(|thought_schema| {
|
||||||
|
let replies = replies_map
|
||||||
|
.get(&thought_id)
|
||||||
|
.unwrap_or(&vec![])
|
||||||
|
.iter()
|
||||||
|
.filter_map(|reply_id| build_thread(*reply_id, schemas_map, replies_map))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
ThoughtThreadSchema {
|
||||||
|
id: thought_schema.id,
|
||||||
|
author_username: thought_schema.author_username.clone(),
|
||||||
|
content: thought_schema.content.clone(),
|
||||||
|
visibility: thought_schema.visibility.clone(),
|
||||||
|
reply_to_id: thought_schema.reply_to_id,
|
||||||
|
created_at: thought_schema.created_at.clone(),
|
||||||
|
replies,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let schemas_map: std::collections::HashMap<Uuid, ThoughtSchema> =
|
||||||
|
thought_schemas.into_iter().map(|s| (s.id, s)).collect();
|
||||||
|
|
||||||
|
let mut replies_map: std::collections::HashMap<Uuid, Vec<Uuid>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for thought in schemas_map.values() {
|
||||||
|
if let Some(parent_id) = thought.reply_to_id {
|
||||||
|
if schemas_map.contains_key(&parent_id) {
|
||||||
|
replies_map.entry(parent_id).or_default().push(thought.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(build_thread(root_thought.id, &schemas_map, &replies_map))
|
||||||
|
}
|
||||||
|
@@ -5,7 +5,7 @@ use sea_query::ValueTypeErr;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema, Debug)]
|
#[derive(Serialize, ToSchema, Debug, Clone)]
|
||||||
#[schema(example = "2025-09-05T12:34:56Z")]
|
#[schema(example = "2025-09-05T12:34:56Z")]
|
||||||
pub struct DateTimeWithTimeZoneWrapper(String);
|
pub struct DateTimeWithTimeZoneWrapper(String);
|
||||||
|
|
||||||
|
@@ -10,6 +10,7 @@ path = "src/lib.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
utoipa = { workspace = true, features = ["axum_extras"] }
|
utoipa = { workspace = true, features = ["axum_extras"] }
|
||||||
utoipa-swagger-ui = { version = "9.0.2", features = [
|
utoipa-swagger-ui = { version = "9.0.2", features = [
|
||||||
"axum",
|
"axum",
|
||||||
@@ -19,5 +20,5 @@ utoipa-scalar = { version = "0.3.0", features = [
|
|||||||
"axum",
|
"axum",
|
||||||
], default-features = false }
|
], default-features = false }
|
||||||
|
|
||||||
api = { path = "../api" }
|
# api = { path = "../api" }
|
||||||
models = { path = "../models" }
|
models = { path = "../models" }
|
||||||
|
@@ -6,28 +6,8 @@ use utoipa::{
|
|||||||
use utoipa_scalar::{Scalar, Servable as ScalarServable};
|
use utoipa_scalar::{Scalar, Servable as ScalarServable};
|
||||||
use utoipa_swagger_ui::SwaggerUi;
|
use utoipa_swagger_ui::SwaggerUi;
|
||||||
|
|
||||||
mod api_key;
|
|
||||||
mod auth;
|
|
||||||
mod feed;
|
|
||||||
mod friends;
|
|
||||||
mod root;
|
|
||||||
mod search;
|
|
||||||
mod tag;
|
|
||||||
mod thought;
|
|
||||||
mod user;
|
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
nest(
|
|
||||||
(path = "/", api = root::RootApi),
|
|
||||||
(path = "/auth", api = auth::AuthApi),
|
|
||||||
(path = "/users", api = user::UserApi),
|
|
||||||
(path = "/users/me/api-keys", api = api_key::ApiKeyApi),
|
|
||||||
(path = "/thoughts", api = thought::ThoughtApi),
|
|
||||||
(path = "/feed", api = feed::FeedApi),
|
|
||||||
(path = "/tags", api = tag::TagApi),
|
|
||||||
(path = "/friends", api = friends::FriendsApi),
|
|
||||||
(path = "/search", api = search::SearchApi),
|
|
||||||
),
|
|
||||||
tags(
|
tags(
|
||||||
(name = "root", description = "Root API"),
|
(name = "root", description = "Root API"),
|
||||||
(name = "auth", description = "Authentication API"),
|
(name = "auth", description = "Authentication API"),
|
||||||
@@ -57,12 +37,14 @@ impl Modify for SecurityAddon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub trait ApiDoc {
|
pub trait ApiDocExt {
|
||||||
fn attach_doc(self) -> Self;
|
fn attach_doc(self) -> Self;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ApiDoc for Router {
|
impl ApiDocExt for Router {
|
||||||
fn attach_doc(self) -> Self {
|
fn attach_doc(self) -> Self {
|
||||||
|
tracing::info!("Attaching API documentation");
|
||||||
|
|
||||||
self.merge(SwaggerUi::new("/docs").url("/openapi.json", _ApiDoc::openapi()))
|
self.merge(SwaggerUi::new("/docs").url("/openapi.json", _ApiDoc::openapi()))
|
||||||
.merge(Scalar::with_url("/scalar", _ApiDoc::openapi()))
|
.merge(Scalar::with_url("/scalar", _ApiDoc::openapi()))
|
||||||
}
|
}
|
||||||
|
@@ -2,15 +2,19 @@ use api::{
|
|||||||
models::{ApiErrorResponse, ParamsErrorResponse},
|
models::{ApiErrorResponse, ParamsErrorResponse},
|
||||||
routers::thought::*,
|
routers::thought::*,
|
||||||
};
|
};
|
||||||
use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema};
|
use models::{
|
||||||
|
params::thought::CreateThoughtParams,
|
||||||
|
schemas::thought::{ThoughtSchema, ThoughtThreadSchema},
|
||||||
|
};
|
||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
paths(thoughts_post, thoughts_delete, get_thought_by_id),
|
paths(thoughts_post, thoughts_delete, get_thought_by_id, get_thought_thread),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
CreateThoughtParams,
|
CreateThoughtParams,
|
||||||
ThoughtSchema,
|
ThoughtSchema,
|
||||||
|
ThoughtThreadSchema,
|
||||||
ApiErrorResponse,
|
ApiErrorResponse,
|
||||||
ParamsErrorResponse
|
ParamsErrorResponse
|
||||||
))
|
))
|
||||||
|
@@ -8,18 +8,16 @@ use serde::Serialize;
|
|||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema, FromQueryResult, Debug)]
|
#[derive(Serialize, ToSchema, FromQueryResult, Debug, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ThoughtSchema {
|
pub struct ThoughtSchema {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
#[schema(example = "frutiger")]
|
#[schema(example = "frutiger")]
|
||||||
#[serde(rename = "authorUsername")]
|
|
||||||
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 visibility: Visibility,
|
pub visibility: Visibility,
|
||||||
#[serde(rename = "replyToId")]
|
|
||||||
pub reply_to_id: Option<Uuid>,
|
pub reply_to_id: Option<Uuid>,
|
||||||
#[serde(rename = "createdAt")]
|
|
||||||
pub created_at: DateTimeWithTimeZoneWrapper,
|
pub created_at: DateTimeWithTimeZoneWrapper,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +35,7 @@ impl ThoughtSchema {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ThoughtListSchema {
|
pub struct ThoughtListSchema {
|
||||||
pub thoughts: Vec<ThoughtSchema>,
|
pub thoughts: Vec<ThoughtSchema>,
|
||||||
}
|
}
|
||||||
@@ -70,3 +69,15 @@ impl From<ThoughtWithAuthor> for ThoughtSchema {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ThoughtThreadSchema {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub author_username: String,
|
||||||
|
pub content: String,
|
||||||
|
pub visibility: Visibility,
|
||||||
|
pub reply_to_id: Option<Uuid>,
|
||||||
|
pub created_at: DateTimeWithTimeZoneWrapper,
|
||||||
|
pub replies: Vec<ThoughtThreadSchema>,
|
||||||
|
}
|
||||||
|
@@ -6,21 +6,16 @@ use uuid::Uuid;
|
|||||||
use crate::domains::user;
|
use crate::domains::user;
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct UserSchema {
|
pub struct UserSchema {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub username: String,
|
pub username: String,
|
||||||
#[serde(rename = "displayName")]
|
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub bio: Option<String>,
|
pub bio: Option<String>,
|
||||||
#[serde(rename = "avatarUrl")]
|
|
||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
#[serde(rename = "headerUrl")]
|
|
||||||
pub header_url: Option<String>,
|
pub header_url: Option<String>,
|
||||||
#[serde(rename = "customCss")]
|
|
||||||
pub custom_css: Option<String>,
|
pub custom_css: Option<String>,
|
||||||
#[serde(rename = "topFriends")]
|
|
||||||
pub top_friends: Vec<String>,
|
pub top_friends: Vec<String>,
|
||||||
#[serde(rename = "joinedAt")]
|
|
||||||
pub joined_at: DateTimeWithTimeZoneWrapper,
|
pub joined_at: DateTimeWithTimeZoneWrapper,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +45,7 @@ impl From<user::Model> for UserSchema {
|
|||||||
avatar_url: user.avatar_url,
|
avatar_url: user.avatar_url,
|
||||||
header_url: user.header_url,
|
header_url: user.header_url,
|
||||||
custom_css: user.custom_css,
|
custom_css: user.custom_css,
|
||||||
top_friends: vec![], // Defaults to an empty list
|
top_friends: vec![],
|
||||||
joined_at: user.created_at.into(),
|
joined_at: user.created_at.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,8 +65,16 @@ impl From<Vec<user::Model>> for UserListSchema {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct MeSchema {
|
pub struct MeSchema {
|
||||||
#[serde(flatten)]
|
pub id: Uuid,
|
||||||
pub user: UserSchema,
|
pub username: String,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
pub header_url: Option<String>,
|
||||||
|
pub custom_css: Option<String>,
|
||||||
|
pub top_friends: Vec<String>,
|
||||||
|
pub joined_at: DateTimeWithTimeZoneWrapper,
|
||||||
pub following: Vec<UserSchema>,
|
pub following: Vec<UserSchema>,
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
use api::{setup_db, setup_router};
|
use api::{setup_db, setup_router};
|
||||||
use doc::ApiDoc;
|
use doc::ApiDocExt;
|
||||||
use utils::migrate;
|
use utils::migrate;
|
||||||
|
|
||||||
pub async fn run(db_url: &str) -> shuttle_axum::ShuttleAxum {
|
pub async fn run(db_url: &str) -> shuttle_axum::ShuttleAxum {
|
||||||
|
@@ -1,10 +1,7 @@
|
|||||||
use api::{setup_config, setup_db, setup_router};
|
use api::{setup_config, setup_db, setup_router};
|
||||||
use doc::ApiDoc;
|
|
||||||
use utils::{create_dev_db, migrate};
|
use utils::{create_dev_db, migrate};
|
||||||
|
|
||||||
async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net::TcpListener) {
|
async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net::TcpListener) {
|
||||||
tracing::info!("Worker {} started", child_num);
|
|
||||||
|
|
||||||
let conn = setup_db(db_url, prefork).await;
|
let conn = setup_db(db_url, prefork).await;
|
||||||
|
|
||||||
if child_num == 0 {
|
if child_num == 0 {
|
||||||
@@ -13,7 +10,7 @@ async fn worker(child_num: u32, db_url: &str, prefork: bool, listener: std::net:
|
|||||||
|
|
||||||
let config = setup_config();
|
let config = setup_config();
|
||||||
|
|
||||||
let router = setup_router(conn, &config).attach_doc();
|
let router = setup_router(conn, &config);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::from_std(listener).expect("bind to port");
|
let listener = tokio::net::TcpListener::from_std(listener).expect("bind to port");
|
||||||
axum::serve(listener, router).await.expect("start server");
|
axum::serve(listener, router).await.expect("start server");
|
||||||
@@ -44,11 +41,12 @@ fn run_non_prefork(db_url: &str, listener: std::net::TcpListener) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
tracing::info!("Starting server...");
|
||||||
let config = setup_config();
|
let config = setup_config();
|
||||||
|
|
||||||
let listener = std::net::TcpListener::bind(config.get_server_url()).expect("bind to port");
|
let listener = std::net::TcpListener::bind(config.get_server_url()).expect("bind to port");
|
||||||
listener.set_nonblocking(true).expect("non blocking failed");
|
listener.set_nonblocking(true).expect("non blocking failed");
|
||||||
println!("listening on http://{}", listener.local_addr().unwrap());
|
tracing::info!("listening on http://{}", listener.local_addr().unwrap());
|
||||||
|
|
||||||
#[cfg(feature = "prefork")]
|
#[cfg(feature = "prefork")]
|
||||||
if config.prefork {
|
if config.prefork {
|
||||||
|
@@ -249,3 +249,73 @@ async fn test_get_thought_by_id_visibility() {
|
|||||||
"Friend should NOT see private thought"
|
"Friend should NOT see private thought"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_get_thought_thread() {
|
||||||
|
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;
|
||||||
|
let user3 =
|
||||||
|
create_user_with_password(&app.db, "user3", "password123", "user3@example.com").await;
|
||||||
|
|
||||||
|
let token1 = login_user(app.router.clone(), "user1", "password123").await;
|
||||||
|
let token2 = login_user(app.router.clone(), "user2", "password123").await;
|
||||||
|
|
||||||
|
// 1. user1 posts a root thought
|
||||||
|
let root_id = post_thought_and_get_id(&app.router, "Root thought", "Public", &token1).await;
|
||||||
|
|
||||||
|
// 2. user2 replies to the root thought
|
||||||
|
let reply1_body = json!({ "content": "First reply", "replyToId": root_id }).to_string();
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(reply1_body),
|
||||||
|
&token2,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let reply1: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
let reply1_id = reply1["id"].as_str().unwrap().to_string();
|
||||||
|
|
||||||
|
// 3. user1 replies to user2's reply
|
||||||
|
let reply2_body =
|
||||||
|
json!({ "content": "Reply to the reply", "replyToId": reply1_id }).to_string();
|
||||||
|
make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/thoughts",
|
||||||
|
"POST",
|
||||||
|
Some(reply2_body),
|
||||||
|
&token1,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// 4. Fetch the entire thread
|
||||||
|
let response = make_get_request(
|
||||||
|
app.router.clone(),
|
||||||
|
&format!("/thoughts/{}/thread", root_id),
|
||||||
|
Some(user3.id), // Fetch as a third user to test visibility
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let thread: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
// 5. Assert the structure
|
||||||
|
assert_eq!(thread["content"], "Root thought");
|
||||||
|
assert_eq!(thread["authorUsername"], "user1");
|
||||||
|
assert_eq!(thread["replies"].as_array().unwrap().len(), 1);
|
||||||
|
|
||||||
|
let reply_level_1 = &thread["replies"][0];
|
||||||
|
assert_eq!(reply_level_1["content"], "First reply");
|
||||||
|
assert_eq!(reply_level_1["authorUsername"], "user2");
|
||||||
|
assert_eq!(reply_level_1["replies"].as_array().unwrap().len(), 1);
|
||||||
|
|
||||||
|
let reply_level_2 = &reply_level_1["replies"][0];
|
||||||
|
assert_eq!(reply_level_2["content"], "Reply to the reply");
|
||||||
|
assert_eq!(reply_level_2["authorUsername"], "user1");
|
||||||
|
assert!(reply_level_2["replies"].as_array().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user