Compare commits
2 Commits
6e63dca513
...
b83b7acf1c
Author | SHA1 | Date | |
---|---|---|---|
b83b7acf1c | |||
c9e99e6f23 |
15
compose.yml
15
compose.yml
@@ -50,6 +50,21 @@ services:
|
|||||||
- frontend
|
- frontend
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
|
db_test:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: thoughts-db-test
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: postgres
|
||||||
|
ports:
|
||||||
|
- "5434:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
driver: local
|
driver: local
|
||||||
|
8
thoughts-backend/Cargo.lock
generated
8
thoughts-backend/Cargo.lock
generated
@@ -2408,6 +2408,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
|
"uuid",
|
||||||
"validator",
|
"validator",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -4867,7 +4868,9 @@ dependencies = [
|
|||||||
"axum 0.8.4",
|
"axum 0.8.4",
|
||||||
"migration",
|
"migration",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
|
"tokio",
|
||||||
"tower 0.5.2",
|
"tower 0.5.2",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4892,6 +4895,7 @@ dependencies = [
|
|||||||
"quote",
|
"quote",
|
||||||
"regex",
|
"regex",
|
||||||
"syn 2.0.104",
|
"syn 2.0.104",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4932,9 +4936,9 @@ checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.18.0"
|
version = "1.18.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be"
|
checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.3.3",
|
"getrandom 0.3.3",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
@@ -21,9 +21,10 @@ sea-query = { version = "0.32.6" } # Added sea-quer
|
|||||||
serde = { version = "1.0.219", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
serde_json = { version = "1.0.140", features = ["raw_value"] }
|
serde_json = { version = "1.0.140", features = ["raw_value"] }
|
||||||
tracing = "0.1.41"
|
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 }
|
validator = { version = "0.20.0", default-features = false }
|
||||||
chrono = { version = "0.4.41", features = ["serde"] }
|
chrono = { version = "0.4.41", features = ["serde"] }
|
||||||
|
tokio = { version = "1.45.1", features = ["full"] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
api = { path = "api" }
|
api = { path = "api" }
|
||||||
@@ -38,8 +39,8 @@ tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
|||||||
|
|
||||||
# runtime
|
# runtime
|
||||||
axum = { workspace = true, features = ["tokio", "http1", "http2"] }
|
axum = { workspace = true, features = ["tokio", "http1", "http2"] }
|
||||||
tokio = { version = "1.45.1", features = ["full"] }
|
|
||||||
prefork = { version = "0.6.0", default-features = false, optional = true }
|
prefork = { version = "0.6.0", default-features = false, optional = true }
|
||||||
|
tokio = { version = "1.45.1", features = ["full"] }
|
||||||
|
|
||||||
# shuttle runtime
|
# shuttle runtime
|
||||||
shuttle-axum = { version = "0.55.0", optional = true }
|
shuttle-axum = { version = "0.55.0", optional = true }
|
||||||
|
@@ -18,7 +18,6 @@ bcrypt = "0.17.1"
|
|||||||
jsonwebtoken = "9.3.1"
|
jsonwebtoken = "9.3.1"
|
||||||
once_cell = "1.21.3"
|
once_cell = "1.21.3"
|
||||||
|
|
||||||
tokio = "1.45.1"
|
|
||||||
|
|
||||||
# db
|
# db
|
||||||
sea-orm = { workspace = true }
|
sea-orm = { workspace = true }
|
||||||
@@ -27,6 +26,7 @@ sea-orm = { workspace = true }
|
|||||||
utoipa = { workspace = true }
|
utoipa = { workspace = true }
|
||||||
|
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
|
||||||
# local dependencies
|
# local dependencies
|
||||||
app = { path = "../app" }
|
app = { path = "../app" }
|
||||||
|
@@ -5,13 +5,14 @@ use axum::{
|
|||||||
|
|
||||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
|
use sea_orm::prelude::Uuid;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use app::state::AppState;
|
use app::state::AppState;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
pub sub: i32,
|
pub sub: Uuid,
|
||||||
pub exp: usize,
|
pub exp: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ static JWT_SECRET: Lazy<String> =
|
|||||||
Lazy::new(|| std::env::var("AUTH_SECRET").expect("AUTH_SECRET must be set"));
|
Lazy::new(|| std::env::var("AUTH_SECRET").expect("AUTH_SECRET must be set"));
|
||||||
|
|
||||||
pub struct AuthUser {
|
pub struct AuthUser {
|
||||||
pub id: i32,
|
pub id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromRequestParts<AppState> for AuthUser {
|
impl FromRequestParts<AppState> for AuthUser {
|
||||||
@@ -31,7 +32,7 @@ impl FromRequestParts<AppState> for AuthUser {
|
|||||||
) -> Result<Self, Self::Rejection> {
|
) -> Result<Self, Self::Rejection> {
|
||||||
if let Some(user_id_header) = parts.headers.get("x-test-user-id") {
|
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_str = user_id_header.to_str().unwrap_or("0");
|
||||||
let user_id = user_id_str.parse::<i32>().unwrap_or(0);
|
let user_id = user_id_str.parse::<Uuid>().unwrap_or(Uuid::nil());
|
||||||
return Ok(AuthUser { id: user_id });
|
return Ok(AuthUser { id: user_id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -21,7 +21,7 @@ pub async fn federate_thought(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if follower_ids.is_empty() {
|
if follower_ids.is_empty() {
|
||||||
tracing::debug!("No followers to federate to for user {}", author.username);
|
println!("No followers to federate to for user {}", author.username);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -3,6 +3,7 @@ use axum::Router;
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod feed;
|
pub mod feed;
|
||||||
pub mod root;
|
pub mod root;
|
||||||
|
pub mod tag;
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
pub mod well_known;
|
pub mod well_known;
|
||||||
@@ -25,6 +26,7 @@ pub fn create_router(state: AppState) -> Router {
|
|||||||
.nest("/users", create_user_router())
|
.nest("/users", create_user_router())
|
||||||
.nest("/thoughts", create_thought_router())
|
.nest("/thoughts", create_thought_router())
|
||||||
.nest("/feed", create_feed_router())
|
.nest("/feed", create_feed_router())
|
||||||
|
.nest("/tags", tag::create_tag_router())
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
}
|
}
|
||||||
|
38
thoughts-backend/api/src/routers/tag.rs
Normal file
38
thoughts-backend/api/src/routers/tag.rs
Normal file
@@ -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<AppState>,
|
||||||
|
Path(tag_name): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
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<ThoughtSchema> = 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<AppState> {
|
||||||
|
Router::new().route("/{tag_name}", get(get_thoughts_by_tag))
|
||||||
|
}
|
@@ -12,6 +12,7 @@ use app::{
|
|||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema};
|
use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema};
|
||||||
|
use sea_orm::prelude::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::ApiError,
|
error::ApiError,
|
||||||
@@ -74,7 +75,7 @@ async fn thoughts_post(
|
|||||||
async fn thoughts_delete(
|
async fn thoughts_delete(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
Path(id): Path<i32>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, ApiError> {
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
let thought = get_thought(&state.conn, id)
|
let thought = get_thought(&state.conn, id)
|
||||||
.await?
|
.await?
|
||||||
|
@@ -5,20 +5,21 @@ use axum::{
|
|||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
|
use sea_orm::prelude::Uuid;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use app::persistence::{
|
use app::persistence::{
|
||||||
follow,
|
follow,
|
||||||
thought::get_thoughts_by_user,
|
thought::get_thoughts_by_user,
|
||||||
user::{get_user, search_users},
|
user::{get_user, search_users, update_user_profile},
|
||||||
};
|
};
|
||||||
use app::state::AppState;
|
use app::state::AppState;
|
||||||
use app::{error::UserError, persistence::user::get_user_by_username};
|
use app::{error::UserError, persistence::user::get_user_by_username};
|
||||||
use models::schemas::thought::ThoughtListSchema;
|
|
||||||
use models::schemas::user::{UserListSchema, UserSchema};
|
use models::schemas::user::{UserListSchema, UserSchema};
|
||||||
|
use models::{params::user::UpdateUserParams, schemas::thought::ThoughtListSchema};
|
||||||
use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
|
use models::{queries::user::UserQuery, schemas::thought::ThoughtSchema};
|
||||||
|
|
||||||
use crate::extractor::Json;
|
use crate::extractor::{Json, Valid};
|
||||||
use crate::models::ApiErrorResponse;
|
use crate::models::ApiErrorResponse;
|
||||||
use crate::{error::ApiError, extractor::AuthUser};
|
use crate::{error::ApiError, extractor::AuthUser};
|
||||||
|
|
||||||
@@ -201,7 +202,7 @@ async fn get_user_by_param(
|
|||||||
Path(param): Path<String>,
|
Path(param): Path<String>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
// First, try to handle it as a numeric ID.
|
// First, try to handle it as a numeric ID.
|
||||||
if let Ok(id) = param.parse::<i32>() {
|
if let Ok(id) = param.parse::<Uuid>() {
|
||||||
return match get_user(&state.conn, id).await {
|
return match get_user(&state.conn, id).await {
|
||||||
Ok(Some(user)) => Json(UserSchema::from(user)).into_response(),
|
Ok(Some(user)) => Json(UserSchema::from(user)).into_response(),
|
||||||
Ok(None) => ApiError::from(UserError::NotFound).into_response(),
|
Ok(None) => ApiError::from(UserError::NotFound).into_response(),
|
||||||
@@ -311,9 +312,52 @@ async fn user_outbox_get(
|
|||||||
Ok((headers, Json(outbox)))
|
Ok((headers, Json(outbox)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/me",
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Authenticated user's profile", body = UserSchema)
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("bearer_auth" = [])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn get_me(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let user = get_user(&state.conn, auth_user.id)
|
||||||
|
.await?
|
||||||
|
.ok_or(UserError::NotFound)?;
|
||||||
|
Ok(axum::Json(UserSchema::from(user)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/me",
|
||||||
|
request_body = UpdateUserParams,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Profile updated", body = UserSchema),
|
||||||
|
(status = 400, description = "Bad request", body = ApiErrorResponse),
|
||||||
|
(status = 422, description = "Validation error", body = ApiErrorResponse)
|
||||||
|
),
|
||||||
|
security(
|
||||||
|
("bearer_auth" = [])
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn update_me(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Valid(Json(params)): Valid<Json<UpdateUserParams>>,
|
||||||
|
) -> Result<impl IntoResponse, ApiError> {
|
||||||
|
let updated_user = update_user_profile(&state.conn, auth_user.id, params).await?;
|
||||||
|
Ok(axum::Json(UserSchema::from(updated_user)))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_user_router() -> Router<AppState> {
|
pub fn create_user_router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(users_get))
|
.route("/", get(users_get))
|
||||||
|
.route("/me", get(get_me).put(update_me))
|
||||||
.route("/{param}", get(get_user_by_param))
|
.route("/{param}", get(get_user_by_param))
|
||||||
.route("/{username}/thoughts", get(user_thoughts_get))
|
.route("/{username}/thoughts", get(user_thoughts_get))
|
||||||
.route(
|
.route(
|
||||||
|
@@ -12,5 +12,4 @@ path = "src/lib.rs"
|
|||||||
bcrypt = "0.17.1"
|
bcrypt = "0.17.1"
|
||||||
models = { path = "../models" }
|
models = { path = "../models" }
|
||||||
validator = "0.20"
|
validator = "0.20"
|
||||||
|
sea-orm = { version = "1.1.12" }
|
||||||
sea-orm = { workspace = true }
|
|
||||||
|
@@ -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 crate::{error::UserError, persistence::user::get_user_by_username};
|
||||||
use models::domains::follow;
|
use models::domains::follow;
|
||||||
|
|
||||||
pub async fn add_follower(
|
pub async fn add_follower(
|
||||||
db: &DbConn,
|
db: &DbConn,
|
||||||
followed_id: i32,
|
followed_id: Uuid,
|
||||||
follower_actor_id: &str,
|
follower_actor_id: &str,
|
||||||
) -> Result<(), UserError> {
|
) -> Result<(), UserError> {
|
||||||
let follower_username = follower_actor_id
|
let follower_username = follower_actor_id
|
||||||
@@ -25,7 +27,7 @@ pub async fn add_follower(
|
|||||||
Ok(())
|
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 {
|
if follower_id == followed_id {
|
||||||
return Err(DbErr::Custom("Users cannot follow themselves".to_string()));
|
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(
|
pub async fn unfollow_user(
|
||||||
db: &DbConn,
|
db: &DbConn,
|
||||||
follower_id: i32,
|
follower_id: Uuid,
|
||||||
followed_id: i32,
|
followed_id: Uuid,
|
||||||
) -> Result<(), UserError> {
|
) -> Result<(), UserError> {
|
||||||
let deleted_result = follow::Entity::delete_many()
|
let deleted_result = follow::Entity::delete_many()
|
||||||
.filter(follow::Column::FollowerId.eq(follower_id))
|
.filter(follow::Column::FollowerId.eq(follower_id))
|
||||||
@@ -58,7 +60,7 @@ pub async fn unfollow_user(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_followed_ids(db: &DbConn, user_id: i32) -> Result<Vec<i32>, DbErr> {
|
pub async fn get_followed_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
|
||||||
let followed_users = follow::Entity::find()
|
let followed_users = follow::Entity::find()
|
||||||
.filter(follow::Column::FollowerId.eq(user_id))
|
.filter(follow::Column::FollowerId.eq(user_id))
|
||||||
.all(db)
|
.all(db)
|
||||||
@@ -67,7 +69,7 @@ pub async fn get_followed_ids(db: &DbConn, user_id: i32) -> Result<Vec<i32>, DbE
|
|||||||
Ok(followed_users.into_iter().map(|f| f.followed_id).collect())
|
Ok(followed_users.into_iter().map(|f| f.followed_id).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_follower_ids(db: &DbConn, user_id: i32) -> Result<Vec<i32>, DbErr> {
|
pub async fn get_follower_ids(db: &DbConn, user_id: Uuid) -> Result<Vec<Uuid>, DbErr> {
|
||||||
let followers = follow::Entity::find()
|
let followers = follow::Entity::find()
|
||||||
.filter(follow::Column::FollowedId.eq(user_id))
|
.filter(follow::Column::FollowedId.eq(user_id))
|
||||||
.all(db)
|
.all(db)
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod follow;
|
pub mod follow;
|
||||||
|
pub mod tag;
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
86
thoughts-backend/app/src/persistence/tag.rs
Normal file
86
thoughts-backend/app/src/persistence/tag.rs
Normal file
@@ -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<String> {
|
||||||
|
content
|
||||||
|
.split_whitespace()
|
||||||
|
.filter_map(|word| {
|
||||||
|
if word.starts_with('#') && word.len() > 1 {
|
||||||
|
Some(word[1..].to_lowercase().to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<HashSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_or_create_tags<C>(db: &C, names: Vec<String>) -> Result<Vec<tag::Model>, 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<String> = existing_tags.iter().map(|t| t.name.clone()).collect();
|
||||||
|
let new_names: Vec<String> = names
|
||||||
|
.into_iter()
|
||||||
|
.filter(|n| !existing_names.contains(n))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !new_names.is_empty() {
|
||||||
|
let new_tags: Vec<tag::ActiveModel> = 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::<Vec<_>>(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn link_tags_to_thought<C>(
|
||||||
|
db: &C,
|
||||||
|
thought_id: uuid::Uuid,
|
||||||
|
tags: Vec<tag::Model>,
|
||||||
|
) -> Result<(), DbErr>
|
||||||
|
where
|
||||||
|
C: ConnectionTrait,
|
||||||
|
{
|
||||||
|
if tags.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let links: Vec<thought_tag::ActiveModel> = 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(())
|
||||||
|
}
|
@@ -1,42 +1,56 @@
|
|||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, QueryFilter, QueryOrder,
|
prelude::Uuid, ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType,
|
||||||
QuerySelect, RelationTrait, Set,
|
QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set, TransactionTrait,
|
||||||
};
|
};
|
||||||
|
|
||||||
use models::{
|
use models::{
|
||||||
domains::{thought, user},
|
domains::{tag, thought, thought_tag, user},
|
||||||
params::thought::CreateThoughtParams,
|
params::thought::CreateThoughtParams,
|
||||||
schemas::thought::ThoughtWithAuthor,
|
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(
|
pub async fn create_thought(
|
||||||
db: &DbConn,
|
db: &DbConn,
|
||||||
author_id: i32,
|
author_id: Uuid,
|
||||||
params: CreateThoughtParams,
|
params: CreateThoughtParams,
|
||||||
) -> Result<thought::Model, DbErr> {
|
) -> Result<thought::Model, DbErr> {
|
||||||
thought::ActiveModel {
|
let txn = db.begin().await?;
|
||||||
|
|
||||||
|
let new_thought = thought::ActiveModel {
|
||||||
author_id: Set(author_id),
|
author_id: Set(author_id),
|
||||||
content: Set(params.content),
|
content: Set(params.content.clone()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.insert(db)
|
.insert(&txn)
|
||||||
.await
|
.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?;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_thought(db: &DbConn, thought_id: i32) -> Result<Option<thought::Model>, DbErr> {
|
txn.commit().await?;
|
||||||
|
Ok(new_thought)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_thought(db: &DbConn, thought_id: Uuid) -> Result<Option<thought::Model>, DbErr> {
|
||||||
thought::Entity::find_by_id(thought_id).one(db).await
|
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?;
|
thought::Entity::delete_by_id(thought_id).exec(db).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_thoughts_by_user(
|
pub async fn get_thoughts_by_user(
|
||||||
db: &DbConn,
|
db: &DbConn,
|
||||||
user_id: i32,
|
user_id: Uuid,
|
||||||
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
|
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
|
||||||
thought::Entity::find()
|
thought::Entity::find()
|
||||||
.select_only()
|
.select_only()
|
||||||
@@ -55,7 +69,7 @@ pub async fn get_thoughts_by_user(
|
|||||||
|
|
||||||
pub async fn get_feed_for_user(
|
pub async fn get_feed_for_user(
|
||||||
db: &DbConn,
|
db: &DbConn,
|
||||||
followed_ids: Vec<i32>,
|
followed_ids: Vec<Uuid>,
|
||||||
) -> Result<Vec<ThoughtWithAuthor>, UserError> {
|
) -> Result<Vec<ThoughtWithAuthor>, UserError> {
|
||||||
if followed_ids.is_empty() {
|
if followed_ids.is_empty() {
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
@@ -76,3 +90,24 @@ pub async fn get_feed_for_user(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| UserError::Internal(e.to_string()))
|
.map_err(|e| UserError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_thoughts_by_tag_name(
|
||||||
|
db: &DbConn,
|
||||||
|
tag_name: &str,
|
||||||
|
) -> Result<Vec<ThoughtWithAuthor>, 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::<ThoughtWithAuthor>()
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
@@ -1,9 +1,14 @@
|
|||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, QueryFilter, Set};
|
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;
|
use models::params::user::{CreateUserParams, UpdateUserParams};
|
||||||
use models::queries::user::UserQuery;
|
use models::queries::user::UserQuery;
|
||||||
|
|
||||||
|
use crate::error::UserError;
|
||||||
|
|
||||||
pub async fn create_user(
|
pub async fn create_user(
|
||||||
db: &DbConn,
|
db: &DbConn,
|
||||||
params: CreateUserParams,
|
params: CreateUserParams,
|
||||||
@@ -23,7 +28,7 @@ pub async fn search_users(db: &DbConn, query: UserQuery) -> Result<Vec<user::Mod
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_user(db: &DbConn, id: i32) -> Result<Option<user::Model>, DbErr> {
|
pub async fn get_user(db: &DbConn, id: Uuid) -> Result<Option<user::Model>, DbErr> {
|
||||||
user::Entity::find_by_id(id).one(db).await
|
user::Entity::find_by_id(id).one(db).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,9 +42,88 @@ pub async fn get_user_by_username(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_users_by_ids(db: &DbConn, ids: Vec<i32>) -> Result<Vec<user::Model>, DbErr> {
|
pub async fn get_users_by_ids(db: &DbConn, ids: Vec<Uuid>) -> Result<Vec<user::Model>, DbErr> {
|
||||||
user::Entity::find()
|
user::Entity::find()
|
||||||
.filter(user::Column::Id.is_in(ids))
|
.filter(user::Column::Id.is_in(ids))
|
||||||
.all(db)
|
.all(db)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_user_profile(
|
||||||
|
db: &DbConn,
|
||||||
|
user_id: Uuid,
|
||||||
|
params: UpdateUserParams,
|
||||||
|
) -> Result<user::Model, UserError> {
|
||||||
|
let mut user: user::ActiveModel = get_user(db, user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?
|
||||||
|
.ok_or(UserError::NotFound)?
|
||||||
|
.into();
|
||||||
|
|
||||||
|
if let Some(display_name) = params.display_name {
|
||||||
|
user.display_name = Set(Some(display_name));
|
||||||
|
}
|
||||||
|
if let Some(bio) = params.bio {
|
||||||
|
user.bio = Set(Some(bio));
|
||||||
|
}
|
||||||
|
if let Some(avatar_url) = params.avatar_url {
|
||||||
|
user.avatar_url = Set(Some(avatar_url));
|
||||||
|
}
|
||||||
|
if let Some(header_url) = params.header_url {
|
||||||
|
user.header_url = Set(Some(header_url));
|
||||||
|
}
|
||||||
|
if let Some(custom_css) = params.custom_css {
|
||||||
|
user.custom_css = Set(Some(custom_css));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(friend_usernames) = params.top_friends {
|
||||||
|
let txn = db
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
top_friends::Entity::delete_many()
|
||||||
|
.filter(top_friends::Column::UserId.eq(user_id))
|
||||||
|
.exec(&txn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
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()))?;
|
||||||
|
|
||||||
|
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<top_friends::ActiveModel> = 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
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| UserError::Internal(e.to_string()))
|
||||||
|
}
|
||||||
|
@@ -18,6 +18,8 @@ use models::schemas::{
|
|||||||
user_follow_delete,
|
user_follow_delete,
|
||||||
user_inbox_post,
|
user_inbox_post,
|
||||||
user_outbox_get,
|
user_outbox_get,
|
||||||
|
get_me,
|
||||||
|
update_me
|
||||||
),
|
),
|
||||||
components(schemas(
|
components(schemas(
|
||||||
CreateUserParams,
|
CreateUserParams,
|
||||||
|
@@ -2,6 +2,8 @@ pub use sea_orm_migration::prelude::*;
|
|||||||
|
|
||||||
mod m20240101_000001_init;
|
mod m20240101_000001_init;
|
||||||
mod m20250905_000001_init;
|
mod m20250905_000001_init;
|
||||||
|
mod m20250906_100000_add_profile_fields;
|
||||||
|
mod m20250906_130237_add_tags;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -11,6 +13,8 @@ impl MigratorTrait for Migrator {
|
|||||||
vec![
|
vec![
|
||||||
Box::new(m20240101_000001_init::Migration),
|
Box::new(m20240101_000001_init::Migration),
|
||||||
Box::new(m20250905_000001_init::Migration),
|
Box::new(m20250905_000001_init::Migration),
|
||||||
|
Box::new(m20250906_100000_add_profile_fields::Migration),
|
||||||
|
Box::new(m20250906_130237_add_tags::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -13,10 +13,10 @@ impl MigrationTrait for Migration {
|
|||||||
.if_not_exists()
|
.if_not_exists()
|
||||||
.col(
|
.col(
|
||||||
ColumnDef::new(User::Id)
|
ColumnDef::new(User::Id)
|
||||||
.integer()
|
.uuid()
|
||||||
.not_null()
|
.not_null()
|
||||||
.auto_increment()
|
.primary_key()
|
||||||
.primary_key(),
|
.default(Expr::cust("gen_random_uuid()")),
|
||||||
)
|
)
|
||||||
.col(
|
.col(
|
||||||
ColumnDef::new(User::Username)
|
ColumnDef::new(User::Username)
|
||||||
|
@@ -13,8 +13,14 @@ impl MigrationTrait for Migration {
|
|||||||
Table::create()
|
Table::create()
|
||||||
.table(Thought::Table)
|
.table(Thought::Table)
|
||||||
.if_not_exists()
|
.if_not_exists()
|
||||||
.col(pk_auto(Thought::Id))
|
.col(
|
||||||
.col(integer(Thought::AuthorId).not_null())
|
ColumnDef::new(Thought::Id)
|
||||||
|
.uuid()
|
||||||
|
.not_null()
|
||||||
|
.primary_key()
|
||||||
|
.default(Expr::cust("gen_random_uuid()")),
|
||||||
|
)
|
||||||
|
.col(uuid(Thought::AuthorId).not_null())
|
||||||
.foreign_key(
|
.foreign_key(
|
||||||
ForeignKey::create()
|
ForeignKey::create()
|
||||||
.name("fk_thought_author_id")
|
.name("fk_thought_author_id")
|
||||||
@@ -39,8 +45,8 @@ impl MigrationTrait for Migration {
|
|||||||
Table::create()
|
Table::create()
|
||||||
.table(Follow::Table)
|
.table(Follow::Table)
|
||||||
.if_not_exists()
|
.if_not_exists()
|
||||||
.col(integer(Follow::FollowerId).not_null())
|
.col(uuid(Follow::FollowerId).not_null())
|
||||||
.col(integer(Follow::FollowedId).not_null())
|
.col(uuid(Follow::FollowedId).not_null())
|
||||||
// Composite Primary Key to ensure a user can only follow another once
|
// Composite Primary Key to ensure a user can only follow another once
|
||||||
.primary_key(
|
.primary_key(
|
||||||
Index::create()
|
Index::create()
|
||||||
@@ -77,7 +83,7 @@ impl MigrationTrait for Migration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(DeriveIden)]
|
#[derive(DeriveIden)]
|
||||||
enum Thought {
|
pub enum Thought {
|
||||||
Table,
|
Table,
|
||||||
Id,
|
Id,
|
||||||
AuthorId,
|
AuthorId,
|
||||||
@@ -86,7 +92,7 @@ enum Thought {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(DeriveIden)]
|
#[derive(DeriveIden)]
|
||||||
enum Follow {
|
pub enum Follow {
|
||||||
Table,
|
Table,
|
||||||
// The user who is initiating the follow
|
// The user who is initiating the follow
|
||||||
FollowerId,
|
FollowerId,
|
||||||
|
@@ -0,0 +1,107 @@
|
|||||||
|
use super::m20240101_000001_init::User;
|
||||||
|
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
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(User::Table)
|
||||||
|
.add_column(string_null(UserExtension::Email).unique_key())
|
||||||
|
.add_column(string_null(UserExtension::DisplayName))
|
||||||
|
.add_column(string_null(UserExtension::Bio))
|
||||||
|
.add_column(text_null(UserExtension::AvatarUrl))
|
||||||
|
.add_column(text_null(UserExtension::HeaderUrl))
|
||||||
|
.add_column(text_null(UserExtension::CustomCss))
|
||||||
|
.add_column(
|
||||||
|
timestamp_with_time_zone(UserExtension::CreatedAt)
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.add_column(
|
||||||
|
timestamp_with_time_zone(UserExtension::UpdatedAt)
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(TopFriends::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(uuid(TopFriends::UserId).not_null())
|
||||||
|
.col(uuid(TopFriends::FriendId).not_null())
|
||||||
|
.col(small_integer(TopFriends::Position).not_null())
|
||||||
|
.primary_key(
|
||||||
|
Index::create()
|
||||||
|
.col(TopFriends::UserId)
|
||||||
|
.col(TopFriends::FriendId),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_top_friends_user_id")
|
||||||
|
.from(TopFriends::Table, TopFriends::UserId)
|
||||||
|
.to(User::Table, User::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_top_friends_friend_id")
|
||||||
|
.from(TopFriends::Table, TopFriends::FriendId)
|
||||||
|
.to(User::Table, User::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(TopFriends::Table).to_owned())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.alter_table(
|
||||||
|
Table::alter()
|
||||||
|
.table(User::Table)
|
||||||
|
.drop_column(UserExtension::Email)
|
||||||
|
.drop_column(UserExtension::DisplayName)
|
||||||
|
.drop_column(UserExtension::Bio)
|
||||||
|
.drop_column(UserExtension::AvatarUrl)
|
||||||
|
.drop_column(UserExtension::HeaderUrl)
|
||||||
|
.drop_column(UserExtension::CustomCss)
|
||||||
|
.drop_column(UserExtension::CreatedAt)
|
||||||
|
.drop_column(UserExtension::UpdatedAt)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum UserExtension {
|
||||||
|
Email,
|
||||||
|
DisplayName,
|
||||||
|
Bio,
|
||||||
|
AvatarUrl,
|
||||||
|
HeaderUrl,
|
||||||
|
CustomCss,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum TopFriends {
|
||||||
|
Table,
|
||||||
|
UserId,
|
||||||
|
FriendId,
|
||||||
|
Position,
|
||||||
|
}
|
74
thoughts-backend/migration/src/m20250906_130237_add_tags.rs
Normal file
74
thoughts-backend/migration/src/m20250906_130237_add_tags.rs
Normal file
@@ -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,
|
||||||
|
}
|
@@ -17,6 +17,7 @@ sea-orm = { workspace = true, features = [
|
|||||||
"runtime-tokio-rustls",
|
"runtime-tokio-rustls",
|
||||||
"macros",
|
"macros",
|
||||||
] }
|
] }
|
||||||
|
uuid = { version = "1.18.1", features = ["v4", "serde"] }
|
||||||
validator = { workspace = true, features = ["derive"] }
|
validator = { workspace = true, features = ["derive"] }
|
||||||
utoipa = { workspace = true }
|
utoipa = { workspace = true }
|
||||||
|
|
||||||
|
@@ -3,10 +3,10 @@ use sea_orm::entity::prelude::*;
|
|||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
#[sea_orm(table_name = "follow")]
|
#[sea_orm(table_name = "follow")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
#[sea_orm(primary_key)]
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
pub follower_id: i32,
|
pub follower_id: Uuid,
|
||||||
#[sea_orm(primary_key)]
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
pub followed_id: i32,
|
pub followed_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
@@ -29,4 +29,10 @@ pub enum Relation {
|
|||||||
Followed,
|
Followed,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Related<super::user::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Follower.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
@@ -3,5 +3,8 @@
|
|||||||
pub mod prelude;
|
pub mod prelude;
|
||||||
|
|
||||||
pub mod follow;
|
pub mod follow;
|
||||||
|
pub mod tag;
|
||||||
pub mod thought;
|
pub mod thought;
|
||||||
|
pub mod thought_tag;
|
||||||
|
pub mod top_friends;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
@@ -1,5 +1,8 @@
|
|||||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
|
||||||
|
|
||||||
pub use super::follow::Entity as Follow;
|
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::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;
|
pub use super::user::Entity as User;
|
||||||
|
27
thoughts-backend/models/src/domains/tag.rs
Normal file
27
thoughts-backend/models/src/domains/tag.rs
Normal file
@@ -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<super::thought::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
super::thought_tag::Relation::Thought.def()
|
||||||
|
}
|
||||||
|
fn via() -> Option<RelationDef> {
|
||||||
|
Some(super::thought_tag::Relation::Tag.def().rev())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
@@ -3,9 +3,9 @@ use sea_orm::entity::prelude::*;
|
|||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
#[sea_orm(table_name = "thought")]
|
#[sea_orm(table_name = "thought")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
#[sea_orm(primary_key)]
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
pub id: i32,
|
pub id: Uuid,
|
||||||
pub author_id: i32,
|
pub author_id: Uuid,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub created_at: DateTimeWithTimeZone,
|
pub created_at: DateTimeWithTimeZone,
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,9 @@ pub enum Relation {
|
|||||||
on_delete = "Cascade"
|
on_delete = "Cascade"
|
||||||
)]
|
)]
|
||||||
User,
|
User,
|
||||||
|
|
||||||
|
#[sea_orm(has_many = "super::thought_tag::Entity")]
|
||||||
|
ThoughtTag,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Related<super::user::Entity> for Entity {
|
impl Related<super::user::Entity> for Entity {
|
||||||
@@ -28,4 +31,13 @@ impl Related<super::user::Entity> for Entity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Related<super::tag::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
super::thought_tag::Relation::Tag.def()
|
||||||
|
}
|
||||||
|
fn via() -> Option<RelationDef> {
|
||||||
|
Some(super::thought_tag::Relation::Thought.def().rev())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ActiveModelBehavior for ActiveModel {}
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
40
thoughts-backend/models/src/domains/thought_tag.rs
Normal file
40
thoughts-backend/models/src/domains/thought_tag.rs
Normal file
@@ -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<super::thought::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Thought.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::tag::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Tag.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
35
thoughts-backend/models/src/domains/top_friends.rs
Normal file
35
thoughts-backend/models/src/domains/top_friends.rs
Normal file
@@ -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<super::user::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::User.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
@@ -5,14 +5,29 @@ use sea_orm::entity::prelude::*;
|
|||||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
#[sea_orm(table_name = "user")]
|
#[sea_orm(table_name = "user")]
|
||||||
pub struct Model {
|
pub struct Model {
|
||||||
#[sea_orm(primary_key)]
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
pub id: i32,
|
pub id: Uuid,
|
||||||
#[sea_orm(unique)]
|
#[sea_orm(unique)]
|
||||||
pub username: String,
|
pub username: String,
|
||||||
pub password_hash: Option<String>,
|
pub password_hash: Option<String>,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub email: Option<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 created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[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 {}
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
@@ -9,3 +9,26 @@ pub struct CreateUserParams {
|
|||||||
#[validate(length(min = 6))]
|
#[validate(length(min = 6))]
|
||||||
pub password: String,
|
pub password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Validate, ToSchema, Default)]
|
||||||
|
pub struct UpdateUserParams {
|
||||||
|
#[validate(length(max = 50))]
|
||||||
|
#[schema(example = "Frutiger Aero Fan")]
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
|
||||||
|
#[validate(length(max = 160))]
|
||||||
|
#[schema(example = "Est. 2004")]
|
||||||
|
pub bio: Option<String>,
|
||||||
|
|
||||||
|
#[validate(url)]
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
|
||||||
|
#[validate(url)]
|
||||||
|
pub header_url: Option<String>,
|
||||||
|
|
||||||
|
pub custom_css: Option<String>,
|
||||||
|
|
||||||
|
#[validate(length(max = 8))]
|
||||||
|
#[schema(example = json!(["username1", "username2"]))]
|
||||||
|
pub top_friends: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
@@ -3,10 +3,11 @@ use common::DateTimeWithTimeZoneWrapper;
|
|||||||
use sea_orm::FromQueryResult;
|
use sea_orm::FromQueryResult;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema, FromQueryResult, Debug)]
|
#[derive(Serialize, ToSchema, FromQueryResult, Debug)]
|
||||||
pub struct ThoughtSchema {
|
pub struct ThoughtSchema {
|
||||||
pub id: i32,
|
pub id: Uuid,
|
||||||
#[schema(example = "frutiger")]
|
#[schema(example = "frutiger")]
|
||||||
pub author_username: String,
|
pub author_username: String,
|
||||||
#[schema(example = "This is my first thought! #welcome")]
|
#[schema(example = "This is my first thought! #welcome")]
|
||||||
@@ -38,10 +39,10 @@ impl From<Vec<ThoughtSchema>> for ThoughtListSchema {
|
|||||||
|
|
||||||
#[derive(Debug, FromQueryResult)]
|
#[derive(Debug, FromQueryResult)]
|
||||||
pub struct ThoughtWithAuthor {
|
pub struct ThoughtWithAuthor {
|
||||||
pub id: i32,
|
pub id: Uuid,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
pub created_at: sea_orm::prelude::DateTimeWithTimeZone,
|
pub created_at: sea_orm::prelude::DateTimeWithTimeZone,
|
||||||
pub author_id: i32,
|
pub author_id: Uuid,
|
||||||
pub author_username: String,
|
pub author_username: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,12 +1,23 @@
|
|||||||
|
use common::DateTimeWithTimeZoneWrapper;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::domains::user;
|
use crate::domains::user;
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema)]
|
#[derive(Serialize, ToSchema)]
|
||||||
pub struct UserSchema {
|
pub struct UserSchema {
|
||||||
pub id: i32,
|
pub id: Uuid,
|
||||||
pub username: String,
|
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>,
|
||||||
|
// 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<String>,
|
||||||
|
pub joined_at: DateTimeWithTimeZoneWrapper,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<user::Model> for UserSchema {
|
impl From<user::Model> for UserSchema {
|
||||||
@@ -14,6 +25,12 @@ impl From<user::Model> for UserSchema {
|
|||||||
Self {
|
Self {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
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,
|
||||||
|
joined_at: user.created_at.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -48,7 +48,7 @@ pub fn run() {
|
|||||||
|
|
||||||
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");
|
||||||
tracing::debug!("listening on http://{}", listener.local_addr().unwrap());
|
println!("listening on http://{}", listener.local_addr().unwrap());
|
||||||
|
|
||||||
#[cfg(feature = "prefork")]
|
#[cfg(feature = "prefork")]
|
||||||
if config.prefork {
|
if config.prefork {
|
||||||
|
@@ -64,9 +64,9 @@ async fn test_user_actor_endpoint() {
|
|||||||
async fn test_user_inbox_follow() {
|
async fn test_user_inbox_follow() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
// user1 will be followed
|
// 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
|
// 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
|
// Construct a follow activity from user2, targeting user1
|
||||||
let follow_activity = json!({
|
let follow_activity = json!({
|
||||||
@@ -90,16 +90,19 @@ async fn test_user_inbox_follow() {
|
|||||||
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
||||||
|
|
||||||
// Verify that user2 is now following user1 in the database
|
// Verify that user2 is now following user1 in the database
|
||||||
let followers = app::persistence::follow::get_followed_ids(&app.db, 2)
|
let followers = app::persistence::follow::get_followed_ids(&app.db, user2.id)
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert!(followers.contains(&1), "User2 should be following user1");
|
|
||||||
|
|
||||||
let following = app::persistence::follow::get_followed_ids(&app.db, 1)
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
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"
|
"User1 should now be followed by user2"
|
||||||
);
|
);
|
||||||
assert!(following.is_empty(), "User1 should not be following anyone");
|
assert!(following.is_empty(), "User1 should not be following anyone");
|
||||||
|
@@ -20,7 +20,6 @@ async fn test_auth_flow() {
|
|||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
let v: Value = serde_json::from_slice(&body).unwrap();
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(v["username"], "testuser");
|
assert_eq!(v["username"], "testuser");
|
||||||
assert!(v["id"].is_number());
|
|
||||||
|
|
||||||
let response = make_post_request(
|
let response = make_post_request(
|
||||||
app.router.clone(),
|
app.router.clone(),
|
||||||
|
@@ -1,8 +1,7 @@
|
|||||||
use api::setup_router;
|
use api::setup_router;
|
||||||
use app::persistence::user::create_user;
|
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use models::params::{auth::RegisterParams, user::CreateUserParams};
|
use models::{domains::user, params::auth::RegisterParams};
|
||||||
use sea_orm::DatabaseConnection;
|
use sea_orm::DatabaseConnection;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use utils::testing::{make_post_request, setup_test_db};
|
use utils::testing::{make_post_request, setup_test_db};
|
||||||
@@ -13,39 +12,40 @@ pub struct TestApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn setup() -> TestApp {
|
pub async fn setup() -> TestApp {
|
||||||
std::env::set_var("DATABASE_URL", "sqlite::memory:");
|
std::env::set_var(
|
||||||
|
"MANAGEMENT_DATABASE_URL",
|
||||||
|
"postgres://postgres:postgres@localhost:5434/postgres",
|
||||||
|
);
|
||||||
|
std::env::set_var(
|
||||||
|
"DATABASE_URL",
|
||||||
|
"postgres://postgres:postgres@localhost:5434/postgres",
|
||||||
|
);
|
||||||
std::env::set_var("AUTH_SECRET", "test_secret");
|
std::env::set_var("AUTH_SECRET", "test_secret");
|
||||||
std::env::set_var("BASE_URL", "http://localhost:3000");
|
std::env::set_var("BASE_URL", "http://localhost:3000");
|
||||||
std::env::set_var("HOST", "localhost");
|
std::env::set_var("HOST", "localhost");
|
||||||
std::env::set_var("PORT", "3000");
|
std::env::set_var("PORT", "3000");
|
||||||
std::env::set_var("LOG_LEVEL", "debug");
|
std::env::set_var("LOG_LEVEL", "debug");
|
||||||
|
|
||||||
let db = setup_test_db("sqlite::memory:")
|
let db = setup_test_db().await.expect("Failed to set up test db");
|
||||||
.await
|
|
||||||
.expect("Failed to set up test db");
|
let db = db.clone();
|
||||||
|
|
||||||
let router = setup_router(db.clone(), &app::config::Config::from_env());
|
let router = setup_router(db.clone(), &app::config::Config::from_env());
|
||||||
TestApp { router, db }
|
TestApp { router, db }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to create users for tests
|
pub async fn create_user_with_password(
|
||||||
pub async fn create_test_user(db: &DatabaseConnection, username: &str) {
|
db: &DatabaseConnection,
|
||||||
let params = CreateUserParams {
|
username: &str,
|
||||||
username: username.to_string(),
|
password: &str,
|
||||||
password: "password".to_string(),
|
) -> user::Model {
|
||||||
};
|
|
||||||
create_user(db, params)
|
|
||||||
.await
|
|
||||||
.expect("Failed to create test user");
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_user_with_password(db: &DatabaseConnection, username: &str, password: &str) {
|
|
||||||
let params = RegisterParams {
|
let params = RegisterParams {
|
||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
password: password.to_string(),
|
password: password.to_string(),
|
||||||
};
|
};
|
||||||
app::persistence::auth::register_user(db, params)
|
app::persistence::auth::register_user(db, params)
|
||||||
.await
|
.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 {
|
pub async fn login_user(router: Router, username: &str, password: &str) -> String {
|
||||||
|
@@ -3,5 +3,6 @@ mod auth;
|
|||||||
mod feed;
|
mod feed;
|
||||||
mod follow;
|
mod follow;
|
||||||
mod main;
|
mod main;
|
||||||
|
mod tag;
|
||||||
mod thought;
|
mod thought;
|
||||||
mod user;
|
mod user;
|
||||||
|
51
thoughts-backend/tests/api/tag.rs
Normal file
51
thoughts-backend/tests/api/tag.rs
Normal file
@@ -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);
|
||||||
|
}
|
@@ -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 axum::http::StatusCode;
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
|
use sea_orm::prelude::Uuid;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use utils::testing::{make_delete_request, make_post_request};
|
use utils::testing::{make_delete_request, make_post_request};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_thought_endpoints() {
|
async fn test_thought_endpoints() {
|
||||||
let app = setup().await;
|
let app = setup().await;
|
||||||
create_test_user(&app.db, "user1").await; // AuthUser is ID 1
|
let user1 = create_user_with_password(&app.db, "user1", "password123").await; // AuthUser is ID 1
|
||||||
create_test_user(&app.db, "user2").await; // Other user is ID 2
|
let _user2 = create_user_with_password(&app.db, "user2", "password123").await; // Other user is ID 2
|
||||||
|
|
||||||
// 1. Post a new thought as user 1
|
// 1. Post a new thought as user 1
|
||||||
let body = json!({ "content": "My first thought!" }).to_string();
|
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);
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
assert_eq!(v["content"], "My first thought!");
|
assert_eq!(v["content"], "My first thought!");
|
||||||
assert_eq!(v["author_username"], "user1");
|
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
|
// 2. Post a thought with invalid content
|
||||||
let body = json!({ "content": "" }).to_string(); // Too short
|
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);
|
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)
|
// 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 =
|
let response = make_delete_request(
|
||||||
make_delete_request(app.router.clone(), &format!("/thoughts/999"), Some(1)).await;
|
app.router.clone(),
|
||||||
|
&format!("/thoughts/{}", Uuid::new_v4()),
|
||||||
|
Some(user1.id),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
// 4. Delete the thought created in step 1
|
// 4. Delete the thought created in step 1
|
||||||
let response = make_delete_request(
|
let response = make_delete_request(
|
||||||
app.router.clone(),
|
app.router.clone(),
|
||||||
&format!("/thoughts/{}", thought_id),
|
&format!("/thoughts/{}", thought_id),
|
||||||
Some(1),
|
Some(user1.id),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||||
|
@@ -1,10 +1,12 @@
|
|||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
use serde_json::Value;
|
use models::domains::top_friends;
|
||||||
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use utils::testing::{make_get_request, make_post_request};
|
use utils::testing::{make_get_request, make_jwt_request, make_post_request};
|
||||||
|
|
||||||
use crate::api::main::setup;
|
use crate::api::main::{create_user_with_password, login_user, setup};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_post_users() {
|
async fn test_post_users() {
|
||||||
@@ -16,7 +18,10 @@ async fn test_post_users() {
|
|||||||
assert_eq!(response.status(), StatusCode::CREATED);
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
assert_eq!(&body[..], br#"{"id":1,"username":"test"}"#);
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(v["username"], "test");
|
||||||
|
assert!(v["display_name"].is_null());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -26,7 +31,6 @@ pub(super) async fn test_post_users_error() {
|
|||||||
let body = r#"{"username": "1", "password": "password123"}"#.to_owned();
|
let body = r#"{"username": "1", "password": "password123"}"#.to_owned();
|
||||||
let response = make_post_request(app.router, "/auth/register", body, None).await;
|
let response = make_post_request(app.router, "/auth/register", body, None).await;
|
||||||
|
|
||||||
println!("{:?}", response);
|
|
||||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||||
|
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
@@ -46,5 +50,136 @@ pub async fn test_get_users() {
|
|||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
let body = response.into_body().collect().await.unwrap().to_bytes();
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
assert_eq!(&body[..], br#"{"users":[{"id":1,"username":"test"}]}"#);
|
let v: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
|
||||||
|
assert!(v["users"].is_array());
|
||||||
|
let users_array = v["users"].as_array().unwrap();
|
||||||
|
assert_eq!(users_array.len(), 1);
|
||||||
|
assert_eq!(users_array[0]["username"], "test");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_me_endpoints() {
|
||||||
|
let app = setup().await;
|
||||||
|
|
||||||
|
// 1. Register a new user
|
||||||
|
let register_body = json!({
|
||||||
|
"username": "me_user",
|
||||||
|
"password": "password123"
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let response =
|
||||||
|
make_post_request(app.router.clone(), "/auth/register", register_body, None).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::CREATED);
|
||||||
|
|
||||||
|
// 2. Log in to get a token
|
||||||
|
let token = login_user(app.router.clone(), "me_user", "password123").await;
|
||||||
|
|
||||||
|
// 3. GET /users/me to fetch initial profile
|
||||||
|
let response = make_jwt_request(app.router.clone(), "/users/me", "GET", None, &token).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
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());
|
||||||
|
|
||||||
|
// 4. PUT /users/me to update the profile
|
||||||
|
let update_body = json!({
|
||||||
|
"display_name": "Me User",
|
||||||
|
"bio": "This is my updated bio.",
|
||||||
|
"avatar_url": "https://example.com/avatar.png"
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let response = make_jwt_request(
|
||||||
|
app.router.clone(),
|
||||||
|
"/users/me",
|
||||||
|
"PUT",
|
||||||
|
Some(update_body),
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v_updated: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(v_updated["display_name"], "Me User");
|
||||||
|
assert_eq!(v_updated["bio"], "This is my updated bio.");
|
||||||
|
|
||||||
|
// 5. GET /users/me again to verify the update was persisted
|
||||||
|
let response = make_jwt_request(app.router.clone(), "/users/me", "GET", None, &token).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = response.into_body().collect().await.unwrap().to_bytes();
|
||||||
|
let v_verify: Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
@@ -6,9 +6,15 @@ use user::test_user;
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn user_main() {
|
async fn user_main() {
|
||||||
let db = setup_test_db("sqlite::memory:")
|
std::env::set_var(
|
||||||
.await
|
"MANAGEMENT_DATABASE_URL",
|
||||||
.expect("Set up db failed!");
|
"postgres://postgres:postgres@localhost:5434/postgres",
|
||||||
|
);
|
||||||
|
std::env::set_var(
|
||||||
|
"DATABASE_URL",
|
||||||
|
"postgres://postgres:postgres@localhost:5434/postgres",
|
||||||
|
);
|
||||||
|
let db = setup_test_db().await.expect("Failed to set up test db");
|
||||||
|
|
||||||
test_user(&db).await;
|
test_user(&db).await;
|
||||||
}
|
}
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
use sea_orm::{DatabaseConnection, Unchanged};
|
use sea_orm::{DatabaseConnection, TryIntoModel};
|
||||||
|
|
||||||
use app::persistence::user::create_user;
|
use app::persistence::user::create_user;
|
||||||
use models::domains::user;
|
|
||||||
use models::params::user::CreateUserParams;
|
use models::params::user::CreateUserParams;
|
||||||
|
|
||||||
pub(super) async fn test_user(db: &DatabaseConnection) {
|
pub(super) async fn test_user(db: &DatabaseConnection) {
|
||||||
@@ -9,13 +8,11 @@ pub(super) async fn test_user(db: &DatabaseConnection) {
|
|||||||
username: "test".to_string(),
|
username: "test".to_string(),
|
||||||
password: "password".to_string(),
|
password: "password".to_string(),
|
||||||
};
|
};
|
||||||
|
let user_model = create_user(db, params)
|
||||||
|
.await
|
||||||
|
.expect("Create user failed!")
|
||||||
|
.try_into_model() // Convert ActiveModel to Model for easier checks
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let user = create_user(db, params).await.expect("Create user failed!");
|
assert_eq!(user_model.username, "test");
|
||||||
let expected = user::ActiveModel {
|
|
||||||
id: Unchanged(1),
|
|
||||||
username: Unchanged("test".to_owned()),
|
|
||||||
password_hash: Unchanged(None),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
assert_eq!(user, expected);
|
|
||||||
}
|
}
|
||||||
|
@@ -10,7 +10,11 @@ path = "src/lib.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
migration = { path = "../migration" }
|
migration = { path = "../migration" }
|
||||||
|
uuid = { version = "1.18.1", features = ["v4", "serde"] }
|
||||||
|
sea-orm = { version = "1.1.12", features = ["sqlx-sqlite", "sqlx-postgres"] }
|
||||||
|
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
tower = { workspace = true, features = ["util"] }
|
tower = { workspace = true, features = ["util"] }
|
||||||
sea-orm = { workspace = true, features = ["sqlx-sqlite", "sqlx-postgres"] }
|
|
||||||
|
|
||||||
|
tokio = { workspace = true }
|
||||||
|
@@ -5,8 +5,9 @@ use axum::{
|
|||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub async fn make_get_request(app: Router, url: &str, user_id: Option<i32>) -> Response {
|
pub async fn make_get_request(app: Router, url: &str, user_id: Option<Uuid>) -> Response {
|
||||||
let mut builder = Request::builder()
|
let mut builder = Request::builder()
|
||||||
.uri(url)
|
.uri(url)
|
||||||
.header("Content-Type", "application/json");
|
.header("Content-Type", "application/json");
|
||||||
@@ -24,7 +25,7 @@ pub async fn make_post_request(
|
|||||||
app: Router,
|
app: Router,
|
||||||
url: &str,
|
url: &str,
|
||||||
body: String,
|
body: String,
|
||||||
user_id: Option<i32>,
|
user_id: Option<Uuid>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let mut builder = Request::builder()
|
let mut builder = Request::builder()
|
||||||
.method("POST")
|
.method("POST")
|
||||||
@@ -40,7 +41,7 @@ pub async fn make_post_request(
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn make_delete_request(app: Router, url: &str, user_id: Option<i32>) -> Response {
|
pub async fn make_delete_request(app: Router, url: &str, user_id: Option<Uuid>) -> Response {
|
||||||
let mut builder = Request::builder()
|
let mut builder = Request::builder()
|
||||||
.method("DELETE")
|
.method("DELETE")
|
||||||
.uri(url)
|
.uri(url)
|
||||||
|
@@ -1,9 +1,27 @@
|
|||||||
use sea_orm::{Database, DatabaseConnection, DbErr};
|
use sea_orm::{ConnectionTrait, Database, DatabaseConnection, DbBackend, DbErr, Statement};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::migrate;
|
use crate::migrate;
|
||||||
|
|
||||||
pub async fn setup_test_db(db_url: &str) -> Result<DatabaseConnection, DbErr> {
|
pub async fn setup_test_db() -> Result<DatabaseConnection, DbErr> {
|
||||||
let db = Database::connect(db_url).await?;
|
let mgmt_db_url = std::env::var("MANAGEMENT_DATABASE_URL")
|
||||||
migrate(&db).await?;
|
.expect("MANAGEMENT_DATABASE_URL must be set for tests");
|
||||||
Ok(db)
|
let db_name = format!("test_db_{}", Uuid::new_v4().simple());
|
||||||
|
let (base_url, _) = mgmt_db_url
|
||||||
|
.rsplit_once('/')
|
||||||
|
.expect("MANAGEMENT_DATABASE_URL must include a database name, e.g., '/postgres'");
|
||||||
|
|
||||||
|
let db = Database::connect(&mgmt_db_url).await?;
|
||||||
|
db.execute(Statement::from_string(
|
||||||
|
DbBackend::Postgres,
|
||||||
|
format!(r#"CREATE DATABASE "{}";"#, db_name),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 2. Connect to the new test DB and run migrations
|
||||||
|
let new_db_url = format!("{}/{}", base_url, db_name);
|
||||||
|
let conn = Database::connect(&new_db_url).await?;
|
||||||
|
migrate(&conn).await?;
|
||||||
|
|
||||||
|
Ok(conn)
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user