feat: Refactor user and thought models to use UUIDs instead of integers

- Updated user and thought models to utilize UUIDs for primary keys.
- Modified persistence functions to accommodate UUIDs for user and thought IDs.
- Implemented tag functionality with new Tag and ThoughtTag models.
- Added migration scripts to create new tables for tags and thought-tag relationships.
- Enhanced thought creation to parse hashtags and link them to thoughts.
- Updated tests to reflect changes in user and thought ID types.
This commit is contained in:
2025-09-06 15:29:38 +02:00
parent c9e99e6f23
commit b83b7acf1c
38 changed files with 638 additions and 107 deletions

View File

@@ -2408,6 +2408,7 @@ dependencies = [
"serde",
"serde_json",
"utoipa",
"uuid",
"validator",
]
@@ -4894,6 +4895,7 @@ dependencies = [
"quote",
"regex",
"syn 2.0.104",
"uuid",
]
[[package]]

View File

@@ -21,7 +21,7 @@ sea-query = { version = "0.32.6" } # Added sea-quer
serde = { version = "1.0.219", features = ["derive"] }
serde_json = { version = "1.0.140", features = ["raw_value"] }
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 }
chrono = { version = "0.4.41", features = ["serde"] }
tokio = { version = "1.45.1", features = ["full"] }

View File

@@ -5,13 +5,14 @@ use axum::{
use jsonwebtoken::{decode, DecodingKey, Validation};
use once_cell::sync::Lazy;
use sea_orm::prelude::Uuid;
use serde::{Deserialize, Serialize};
use app::state::AppState;
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: i32,
pub sub: Uuid,
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"));
pub struct AuthUser {
pub id: i32,
pub id: Uuid,
}
impl FromRequestParts<AppState> for AuthUser {
@@ -31,7 +32,7 @@ impl FromRequestParts<AppState> for AuthUser {
) -> Result<Self, Self::Rejection> {
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 = 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 });
}

View File

@@ -3,6 +3,7 @@ use axum::Router;
pub mod auth;
pub mod feed;
pub mod root;
pub mod tag;
pub mod thought;
pub mod user;
pub mod well_known;
@@ -25,6 +26,7 @@ pub fn create_router(state: AppState) -> Router {
.nest("/users", create_user_router())
.nest("/thoughts", create_thought_router())
.nest("/feed", create_feed_router())
.nest("/tags", tag::create_tag_router())
.with_state(state)
.layer(cors)
}

View 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))
}

View File

