From 6d0b1a3121d44dd7367bde141ecde4caee31de63 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 29 May 2026 14:17:41 +0200 Subject: [PATCH] refactor: eliminate User/UserResponse struct literals, add AP user tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Feed/search adapters use #[sqlx(flatten)] UserRow instead of inline User construction — single point of change when User gains fields - User::new_remote constructor replaces struct literal in testing - to_summary_response replaces inline UserResponse in get_users - 5 integration tests for PgApUserRepository (find, count, profile_fields→attachment) --- .../adapters/postgres-federation/migrations | 1 + .../adapters/postgres-federation/src/lib.rs | 88 +++++++++++++++++++ crates/adapters/postgres-search/src/lib.rs | 52 +++-------- crates/adapters/postgres-search/src/tests.rs | 1 + crates/adapters/postgres/src/feed/mod.rs | 46 +++------- crates/adapters/postgres/src/feed/tests.rs | 1 + crates/application/src/testing.rs | 22 ++--- crates/domain/src/models/user.rs | 19 ++++ crates/presentation/src/handlers/auth.rs | 17 ++++ crates/presentation/src/handlers/users/mod.rs | 20 +---- 10 files changed, 160 insertions(+), 107 deletions(-) create mode 120000 crates/adapters/postgres-federation/migrations diff --git a/crates/adapters/postgres-federation/migrations b/crates/adapters/postgres-federation/migrations new file mode 120000 index 0000000..a9ede8e --- /dev/null +++ b/crates/adapters/postgres-federation/migrations @@ -0,0 +1 @@ +../postgres/migrations \ No newline at end of file diff --git a/crates/adapters/postgres-federation/src/lib.rs b/crates/adapters/postgres-federation/src/lib.rs index 5143bef..00e83eb 100644 --- a/crates/adapters/postgres-federation/src/lib.rs +++ b/crates/adapters/postgres-federation/src/lib.rs @@ -832,3 +832,91 @@ impl ApUserRepository for PgApUserRepository { Ok(n as usize) } } + +#[cfg(test)] +mod tests { + use super::*; + use k_ap::ApUserRepository; + + async fn seed_local_user(pool: &PgPool, username: &str) -> uuid::Uuid { + let id = uuid::Uuid::new_v4(); + sqlx::query( + "INSERT INTO users (id,username,email,password_hash,local,created_at,updated_at) + VALUES ($1,$2,$3,'h',true,NOW(),NOW())", + ) + .bind(id) + .bind(username) + .bind(format!("{username}@test.com")) + .execute(pool) + .await + .unwrap(); + id + } + + #[sqlx::test(migrations = "./migrations")] + async fn find_by_id_returns_local_user(pool: PgPool) { + let id = seed_local_user(&pool, "alice").await; + let repo = PgApUserRepository::new(pool, "https://example.com".into()); + + let user = repo.find_by_id(id).await.unwrap().unwrap(); + assert_eq!(user.username, "alice"); + assert!(user.attachment.is_empty()); + } + + #[sqlx::test(migrations = "./migrations")] + async fn find_by_username_returns_local_user(pool: PgPool) { + seed_local_user(&pool, "bob").await; + let repo = PgApUserRepository::new(pool, "https://example.com".into()); + + let user = repo.find_by_username("bob").await.unwrap().unwrap(); + assert_eq!(user.username, "bob"); + } + + #[sqlx::test(migrations = "./migrations")] + async fn find_by_id_returns_none_for_missing(pool: PgPool) { + let repo = PgApUserRepository::new(pool, "https://example.com".into()); + let result = repo.find_by_id(uuid::Uuid::new_v4()).await.unwrap(); + assert!(result.is_none()); + } + + #[sqlx::test(migrations = "./migrations")] + async fn profile_fields_map_to_attachment(pool: PgPool) { + let id = seed_local_user(&pool, "carol").await; + let fields = serde_json::json!([ + {"name": "Website", "value": "https://carol.dev"}, + {"name": "Pronouns", "value": "she/her"} + ]); + sqlx::query("UPDATE users SET profile_fields = $2 WHERE id = $1") + .bind(id) + .bind(&fields) + .execute(&pool) + .await + .unwrap(); + + let repo = PgApUserRepository::new(pool, "https://example.com".into()); + let user = repo.find_by_id(id).await.unwrap().unwrap(); + + assert_eq!(user.attachment.len(), 2); + assert_eq!(user.attachment[0].name, "Website"); + assert_eq!(user.attachment[0].value, "https://carol.dev"); + assert_eq!(user.attachment[1].name, "Pronouns"); + assert_eq!(user.attachment[1].value, "she/her"); + } + + #[sqlx::test(migrations = "./migrations")] + async fn count_users_counts_local_only(pool: PgPool) { + seed_local_user(&pool, "local1").await; + seed_local_user(&pool, "local2").await; + sqlx::query( + "INSERT INTO users (id,username,email,password_hash,local,created_at,updated_at) + VALUES ($1,'remote','r@r.com','h',false,NOW(),NOW())", + ) + .bind(uuid::Uuid::new_v4()) + .execute(&pool) + .await + .unwrap(); + + let repo = PgApUserRepository::new(pool, "https://example.com".into()); + assert_eq!(repo.count_users().await.unwrap(), 2); + } +} diff --git a/crates/adapters/postgres-search/src/lib.rs b/crates/adapters/postgres-search/src/lib.rs index 229c355..7f0ed95 100644 --- a/crates/adapters/postgres-search/src/lib.rs +++ b/crates/adapters/postgres-search/src/lib.rs @@ -9,9 +9,9 @@ use domain::{ user::User, }, ports::SearchPort, - value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username}, + value_objects::{Content, ThoughtId, UserId}, }; -use postgres::user::{UserRow, USER_SELECT}; +use postgres::user::USER_SELECT; use sqlx::PgPool; pub struct PgSearchRepository { @@ -34,25 +34,15 @@ struct FeedRow { sensitive: bool, t_local: bool, thought_created_at: DateTime, - updated_at: Option>, - author_id: uuid::Uuid, - username: String, - email: String, - password_hash: String, - display_name: Option, - bio: Option, - avatar_url: Option, - header_url: Option, - custom_css: Option, - author_local: bool, - author_created_at: DateTime, - author_updated_at: DateTime, + thought_updated_at: Option>, + note_extensions: Option, + #[sqlx(flatten)] + author: postgres::user::UserRow, like_count: i64, boost_count: i64, reply_count: i64, liked_by_viewer: bool, boosted_by_viewer: bool, - note_extensions: Option, } fn feed_select(viewer: Option) -> String { @@ -68,11 +58,11 @@ fn feed_select(viewer: Option) -> String { t.id AS thought_id, t.user_id AS t_user_id, t.content,\n\ t.in_reply_to_id,\n\ t.visibility, t.content_warning, t.sensitive, t.local AS t_local,\n\ - t.created_at AS thought_created_at, t.updated_at, t.note_extensions,\n\ - u.id AS author_id, u.username, u.email, u.password_hash,\n\ - u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css,\n\ - u.local AS author_local,\n\ - u.created_at AS author_created_at, u.updated_at AS author_updated_at,\n\ + t.created_at AS thought_created_at, t.updated_at AS thought_updated_at, t.note_extensions,\n\ + u.id, u.username, u.email, u.password_hash,\n\ + u.display_name, u.bio, u.avatar_url, u.header_url, u.custom_css, u.profile_fields,\n\ + u.local,\n\ + u.created_at, u.updated_at,\n\ (SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,\n\ (SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,\n\ (SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count,\n\ @@ -92,24 +82,10 @@ fn row_to_entry(r: FeedRow, viewer: Option) -> Result(&sql) + let rows = sqlx::query_as::<_, postgres::user::UserRow>(&sql) .bind(query) .bind(page.limit()) .bind(page.offset()) diff --git a/crates/adapters/postgres-search/src/tests.rs b/crates/adapters/postgres-search/src/tests.rs index 98db075..779b632 100644 --- a/crates/adapters/postgres-search/src/tests.rs +++ b/crates/adapters/postgres-search/src/tests.rs @@ -5,6 +5,7 @@ use domain::{ user::User, }, ports::{SearchPort, ThoughtRepository, UserWriter}, + value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username}, }; async fn seed_thought(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) { diff --git a/crates/adapters/postgres/src/feed/mod.rs b/crates/adapters/postgres/src/feed/mod.rs index fc47ba8..ffb8533 100644 --- a/crates/adapters/postgres/src/feed/mod.rs +++ b/crates/adapters/postgres/src/feed/mod.rs @@ -10,7 +10,7 @@ use domain::{ user::User, }, ports::{FeedOptions, FeedRepository, FeedRequest, FeedScope, FeedSort}, - value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username}, + value_objects::{Content, ThoughtId, UserId}, }; use sqlx::PgPool; @@ -34,20 +34,10 @@ struct FeedRow { sensitive: bool, t_local: bool, thought_created_at: DateTime, - updated_at: Option>, + thought_updated_at: Option>, note_extensions: Option, - author_id: uuid::Uuid, - username: String, - email: String, - password_hash: String, - display_name: Option, - bio: Option, - avatar_url: Option, - header_url: Option, - custom_css: Option, - author_local: bool, - author_created_at: DateTime, - author_updated_at: DateTime, + #[sqlx(flatten)] + author: crate::user::UserRow, like_count: i64, boost_count: i64, reply_count: i64, @@ -66,24 +56,10 @@ fn row_to_entry(r: FeedRow, viewer: Option) -> Result FeedSqlBuilder<'a> { t.id AS thought_id, t.user_id AS t_user_id, t.content, t.in_reply_to_id, t.visibility, t.content_warning, t.sensitive, t.local AS t_local, - t.created_at AS thought_created_at, t.updated_at, + t.created_at AS thought_created_at, t.updated_at AS thought_updated_at, t.note_extensions, - u.id AS author_id, + u.id, CASE WHEN NOT u.local AND ra.handle IS NOT NULL AND ra.handle != '' THEN '@' || ra.handle || CASE WHEN ra.handle NOT LIKE '%@%' @@ -148,9 +124,9 @@ impl<'a> FeedSqlBuilder<'a> { COALESCE(ra.display_name, u.display_name) AS display_name, u.bio, COALESCE(ra.avatar_url, u.avatar_url) AS avatar_url, - u.header_url, u.custom_css, - u.local AS author_local, - u.created_at AS author_created_at, u.updated_at AS author_updated_at, + u.header_url, u.custom_css, u.profile_fields, + u.local, + u.created_at, u.updated_at, COALESCE(l_agg.cnt, 0) AS like_count, COALESCE(b_agg.cnt, 0) AS boost_count, COALESCE(r_agg.cnt, 0) AS reply_count, diff --git a/crates/adapters/postgres/src/feed/tests.rs b/crates/adapters/postgres/src/feed/tests.rs index e4754de..9639d3d 100644 --- a/crates/adapters/postgres/src/feed/tests.rs +++ b/crates/adapters/postgres/src/feed/tests.rs @@ -7,6 +7,7 @@ use domain::{ user::User, }, ports::{FeedOptions, FeedQuery, FeedRequest, ThoughtRepository, UserWriter}, + value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username}, }; async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) { diff --git a/crates/application/src/testing.rs b/crates/application/src/testing.rs index 5e21a78..a252a82 100644 --- a/crates/application/src/testing.rs +++ b/crates/application/src/testing.rs @@ -5,7 +5,7 @@ use domain::{ errors::DomainError, models::user::User, testing::TestStore, - value_objects::{Email, PasswordHash, ThoughtId, UserId, Username}, + value_objects::{Email, ThoughtId, UserId, Username}, }; use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -63,21 +63,11 @@ impl ActivityPubRepository for TestApRepo { let handle = url::Url::parse(actor_ap_url) .map(|u| u.path().trim_start_matches('/').replace('/', "_")) .unwrap_or_else(|_| format!("remote_{}", &uid.to_string()[..8])); - let user = User { - id: uid.clone(), - username: Username::from_trusted(handle), - email: Email::from_trusted(format!("{}@remote", uid)), - password_hash: PasswordHash("".into()), - display_name: None, - bio: None, - avatar_url: None, - header_url: None, - custom_css: None, - profile_fields: vec![], - local: false, - created_at: chrono::Utc::now(), - updated_at: chrono::Utc::now(), - }; + let user = User::new_remote( + uid.clone(), + Username::from_trusted(handle), + Email::from_trusted(format!("{}@remote", uid)), + ); self.inner.users.lock().unwrap().push(user); self.inner .actor_ap_ids diff --git a/crates/domain/src/models/user.rs b/crates/domain/src/models/user.rs index bcf5f51..2d179a6 100644 --- a/crates/domain/src/models/user.rs +++ b/crates/domain/src/models/user.rs @@ -52,4 +52,23 @@ impl User { updated_at: now, } } + + pub fn new_remote(id: UserId, username: Username, email: Email) -> Self { + let now = Utc::now(); + Self { + id, + username, + email, + password_hash: PasswordHash(String::new()), + display_name: None, + bio: None, + avatar_url: None, + header_url: None, + custom_css: None, + profile_fields: vec![], + local: false, + created_at: now, + updated_at: now, + } + } } diff --git a/crates/presentation/src/handlers/auth.rs b/crates/presentation/src/handlers/auth.rs index 9500ff5..c4ea8ad 100644 --- a/crates/presentation/src/handlers/auth.rs +++ b/crates/presentation/src/handlers/auth.rs @@ -5,6 +5,7 @@ use api_types::{ }; use application::use_cases::auth::{login, register, LoginInput, RegisterInput}; use axum::{http::StatusCode, response::IntoResponse, Json}; +use domain::models::feed::UserSummary; use domain::ports::{AuthService, EventPublisher, PasswordHasher, UserRepository}; deps_struct!(AuthDeps { @@ -37,6 +38,22 @@ pub fn to_user_response(u: &domain::models::user::User) -> UserResponse { } } +pub fn to_summary_response(u: &UserSummary) -> UserResponse { + UserResponse { + id: u.id.as_uuid(), + username: u.username.clone(), + display_name: u.display_name.clone(), + bio: u.bio.clone(), + avatar_url: u.avatar_url.clone(), + header_url: None, + custom_css: None, + profile_fields: vec![], + local: true, + is_followed_by_viewer: false, + created_at: chrono::Utc::now(), + } +} + #[utoipa::path( post, path = "/auth/register", request_body = RegisterRequest, diff --git a/crates/presentation/src/handlers/users/mod.rs b/crates/presentation/src/handlers/users/mod.rs index ee4d37b..10973df 100644 --- a/crates/presentation/src/handlers/users/mod.rs +++ b/crates/presentation/src/handlers/users/mod.rs @@ -1,7 +1,7 @@ use crate::{ errors::ApiError, extractors::{AuthUser, Deps, FromAppState, OptionalAuthUser}, - handlers::auth::to_user_response, + handlers::auth::{to_summary_response, to_user_response}, state::AppState, }; use api_types::{ @@ -200,23 +200,7 @@ pub async fn get_users( } let result = list_users(&*d.users, page_params).await?; - let items: Vec = result - .items - .iter() - .map(|u| UserResponse { - id: u.id.as_uuid(), - username: u.username.clone(), - display_name: u.display_name.clone(), - bio: u.bio.clone(), - avatar_url: u.avatar_url.clone(), - header_url: None, - custom_css: None, - profile_fields: vec![], - local: true, - is_followed_by_viewer: false, - created_at: chrono::Utc::now(), - }) - .collect(); + let items: Vec = result.items.iter().map(to_summary_response).collect(); Ok(Json(PagedResponse { items, total: result.total,