@@ -12,6 +12,7 @@ use app::{
state::AppState,
};
use models::{params::thought::CreateThoughtParams, schemas::thought::ThoughtSchema};
use sea_orm::prelude::Uuid;
use crate::{
error::ApiError,
@@ -74,7 +75,7 @@ async fn thoughts_post(
async fn thoughts_delete(
State(state): State<AppState>,
auth_user: AuthUser,
Path(id): Path<i32>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
let thought = get_thought(&state.conn, id)
.await?

View File

@@ -5,6 +5,7 @@ use axum::{
routing::{get, post},
Router,
};
use sea_orm::prelude::Uuid;
use serde_json::{json, Value};
use app::persistence::{
@@ -201,7 +202,7 @@ async fn get_user_by_param(
Path(param): Path<String>,
) -> Response {
// 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 {
Ok(Some(user)) => Json(UserSchema::from(user)).into_response(),
Ok(None) => ApiError::from(UserError::NotFound).into_response(),

View File

@@ -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 models::domains::follow;
pub async fn add_follower(
db: &DbConn,
followed_id: i32,
followed_id: Uuid,
follower_actor_id: &str,
) -> Result<(), UserError> {
let follower_username = follower_actor_id
@@ -25,7 +27,7 @@ pub async fn add_follower(
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 {
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(
db: &DbConn,
follower_id: i32,
followed_id: i32,
follower_id: Uuid,
followed_id: Uuid,
) -> Result<(), UserError> {
let deleted_result = follow::Entity::delete_many()
.filter(follow::Column::FollowerId.eq(follower_id))
@@ -58,7 +60,7 @@ pub async fn unfollow_user(
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()
.filter(follow::Column::FollowerId.eq(user_id))
.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())
}
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()
.filter(follow::Column::FollowedId.eq(user_id))
.all(db)

View File

@@ -1,4 +1,5 @@
pub mod auth;
pub mod follow;
pub mod tag;
pub mod thought;
pub mod user;

View 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(())
}

View File

@@ -1,42 +1,56 @@
use sea_orm::{
ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType, QueryFilter, QueryOrder,
QuerySelect, RelationTrait, Set,
prelude::Uuid, ActiveModelTrait, ColumnTrait, DbConn, DbErr, EntityTrait, JoinType,
QueryFilter, QueryOrder, QuerySelect, RelationTrait, Set, TransactionTrait,
};
use models::{
domains::{thought, user},
domains::{tag, thought, thought_tag, user},
params::thought::CreateThoughtParams,
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(
db: &DbConn,
author_id: i32,
author_id: Uuid,
params: CreateThoughtParams,
) -> Result<thought::Model, DbErr> {
thought::ActiveModel {
let txn = db.begin().await?;
let new_thought = thought::ActiveModel {
author_id: Set(author_id),
content: Set(params.content),
content: Set(params.content.clone()),
..Default::default()
}
.insert(db)
.await
.insert(&txn)
.await?;
let tag_names = parse_hashtags(&params.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
}
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?;
Ok(())
}
pub async fn get_thoughts_by_user(
db: &DbConn,
user_id: i32,
user_id: Uuid,
) -> Result<Vec<ThoughtWithAuthor>, DbErr> {
thought::Entity::find()
.select_only()
@@ -55,7 +69,7 @@ pub async fn get_thoughts_by_user(
pub async fn get_feed_for_user(
db: &DbConn,
followed_ids: Vec<i32>,
followed_ids: Vec<Uuid>,
) -> Result<Vec<ThoughtWithAuthor>, UserError> {
if followed_ids.is_empty() {
return Ok(vec![]);
@@ -76,3 +90,24 @@ pub async fn get_feed_for_user(
.await
.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
}

View File

@@ -1,8 +1,9 @@
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, UpdateUserParams};
use models::queries::user::UserQuery;
@@ -27,7 +28,7 @@ pub async fn search_users(db: &DbConn, query: UserQuery) -> Result<Vec<user::Mod
.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
}
@@ -41,7 +42,7 @@ pub async fn get_user_by_username(
.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()
.filter(user::Column::Id.is_in(ids))
.all(db)
@@ -50,7 +51,7 @@ pub async fn get_users_by_ids(db: &DbConn, ids: Vec<i32>) -> Result<Vec<user::Mo
pub async fn update_user_profile(
db: &DbConn,
user_id: i32,
user_id: Uuid,
params: UpdateUserParams,
) -> Result<user::Model, UserError> {
let mut user: user::ActiveModel = get_user(db, user_id)
@@ -75,26 +76,47 @@ pub async fn update_user_profile(
user.custom_css = Set(Some(custom_css));
}
// This is a complex operation, so we use a transaction
if let Some(friend_usernames) = params.top_friends {
let txn = db
.begin()
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
// 1. Delete old top friends
// In a real app, you would create a `top_friends` entity and use it here.
// For now, we'll skip this to avoid creating the model.
top_friends::Entity::delete_many()
.filter(top_friends::Column::UserId.eq(user_id))
.exec(&txn)
.await
.map_err(|e| UserError::Internal(e.to_string()))?;
// 2. Find new friends by username
let _friends = user::Entity::find()
.filter(user::Column::Username.is_in(friend_usernames))
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()))?;
// 3. Insert new friends
// This part would involve inserting into the `top_friends` table.
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

View File

@@ -3,6 +3,7 @@ pub use sea_orm_migration::prelude::*;
mod m20240101_000001_init;
mod m20250905_000001_init;
mod m20250906_100000_add_profile_fields;
mod m20250906_130237_add_tags;
pub struct Migrator;
@@ -13,6 +14,7 @@ impl MigratorTrait for Migrator {
Box::new(m20240101_000001_init::Migration),
Box::new(m20250905_000001_init::Migration),
Box::new(m20250906_100000_add_profile_fields::Migration),
Box::new(m20250906_130237_add_tags::Migration),
]
}
}

View File

@@ -13,10 +13,10 @@ impl MigrationTrait for Migration {
.if_not_exists()
.col(
ColumnDef::new(User::Id)
.integer()
.uuid()
.not_null()
.auto_increment()
.primary_key(),
.primary_key()
.default(Expr::cust("gen_random_uuid()")),
)
.col(
ColumnDef::new(User::Username)

View File

@@ -13,8 +13,14 @@ impl MigrationTrait for Migration {
Table::create()
.table(Thought::Table)
.if_not_exists()
.col(pk_auto(Thought::Id))
.col(integer(Thought::AuthorId).not_null())
.col(
ColumnDef::new(Thought::Id)
.uuid()
.not_null()
.primary_key()
.default(Expr::cust("gen_random_uuid()")),
)
.col(uuid(Thought::AuthorId).not_null())
.foreign_key(
ForeignKey::create()
.name("fk_thought_author_id")
@@ -39,8 +45,8 @@ impl MigrationTrait for Migration {
Table::create()
.table(Follow::Table)
.if_not_exists()
.col(integer(Follow::FollowerId).not_null())
.col(integer(Follow::FollowedId).not_null())
.col(uuid(Follow::FollowerId).not_null())
.col(uuid(Follow::FollowedId).not_null())
// Composite Primary Key to ensure a user can only follow another once
.primary_key(
Index::create()
@@ -77,7 +83,7 @@ impl MigrationTrait for Migration {
}
#[derive(DeriveIden)]
enum Thought {
pub enum Thought {
Table,
Id,
AuthorId,
@@ -86,7 +92,7 @@ enum Thought {
}
#[derive(DeriveIden)]
enum Follow {
pub enum Follow {
Table,
// The user who is initiating the follow
FollowerId,

View File

@@ -36,8 +36,8 @@ impl MigrationTrait for Migration {
Table::create()
.table(TopFriends::Table)
.if_not_exists()
.col(integer(TopFriends::UserId).not_null())
.col(integer(TopFriends::FriendId).not_null())
.col(uuid(TopFriends::UserId).not_null())
.col(uuid(TopFriends::FriendId).not_null())
.col(small_integer(TopFriends::Position).not_null())
.primary_key(
Index::create()

View 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,
}

View File

@@ -17,6 +17,7 @@ sea-orm = { workspace = true, features = [
"runtime-tokio-rustls",
"macros",
] }
uuid = { version = "1.18.1", features = ["v4", "serde"] }
validator = { workspace = true, features = ["derive"] }
utoipa = { workspace = true }

View File

@@ -3,10 +3,10 @@ use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "follow")]
pub struct Model {
#[sea_orm(primary_key)]
pub follower_id: i32,
#[sea_orm(primary_key)]
pub followed_id: i32,
#[sea_orm(primary_key, auto_increment = false)]
pub follower_id: Uuid,
#[sea_orm(primary_key, auto_increment = false)]
pub followed_id: Uuid,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -29,4 +29,10 @@ pub enum Relation {
Followed,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::Follower.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -3,5 +3,8 @@
pub mod prelude;
pub mod follow;
pub mod tag;
pub mod thought;
pub mod thought_tag;
pub mod top_friends;
pub mod user;

View File

@@ -1,5 +1,8 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.0
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_tag::Entity as ThoughtTag;
pub use super::top_friends::Entity as TopFriends;
pub use super::user::Entity as User;

View 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 {}

View File

@@ -3,9 +3,9 @@ use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "thought")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
pub author_id: i32,
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub author_id: Uuid,
pub content: String,
pub created_at: DateTimeWithTimeZone,
}
@@ -20,6 +20,9 @@ pub enum Relation {
on_delete = "Cascade"
)]
User,
#[sea_orm(has_many = "super::thought_tag::Entity")]
ThoughtTag,
}
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 {}

View 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 {}

View 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 {}

View File

@@ -5,8 +5,8 @@ use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "user")]
pub struct Model {
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
#[sea_orm(unique)]
pub username: String,
pub password_hash: Option<String>,
@@ -22,6 +22,12 @@ pub struct Model {
}
#[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 {}

View File

@@ -3,10 +3,11 @@ use common::DateTimeWithTimeZoneWrapper;
use sea_orm::FromQueryResult;
use serde::Serialize;
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Serialize, ToSchema, FromQueryResult, Debug)]
pub struct ThoughtSchema {
pub id: i32,
pub id: Uuid,
#[schema(example = "frutiger")]
pub author_username: String,
#[schema(example = "This is my first thought! #welcome")]
@@ -38,10 +39,10 @@ impl From<Vec<ThoughtSchema>> for ThoughtListSchema {
#[derive(Debug, FromQueryResult)]
pub struct ThoughtWithAuthor {
pub id: i32,
pub id: Uuid,
pub content: String,
pub created_at: sea_orm::prelude::DateTimeWithTimeZone,
pub author_id: i32,
pub author_id: Uuid,
pub author_username: String,
}

View File

@@ -1,12 +1,13 @@
use common::DateTimeWithTimeZoneWrapper;
use serde::Serialize;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::domains::user;
#[derive(Serialize, ToSchema)]
pub struct UserSchema {
pub id: i32,
pub id: Uuid,
pub username: String,
pub display_name: Option<String>,
pub bio: Option<String>,

View File

@@ -64,9 +64,9 @@ async fn test_user_actor_endpoint() {
async fn test_user_inbox_follow() {
let app = setup().await;
// 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
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
let follow_activity = json!({
@@ -90,16 +90,19 @@ async fn test_user_inbox_follow() {
assert_eq!(response.status(), StatusCode::ACCEPTED);
// Verify that user2 is now following user1 in the database
let followers = app::persistence::follow::get_followed_ids(&app.db, 2)
.await
.unwrap();
assert!(followers.contains(&1), "User2 should be following user1");
let following = app::persistence::follow::get_followed_ids(&app.db, 1)
let followers = app::persistence::follow::get_followed_ids(&app.db, user2.id)
.await
.unwrap();
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"
);
assert!(following.is_empty(), "User1 should not be following anyone");

View File

@@ -20,7 +20,6 @@ async fn test_auth_flow() {
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["username"], "testuser");
assert!(v["id"].is_number());
let response = make_post_request(
app.router.clone(),

View File

@@ -1,8 +1,7 @@
use api::setup_router;
use app::persistence::user::create_user;
use axum::Router;
use http_body_util::BodyExt;
use models::params::{auth::RegisterParams, user::CreateUserParams};
use models::{domains::user, params::auth::RegisterParams};
use sea_orm::DatabaseConnection;
use serde_json::{json, Value};
use utils::testing::{make_post_request, setup_test_db};
@@ -35,25 +34,18 @@ pub async fn setup() -> TestApp {
TestApp { router, db }
}
// Helper to create users for tests
pub async fn create_test_user(db: &DatabaseConnection, username: &str) {
let params = CreateUserParams {
username: username.to_string(),
password: "password".to_string(),
};
create_user(db, params)
.await
.expect("Failed to create test user");
}
pub async fn create_user_with_password(db: &DatabaseConnection, username: &str, password: &str) {
pub async fn create_user_with_password(
db: &DatabaseConnection,
username: &str,
password: &str,
) -> user::Model {
let params = RegisterParams {
username: username.to_string(),
password: password.to_string(),
};
app::persistence::auth::register_user(db, params)
.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 {

View File

@@ -3,5 +3,6 @@ mod auth;
mod feed;
mod follow;
mod main;
mod tag;
mod thought;
mod user;

View 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);
}

View File

@@ -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 http_body_util::BodyExt;
use sea_orm::prelude::Uuid;
use serde_json::json;
use utils::testing::{make_delete_request, make_post_request};
#[tokio::test]
async fn test_thought_endpoints() {
let app = setup().await;
create_test_user(&app.db, "user1").await; // AuthUser is ID 1
create_test_user(&app.db, "user2").await; // Other user is ID 2
let user1 = create_user_with_password(&app.db, "user1", "password123").await; // AuthUser is ID 1
let _user2 = create_user_with_password(&app.db, "user2", "password123").await; // Other user is ID 2
// 1. Post a new thought as user 1
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);
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["content"], "My first thought!");
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
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);
// 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 =
make_delete_request(app.router.clone(), &format!("/thoughts/999"), Some(1)).await;
let response = make_delete_request(
app.router.clone(),
&format!("/thoughts/{}", Uuid::new_v4()),
Some(user1.id),
)
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
// 4. Delete the thought created in step 1
let response = make_delete_request(
app.router.clone(),
&format!("/thoughts/{}", thought_id),
Some(1),
Some(user1.id),
)
.await;
assert_eq!(response.status(), StatusCode::NO_CONTENT);

View File

@@ -1,10 +1,12 @@
use axum::http::StatusCode;
use http_body_util::BodyExt;
use models::domains::top_friends;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use serde_json::{json, Value};
use utils::testing::{make_get_request, make_jwt_request, make_post_request};
use crate::api::main::{login_user, setup};
use crate::api::main::{create_user_with_password, login_user, setup};
#[tokio::test]
async fn test_post_users() {
@@ -18,7 +20,6 @@ async fn test_post_users() {
let body = response.into_body().collect().await.unwrap().to_bytes();
let v: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(v["id"], 1);
assert_eq!(v["username"], "test");
assert!(v["display_name"].is_null());
}
@@ -54,7 +55,6 @@ pub async fn test_get_users() {
assert!(v["users"].is_array());
let users_array = v["users"].as_array().unwrap();
assert_eq!(users_array.len(), 1);
assert_eq!(users_array[0]["id"], 1);
assert_eq!(users_array[0]["username"], "test");
}
@@ -113,3 +113,73 @@ async fn test_me_endpoints() {
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);
}

View File

@@ -14,6 +14,5 @@ pub(super) async fn test_user(db: &DatabaseConnection) {
.try_into_model() // Convert ActiveModel to Model for easier checks
.unwrap();
assert_eq!(user_model.id, 1);
assert_eq!(user_model.username, "test");
}

View File

@@ -10,7 +10,7 @@ path = "src/lib.rs"
[dependencies]
migration = { path = "../migration" }
uuid = { version = "1.18.1", features = ["v4"] }
uuid = { version = "1.18.1", features = ["v4", "serde"] }
sea-orm = { version = "1.1.12", features = ["sqlx-sqlite", "sqlx-postgres"] }
axum = { workspace = true }

View File

@@ -5,8 +5,9 @@ use axum::{
Router,
};
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()
.uri(url)
.header("Content-Type", "application/json");
@@ -24,7 +25,7 @@ pub async fn make_post_request(
app: Router,
url: &str,
body: String,
user_id: Option<i32>,
user_id: Option<Uuid>,
) -> Response {
let mut builder = Request::builder()
.method("POST")
@@ -40,7 +41,7 @@ pub async fn make_post_request(
.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()
.method("DELETE")
.uri(url)