structural refactor and codebase improvements
This commit is contained in:
28
crates/application/src/users/authorize_admin.rs
Normal file
28
crates/application/src/users/authorize_admin.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::UserRole;
|
||||
use domain::value_objects::UserId;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::users::deps::AuthorizeAdminDeps;
|
||||
|
||||
/// Whether `user_id` is an admin, for the two extractors (`AdminApiUser`,
|
||||
/// `AdminUser`) that gate admin-only routes.
|
||||
///
|
||||
/// Returns `Ok(None)` when the user row does not exist — a distinct success
|
||||
/// value, not folded into `DomainError::NotFound`, specifically so callers can
|
||||
/// tell "row missing" apart from "repository call failed" without depending on
|
||||
/// which `DomainError` variant a lookup failure happens to produce. Each
|
||||
/// extractor rejects those two cases differently (missing row is a 401/404,
|
||||
/// a repository error is a 500) and that distinction must stay exact.
|
||||
pub async fn execute(
|
||||
deps: &AuthorizeAdminDeps,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<bool>, DomainError> {
|
||||
let id = UserId::from_uuid(user_id);
|
||||
let found = deps.user.find_by_id(&id).await?;
|
||||
Ok(found.map(|user| matches!(user.role(), UserRole::Admin)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/authorize_admin.rs"]
|
||||
mod tests;
|
||||
@@ -1,14 +1,48 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
DiaryQuery, EventPublisher, FederationAdminQuery, ObjectStorage, SocialQuery, StatsRepository,
|
||||
UserRepository,
|
||||
DiaryQuery, EventPublisher, FederatedProfileQuery, FederationAdminQuery, FollowGraphQuery,
|
||||
ObjectStorage, StatsRepository, UserProfileFieldsRepository, UserRepository,
|
||||
UserSettingsRepository,
|
||||
};
|
||||
use domain::value_objects::InstanceIdentity;
|
||||
|
||||
pub struct GetProfileDeps {
|
||||
/// The local half of the former `GetProfileDeps` split (ADR-0004). `user` and
|
||||
/// `instance` build the always-populated `ProfileIdentity`; `stats`/`diary`/
|
||||
/// `social_query` are unchanged from before the split.
|
||||
pub struct GetLocalProfileDeps {
|
||||
pub stats: Arc<dyn StatsRepository>,
|
||||
pub diary: Arc<dyn DiaryQuery>,
|
||||
pub social_query: Arc<dyn SocialQuery>,
|
||||
pub social_query: Arc<dyn FollowGraphQuery>,
|
||||
pub user: Arc<dyn UserRepository>,
|
||||
pub instance: InstanceIdentity,
|
||||
}
|
||||
|
||||
/// The federated half of the former `GetProfileDeps` split. No `user`/`instance` —
|
||||
/// the federated handler builds identity from the resolved remote actor and never
|
||||
/// reads a local user row for this path.
|
||||
pub struct GetFederatedProfileStatsDeps {
|
||||
pub stats: Arc<dyn StatsRepository>,
|
||||
pub diary: Arc<dyn DiaryQuery>,
|
||||
pub social_query: Arc<dyn FollowGraphQuery>,
|
||||
}
|
||||
|
||||
/// Backs page-chrome data (email/role badge, pending-follow badge). See the error
|
||||
/// policy note on `get_page_viewer::execute`: this deliberately propagates errors
|
||||
/// rather than degrading internally — the caller decides chrome degrades.
|
||||
pub struct GetPageViewerDeps {
|
||||
pub user: Arc<dyn UserRepository>,
|
||||
pub follow_graph: Arc<dyn FollowGraphQuery>,
|
||||
}
|
||||
|
||||
pub struct ResolveUsernameDeps {
|
||||
pub user: Arc<dyn UserRepository>,
|
||||
}
|
||||
|
||||
pub struct GetProfileSettingsDeps {
|
||||
pub user: Arc<dyn UserRepository>,
|
||||
pub profile_fields: Arc<dyn UserProfileFieldsRepository>,
|
||||
pub instance: InstanceIdentity,
|
||||
}
|
||||
|
||||
pub struct GetUsersListDeps {
|
||||
@@ -26,3 +60,31 @@ pub struct DeleteAccountDeps {
|
||||
pub user: Arc<dyn UserRepository>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
pub struct GetCurrentProfileDeps {
|
||||
pub user: Arc<dyn UserRepository>,
|
||||
}
|
||||
|
||||
pub struct UpdateProfileFieldsDeps {
|
||||
pub profile_fields: Arc<dyn UserProfileFieldsRepository>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
pub struct GetSettingsDeps {
|
||||
pub user_settings: Arc<dyn UserSettingsRepository>,
|
||||
}
|
||||
|
||||
pub struct UpdateSettingsDeps {
|
||||
pub user_settings: Arc<dyn UserSettingsRepository>,
|
||||
}
|
||||
|
||||
pub struct AuthorizeAdminDeps {
|
||||
pub user: Arc<dyn UserRepository>,
|
||||
}
|
||||
|
||||
/// `Option` mirrors `Repositories::federated_profile` — genuine optional
|
||||
/// configuration (federation on/off), not a container-shape workaround. Same
|
||||
/// exemption as `Services::person_enrichment`.
|
||||
pub struct GetFederatedProfileDeps {
|
||||
pub federated_profile: Option<Arc<dyn FederatedProfileQuery>>,
|
||||
}
|
||||
|
||||
80
crates/application/src/users/diary_filter.rs
Normal file
80
crates/application/src/users/diary_filter.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
//! Shared, pure helpers for turning a `GetUserProfileQuery`'s sort/paging/search
|
||||
//! fields into a `DiaryFilter`. Used by both halves of the former `get_profile`
|
||||
//! split (`get_local_profile`, `get_federated_profile_stats`) so the query-shaping
|
||||
//! logic has exactly one definition.
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{DiaryFilter, FeedSortBy, ReviewSortBy, collections::PageParams},
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
pub(super) fn feed_sort_to_direction(sort_by: FeedSortBy) -> ReviewSortBy {
|
||||
match sort_by {
|
||||
FeedSortBy::Date => ReviewSortBy::Descending,
|
||||
FeedSortBy::DateAsc => ReviewSortBy::Ascending,
|
||||
FeedSortBy::Rating => ReviewSortBy::ByRatingDesc,
|
||||
FeedSortBy::RatingAsc => ReviewSortBy::ByRatingAsc,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn paged_user_filter(
|
||||
user_id: UserId,
|
||||
sort_by: ReviewSortBy,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
search: Option<String>,
|
||||
include_remote: bool,
|
||||
) -> Result<DiaryFilter, DomainError> {
|
||||
let page = PageParams::new(limit, offset)?;
|
||||
Ok(DiaryFilter {
|
||||
sort_by,
|
||||
page,
|
||||
movie_id: None,
|
||||
user_id: Some(user_id),
|
||||
search,
|
||||
include_remote,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn feed_sort_to_direction_all_variants() {
|
||||
assert!(matches!(
|
||||
feed_sort_to_direction(FeedSortBy::Date),
|
||||
ReviewSortBy::Descending
|
||||
));
|
||||
assert!(matches!(
|
||||
feed_sort_to_direction(FeedSortBy::DateAsc),
|
||||
ReviewSortBy::Ascending
|
||||
));
|
||||
assert!(matches!(
|
||||
feed_sort_to_direction(FeedSortBy::Rating),
|
||||
ReviewSortBy::ByRatingDesc
|
||||
));
|
||||
assert!(matches!(
|
||||
feed_sort_to_direction(FeedSortBy::RatingAsc),
|
||||
ReviewSortBy::ByRatingAsc
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paged_user_filter_builds_correctly() {
|
||||
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
|
||||
let filter = paged_user_filter(
|
||||
uid.clone(),
|
||||
ReviewSortBy::Descending,
|
||||
Some(20),
|
||||
Some(5),
|
||||
Some("blade".into()),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(filter.user_id.unwrap().value(), uid.value());
|
||||
assert_eq!(filter.search.as_deref(), Some("blade"));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{errors::DomainError, ports::UserRepository};
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use crate::users::deps::GetCurrentProfileDeps;
|
||||
use crate::users::queries::GetCurrentProfileQuery;
|
||||
|
||||
pub struct ProfileFieldData {
|
||||
@@ -21,11 +20,12 @@ pub struct CurrentProfileData {
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
user: Arc<dyn UserRepository>,
|
||||
deps: &GetCurrentProfileDeps,
|
||||
query: GetCurrentProfileQuery,
|
||||
) -> Result<CurrentProfileData, DomainError> {
|
||||
let user_id = domain::value_objects::UserId::from_uuid(query.user_id);
|
||||
let found = user
|
||||
let found = deps
|
||||
.user
|
||||
.find_by_id(&user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("User not found".into()))?;
|
||||
|
||||
31
crates/application/src/users/get_federated_profile.rs
Normal file
31
crates/application/src/users/get_federated_profile.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::FederatedProfile;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::users::deps::GetFederatedProfileDeps;
|
||||
|
||||
/// Looks up a federated (remote) profile by synthetic user id — the fallback
|
||||
/// path `get_user_profile` (`handlers/users.rs`) takes when the local-profile
|
||||
/// lookup comes back `NotFound`.
|
||||
///
|
||||
/// Federation being disabled (`deps.federated_profile` is `None`) collapses to
|
||||
/// `Ok(None)`, the same value a present-but-empty lookup would return. That's
|
||||
/// deliberate: the handler's `if let Ok(Some(fed)) = ...` treats "port absent",
|
||||
/// "call returned `Ok(None)`", and "call returned `Err`" identically — all three
|
||||
/// fall through to a 404 — and this use case must not make any of those three
|
||||
/// distinguishable to a caller that cannot act on the difference. An `Err` from
|
||||
/// the port is propagated unchanged; the swallow stays at the handler, exactly
|
||||
/// where it already was.
|
||||
pub async fn execute(
|
||||
deps: &GetFederatedProfileDeps,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<FederatedProfile>, DomainError> {
|
||||
match &deps.federated_profile {
|
||||
Some(fed_query) => fed_query.get_federated_profile(user_id).await,
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_federated_profile.rs"]
|
||||
mod tests;
|
||||
89
crates/application/src/users/get_federated_profile_stats.rs
Normal file
89
crates/application/src/users/get_federated_profile_stats.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
//! The federated half of the former `get_profile` (ADR-0004's closed wart).
|
||||
//! `build_federated_profile_response` (`handlers/users.rs`) calls this with a
|
||||
//! synthetic `user_id` that has no row in the local `users` table — a federated
|
||||
//! actor's identity is built entirely from the resolved `FederatedProfile`, so
|
||||
//! this type carries no `identity` field at all: the old `get_profile` computed
|
||||
//! one for this path anyway, filled with empty-string sentinels for `username`/
|
||||
//! `handle`, and the handler never read it (see the closed wart paragraph in
|
||||
//! `docs/adr/0004-instance-identity.md`). No type here can represent that
|
||||
//! sentinel, because there is nothing to fill in.
|
||||
//!
|
||||
//! A federated actor also has no local pending-follow-request concept, so unlike
|
||||
//! `get_local_profile` this never computes `pending_followers` — it would always
|
||||
//! be empty here regardless of `is_own_profile`.
|
||||
|
||||
use crate::users::{
|
||||
deps::GetFederatedProfileStatsDeps,
|
||||
diary_filter::{feed_sort_to_direction, paged_user_filter},
|
||||
queries::{GetUserProfileQuery, ProfileView},
|
||||
};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{DiaryEntry, UserStats, UserTrends, collections::Paginated},
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
pub struct FederatedProfileStats {
|
||||
pub stats: UserStats,
|
||||
pub entries: Option<Paginated<DiaryEntry>>,
|
||||
pub history: Option<Vec<DiaryEntry>>,
|
||||
pub trends: Option<UserTrends>,
|
||||
pub following_count: usize,
|
||||
pub followers_count: usize,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
deps: &GetFederatedProfileStatsDeps,
|
||||
query: GetUserProfileQuery,
|
||||
) -> Result<FederatedProfileStats, DomainError> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
let stats = deps.stats.get_user_stats(&user_id).await?;
|
||||
|
||||
let following_count = deps
|
||||
.social_query
|
||||
.count_following(&user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let followers_count = deps
|
||||
.social_query
|
||||
.count_followers(&user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let base = |entries, history, trends| FederatedProfileStats {
|
||||
stats,
|
||||
entries,
|
||||
history,
|
||||
trends,
|
||||
following_count,
|
||||
followers_count,
|
||||
};
|
||||
|
||||
match query.view {
|
||||
ProfileView::History => {
|
||||
let all_entries = deps.diary.get_user_history(&user_id).await?;
|
||||
Ok(base(None, Some(all_entries), None))
|
||||
}
|
||||
ProfileView::Trends => {
|
||||
let trends = deps.stats.get_user_trends(&user_id).await?;
|
||||
Ok(base(None, None, Some(trends)))
|
||||
}
|
||||
ProfileView::Ratings | ProfileView::Recent => {
|
||||
let sort_direction = feed_sort_to_direction(query.sort_by);
|
||||
let filter = paged_user_filter(
|
||||
user_id,
|
||||
sort_direction,
|
||||
query.limit,
|
||||
query.offset,
|
||||
query.search.clone(),
|
||||
query.include_remote,
|
||||
)?;
|
||||
let entries = deps.diary.query_diary(&filter).await?;
|
||||
Ok(base(Some(entries), None, None))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_federated_profile_stats.rs"]
|
||||
mod tests;
|
||||
154
crates/application/src/users/get_local_profile.rs
Normal file
154
crates/application/src/users/get_local_profile.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
//! The local half of the former `get_profile` (ADR-0004's closed wart). Unlike
|
||||
//! the old unified function, a user id with no local row is `Err(NotFound)` here
|
||||
//! — there is no federated fallback inside this function, and no empty-string
|
||||
//! sentinel for `identity`, because `LocalProfileData::identity` is always fully
|
||||
//! populated by construction.
|
||||
|
||||
use crate::users::{
|
||||
deps::GetLocalProfileDeps,
|
||||
diary_filter::{feed_sort_to_direction, paged_user_filter},
|
||||
queries::{GetUserProfileQuery, ProfileView},
|
||||
};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{DiaryEntry, UserStats, UserTrends, collections::Paginated},
|
||||
value_objects::{InstanceIdentity, UserId},
|
||||
};
|
||||
|
||||
pub struct PendingFollowerView {
|
||||
pub url: String,
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ProfileIdentity {
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub handle: String,
|
||||
pub actor_url: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub banner_url: Option<String>,
|
||||
/// Only the HTML profile handler reads this (it derives its "display name"
|
||||
/// from the email's local part, not from `username` — see
|
||||
/// `handlers/users.rs::get_user_profile_html`). Sourced from the same local
|
||||
/// `User` row as every other identity field.
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
pub struct LocalProfileData {
|
||||
pub stats: UserStats,
|
||||
pub entries: Option<Paginated<DiaryEntry>>,
|
||||
pub history: Option<Vec<DiaryEntry>>,
|
||||
pub trends: Option<UserTrends>,
|
||||
pub following_count: usize,
|
||||
pub followers_count: usize,
|
||||
pub pending_followers: Vec<PendingFollowerView>,
|
||||
pub identity: ProfileIdentity,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
deps: &GetLocalProfileDeps,
|
||||
query: GetUserProfileQuery,
|
||||
) -> Result<LocalProfileData, DomainError> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
let user = deps
|
||||
.user
|
||||
.find_by_id(&user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("user {}", query.user_id)))?;
|
||||
|
||||
let stats = deps.stats.get_user_stats(&user_id).await?;
|
||||
|
||||
let (following_count, followers_count, pending_followers) =
|
||||
load_social_counts(deps, &user_id, query.is_own_profile, &deps.instance).await;
|
||||
|
||||
let identity = ProfileIdentity {
|
||||
username: user.username().value().to_string(),
|
||||
display_name: user.display_name().map(str::to_string),
|
||||
bio: user.bio().map(str::to_string),
|
||||
handle: deps.instance.handle_for(user.username().value()),
|
||||
actor_url: deps.instance.actor_url_for(&user_id),
|
||||
avatar_url: user.avatar_path().map(|p| deps.instance.image_url_for(p)),
|
||||
banner_url: user.banner_path().map(|p| deps.instance.image_url_for(p)),
|
||||
email: user.email().value().to_string(),
|
||||
};
|
||||
|
||||
let base = |entries, history, trends| LocalProfileData {
|
||||
stats,
|
||||
entries,
|
||||
history,
|
||||
trends,
|
||||
following_count,
|
||||
followers_count,
|
||||
pending_followers,
|
||||
identity,
|
||||
};
|
||||
|
||||
match query.view {
|
||||
ProfileView::History => {
|
||||
let all_entries = deps.diary.get_user_history(&user_id).await?;
|
||||
Ok(base(None, Some(all_entries), None))
|
||||
}
|
||||
ProfileView::Trends => {
|
||||
let trends = deps.stats.get_user_trends(&user_id).await?;
|
||||
Ok(base(None, None, Some(trends)))
|
||||
}
|
||||
ProfileView::Ratings | ProfileView::Recent => {
|
||||
let sort_direction = feed_sort_to_direction(query.sort_by);
|
||||
let filter = paged_user_filter(
|
||||
user_id,
|
||||
sort_direction,
|
||||
query.limit,
|
||||
query.offset,
|
||||
query.search.clone(),
|
||||
query.include_remote,
|
||||
)?;
|
||||
let entries = deps.diary.query_diary(&filter).await?;
|
||||
Ok(base(Some(entries), None, None))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_social_counts(
|
||||
deps: &GetLocalProfileDeps,
|
||||
user_id: &UserId,
|
||||
is_own_profile: bool,
|
||||
instance: &InstanceIdentity,
|
||||
) -> (usize, usize, Vec<PendingFollowerView>) {
|
||||
let following = deps
|
||||
.social_query
|
||||
.count_following(user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let followers = deps
|
||||
.social_query
|
||||
.count_followers(user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
if !is_own_profile {
|
||||
return (following, followers, vec![]);
|
||||
}
|
||||
let pending = deps
|
||||
.social_query
|
||||
.get_pending_followers(user_id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let url = instance.actor_url_of(&p.identity);
|
||||
PendingFollowerView {
|
||||
url,
|
||||
handle: p.handle,
|
||||
display_name: p.display_name,
|
||||
avatar_url: p.avatar_url,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
(following, followers, pending)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_local_profile.rs"]
|
||||
mod tests;
|
||||
44
crates/application/src/users/get_page_viewer.rs
Normal file
44
crates/application/src/users/get_page_viewer.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
//! Backs page-chrome data: the nav bar's email/admin state and pending-follow
|
||||
//! badge. Absorbs `handlers/helpers.rs::build_page_context`'s inline user lookup
|
||||
//! and pending-follower count.
|
||||
//!
|
||||
//! **Error policy (deliberately no internal tolerance):** this function
|
||||
//! propagates every error from `user` and `follow_graph` with `?`. It does not
|
||||
//! itself decide to degrade — that's a presentation-layer policy
|
||||
//! (`build_page_context` logs a warning and falls back to a zeroed `PageViewer`
|
||||
//! on `Err`). Page chrome degrading is a rendering decision, not a domain one.
|
||||
|
||||
use domain::{errors::DomainError, models::UserRole, value_objects::UserId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::users::deps::GetPageViewerDeps;
|
||||
|
||||
pub struct PageViewer {
|
||||
pub email: Option<String>,
|
||||
pub is_admin: bool,
|
||||
pub pending_follow_count: usize,
|
||||
}
|
||||
|
||||
pub async fn execute(deps: &GetPageViewerDeps, user_id: Uuid) -> Result<PageViewer, DomainError> {
|
||||
let uid = UserId::from_uuid(user_id);
|
||||
let user = deps
|
||||
.user
|
||||
.find_by_id(&uid)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("user {}", user_id)))?;
|
||||
|
||||
// Reuse `FollowGraphQuery::count_pending_followers` directly — the same port
|
||||
// method `social::count_pending_followers::execute` wraps — rather than
|
||||
// re-deriving the count from raw follow rows a second time.
|
||||
let pending_follow_count = deps.follow_graph.count_pending_followers(&uid).await?;
|
||||
|
||||
Ok(PageViewer {
|
||||
email: Some(user.email().value().to_string()),
|
||||
is_admin: matches!(user.role(), UserRole::Admin),
|
||||
pending_follow_count,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_page_viewer.rs"]
|
||||
mod tests;
|
||||
@@ -1,192 +0,0 @@
|
||||
use crate::users::{
|
||||
deps::GetProfileDeps,
|
||||
queries::{GetUserProfileQuery, ProfileView},
|
||||
};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::FeedSortBy,
|
||||
models::{
|
||||
DiaryEntry, DiaryFilter, ReviewSortBy, UserStats, UserTrends,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
pub struct PendingFollowerView {
|
||||
pub url: String,
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
pub struct UserProfileData {
|
||||
pub stats: UserStats,
|
||||
pub entries: Option<Paginated<DiaryEntry>>,
|
||||
pub history: Option<Vec<DiaryEntry>>,
|
||||
pub trends: Option<UserTrends>,
|
||||
pub following_count: usize,
|
||||
pub followers_count: usize,
|
||||
pub pending_followers: Vec<PendingFollowerView>,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
deps: &GetProfileDeps,
|
||||
query: GetUserProfileQuery,
|
||||
) -> Result<UserProfileData, DomainError> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
let stats = deps.stats.get_user_stats(&user_id).await?;
|
||||
|
||||
let (following_count, followers_count, pending_followers) =
|
||||
load_social_counts(deps, &user_id, query.is_own_profile).await;
|
||||
|
||||
let base = |entries, history, trends| UserProfileData {
|
||||
stats,
|
||||
entries,
|
||||
history,
|
||||
trends,
|
||||
following_count,
|
||||
followers_count,
|
||||
pending_followers,
|
||||
};
|
||||
|
||||
match query.view {
|
||||
ProfileView::History => {
|
||||
let all_entries = deps.diary.get_user_history(&user_id).await?;
|
||||
Ok(base(None, Some(all_entries), None))
|
||||
}
|
||||
ProfileView::Trends => {
|
||||
let trends = deps.stats.get_user_trends(&user_id).await?;
|
||||
Ok(base(None, None, Some(trends)))
|
||||
}
|
||||
ProfileView::Ratings | ProfileView::Recent => {
|
||||
let sort_direction = feed_sort_to_direction(query.sort_by);
|
||||
let filter = paged_user_filter(
|
||||
user_id,
|
||||
sort_direction,
|
||||
query.limit,
|
||||
query.offset,
|
||||
query.search.clone(),
|
||||
query.include_remote,
|
||||
)?;
|
||||
let entries = deps.diary.query_diary(&filter).await?;
|
||||
Ok(base(Some(entries), None, None))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_social_counts(
|
||||
deps: &GetProfileDeps,
|
||||
user_id: &UserId,
|
||||
is_own_profile: bool,
|
||||
) -> (usize, usize, Vec<PendingFollowerView>) {
|
||||
let following = deps
|
||||
.social_query
|
||||
.count_following(user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let followers = deps
|
||||
.social_query
|
||||
.count_followers(user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
if !is_own_profile {
|
||||
return (following, followers, vec![]);
|
||||
}
|
||||
let pending = deps
|
||||
.social_query
|
||||
.get_pending_followers(user_id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let url = match &p.identity {
|
||||
domain::value_objects::SocialIdentity::Remote { actor_url } => actor_url.clone(),
|
||||
domain::value_objects::SocialIdentity::Local(uid) => {
|
||||
format!("local:{}", uid.value())
|
||||
}
|
||||
};
|
||||
PendingFollowerView {
|
||||
url,
|
||||
handle: p.handle,
|
||||
display_name: p.display_name,
|
||||
avatar_url: p.avatar_url,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
(following, followers, pending)
|
||||
}
|
||||
|
||||
fn feed_sort_to_direction(sort_by: FeedSortBy) -> ReviewSortBy {
|
||||
match sort_by {
|
||||
FeedSortBy::Date => ReviewSortBy::Descending,
|
||||
FeedSortBy::DateAsc => ReviewSortBy::Ascending,
|
||||
FeedSortBy::Rating => ReviewSortBy::ByRatingDesc,
|
||||
FeedSortBy::RatingAsc => ReviewSortBy::ByRatingAsc,
|
||||
}
|
||||
}
|
||||
|
||||
fn paged_user_filter(
|
||||
user_id: UserId,
|
||||
sort_by: ReviewSortBy,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
search: Option<String>,
|
||||
include_remote: bool,
|
||||
) -> Result<DiaryFilter, DomainError> {
|
||||
let page = PageParams::new(limit, offset)?;
|
||||
Ok(DiaryFilter {
|
||||
sort_by,
|
||||
page,
|
||||
movie_id: None,
|
||||
user_id: Some(user_id),
|
||||
search,
|
||||
include_remote,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_profile.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod helper_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn feed_sort_to_direction_all_variants() {
|
||||
use domain::models::FeedSortBy;
|
||||
assert!(matches!(
|
||||
feed_sort_to_direction(FeedSortBy::Date),
|
||||
ReviewSortBy::Descending
|
||||
));
|
||||
assert!(matches!(
|
||||
feed_sort_to_direction(FeedSortBy::DateAsc),
|
||||
ReviewSortBy::Ascending
|
||||
));
|
||||
assert!(matches!(
|
||||
feed_sort_to_direction(FeedSortBy::Rating),
|
||||
ReviewSortBy::ByRatingDesc
|
||||
));
|
||||
assert!(matches!(
|
||||
feed_sort_to_direction(FeedSortBy::RatingAsc),
|
||||
ReviewSortBy::ByRatingAsc
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paged_user_filter_builds_correctly() {
|
||||
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
|
||||
let filter = paged_user_filter(
|
||||
uid.clone(),
|
||||
ReviewSortBy::Descending,
|
||||
Some(20),
|
||||
Some(5),
|
||||
Some("blade".into()),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(filter.user_id.unwrap().value(), uid.value());
|
||||
assert_eq!(filter.search.as_deref(), Some("blade"));
|
||||
}
|
||||
}
|
||||
64
crates/application/src/users/get_profile_settings.rs
Normal file
64
crates/application/src/users/get_profile_settings.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
//! Absorbs BOTH of `handlers/users.rs::get_profile_settings`'s repository calls:
|
||||
//! the user lookup (`repos.user.find_by_id`) and the profile_fields join
|
||||
//! (`repos.profile_fields.get_fields`) — plus the avatar/banner URL derivation via
|
||||
//! `InstanceIdentity`, following the same pattern `get_local_profile` uses.
|
||||
//!
|
||||
//! Error policy: profile fields are primary content for this page (it's the page
|
||||
//! whose entire job is editing them), not page chrome, so a failed join
|
||||
//! propagates as `Err` rather than silently degrading to an empty list.
|
||||
|
||||
use domain::{errors::DomainError, value_objects::UserId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::users::deps::GetProfileSettingsDeps;
|
||||
|
||||
pub struct ProfileSettings {
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub banner_url: Option<String>,
|
||||
/// Not in the brief's interface sketch, but the deleted handler code read
|
||||
/// `user.also_known_as()` and rendered it in `ProfileSettingsTemplate` — this
|
||||
/// use case absorbs that read too, or the settings page would silently stop
|
||||
/// showing it.
|
||||
pub also_known_as: Option<String>,
|
||||
pub fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
deps: &GetProfileSettingsDeps,
|
||||
user_id: Uuid,
|
||||
) -> Result<ProfileSettings, DomainError> {
|
||||
let uid = UserId::from_uuid(user_id);
|
||||
let user = deps
|
||||
.user
|
||||
.find_by_id(&uid)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("user {}", user_id)))?;
|
||||
|
||||
let avatar_url = user.avatar_path().map(|p| deps.instance.image_url_for(p));
|
||||
let banner_url = user.banner_path().map(|p| deps.instance.image_url_for(p));
|
||||
|
||||
let fields = deps
|
||||
.profile_fields
|
||||
.get_fields(&uid)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|f| (f.name, f.value))
|
||||
.collect();
|
||||
|
||||
Ok(ProfileSettings {
|
||||
username: user.username().value().to_string(),
|
||||
display_name: user.display_name().map(str::to_string),
|
||||
bio: user.bio().map(str::to_string),
|
||||
avatar_url,
|
||||
banner_url,
|
||||
also_known_as: user.also_known_as().map(str::to_string),
|
||||
fields,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_profile_settings.rs"]
|
||||
mod tests;
|
||||
@@ -1,15 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
use domain::{errors::DomainError, models::UserSettings, value_objects::UserId};
|
||||
|
||||
use domain::{
|
||||
errors::DomainError, models::UserSettings, ports::UserSettingsRepository, value_objects::UserId,
|
||||
};
|
||||
use crate::users::deps::GetSettingsDeps;
|
||||
|
||||
pub async fn execute(
|
||||
user_settings: Arc<dyn UserSettingsRepository>,
|
||||
deps: &GetSettingsDeps,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<UserSettings, DomainError> {
|
||||
let uid = UserId::from_uuid(user_id);
|
||||
user_settings.get(&uid).await
|
||||
deps.user_settings.get(&uid).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
pub mod authorize_admin;
|
||||
pub mod commands;
|
||||
pub mod delete_account;
|
||||
pub mod deps;
|
||||
mod diary_filter;
|
||||
pub mod get_current_profile;
|
||||
pub mod get_profile;
|
||||
pub mod get_federated_profile;
|
||||
pub mod get_federated_profile_stats;
|
||||
pub mod get_local_profile;
|
||||
pub mod get_page_viewer;
|
||||
pub mod get_profile_settings;
|
||||
pub mod get_settings;
|
||||
pub mod get_users;
|
||||
pub mod queries;
|
||||
pub mod resolve_username_to_id;
|
||||
pub mod update_profile;
|
||||
pub mod update_profile_fields;
|
||||
pub mod update_settings;
|
||||
|
||||
23
crates/application/src/users/resolve_username_to_id.rs
Normal file
23
crates/application/src/users/resolve_username_to_id.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
//! Absorbs `handlers/users.rs::get_user_by_username`'s
|
||||
//! `repos.user.find_by_username()` call. `None` means "no such username" — the
|
||||
//! handler turns that into a 404, same as it always has; this is not an error
|
||||
//! condition.
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{UserId, Username},
|
||||
};
|
||||
|
||||
use crate::users::deps::ResolveUsernameDeps;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &ResolveUsernameDeps,
|
||||
username: &Username,
|
||||
) -> Result<Option<UserId>, DomainError> {
|
||||
let user = deps.user.find_by_username(username).await?;
|
||||
Ok(user.map(|u| u.id().clone()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/resolve_username_to_id.rs"]
|
||||
mod tests;
|
||||
92
crates/application/src/users/tests/authorize_admin.rs
Normal file
92
crates/application/src/users/tests/authorize_admin.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::models::UserRole;
|
||||
use domain::testing::InMemoryUserRepository;
|
||||
|
||||
use crate::{
|
||||
auth::{commands::RegisterCommand, deps::RegisterDeps, register},
|
||||
test_helpers::TestContextBuilder,
|
||||
users::{authorize_admin, deps::AuthorizeAdminDeps},
|
||||
};
|
||||
|
||||
async fn register_user(
|
||||
b: &TestContextBuilder,
|
||||
email: &str,
|
||||
username: &str,
|
||||
role: UserRole,
|
||||
) -> domain::models::User {
|
||||
let reg_deps = RegisterDeps {
|
||||
user: b.user_repo.clone(),
|
||||
password_hasher: b.password_hasher.clone(),
|
||||
config: b.config.clone(),
|
||||
};
|
||||
register::execute(
|
||||
®_deps,
|
||||
RegisterCommand {
|
||||
email: email.into(),
|
||||
username: username.into(),
|
||||
password: "password123".into(),
|
||||
role,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
b.user_repo
|
||||
.find_by_email(&domain::value_objects::Email::new(email.into()).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorize_admin_is_true_for_an_admin_user() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
let user = register_user(&b, "admin@example.com", "admin_user", UserRole::Admin).await;
|
||||
|
||||
let deps = AuthorizeAdminDeps {
|
||||
user: b.user_repo.clone(),
|
||||
};
|
||||
let result = authorize_admin::execute(&deps, user.id().value())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, Some(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorize_admin_is_false_for_a_regular_user() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
let user = register_user(
|
||||
&b,
|
||||
"standard@example.com",
|
||||
"standard_user",
|
||||
UserRole::Standard,
|
||||
)
|
||||
.await;
|
||||
|
||||
let deps = AuthorizeAdminDeps {
|
||||
user: b.user_repo.clone(),
|
||||
};
|
||||
let result = authorize_admin::execute(&deps, user.id().value())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, Some(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorize_admin_is_not_found_for_unknown_user() {
|
||||
let b = TestContextBuilder::new();
|
||||
|
||||
let deps = AuthorizeAdminDeps {
|
||||
user: b.user_repo.clone(),
|
||||
};
|
||||
let result = authorize_admin::execute(&deps, uuid::Uuid::new_v4())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
auth::{commands::RegisterCommand, deps::RegisterDeps, register},
|
||||
test_helpers::TestContextBuilder,
|
||||
users::{get_current_profile, queries::GetCurrentProfileQuery},
|
||||
users::{deps::GetCurrentProfileDeps, get_current_profile, queries::GetCurrentProfileQuery},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
@@ -41,8 +41,9 @@ async fn returns_profile_for_existing_user() {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let deps = GetCurrentProfileDeps { user: user_repo };
|
||||
let profile = get_current_profile::execute(
|
||||
user_repo,
|
||||
&deps,
|
||||
GetCurrentProfileQuery {
|
||||
user_id: user.id().value(),
|
||||
},
|
||||
@@ -58,8 +59,9 @@ async fn fails_for_nonexistent_user() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
|
||||
let deps = GetCurrentProfileDeps { user: user_repo };
|
||||
let result = get_current_profile::execute(
|
||||
user_repo,
|
||||
&deps,
|
||||
GetCurrentProfileQuery {
|
||||
user_id: Uuid::new_v4(),
|
||||
},
|
||||
@@ -97,8 +99,9 @@ async fn returns_profile_with_avatar_banner_and_fields() {
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
let user_repo = b.user_repo.clone();
|
||||
|
||||
let deps = GetCurrentProfileDeps { user: user_repo };
|
||||
let profile = get_current_profile::execute(
|
||||
user_repo,
|
||||
&deps,
|
||||
GetCurrentProfileQuery {
|
||||
user_id: uid.value(),
|
||||
},
|
||||
|
||||
115
crates/application/src/users/tests/get_federated_profile.rs
Normal file
115
crates/application/src/users/tests/get_federated_profile.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery};
|
||||
|
||||
use crate::users::deps::GetFederatedProfileDeps;
|
||||
use crate::users::get_federated_profile;
|
||||
|
||||
fn a_profile() -> FederatedProfile {
|
||||
FederatedProfile {
|
||||
actor_url: "https://remote.example/users/alice".into(),
|
||||
handle: "alice@remote.example".into(),
|
||||
display_name: Some("Alice".into()),
|
||||
bio: None,
|
||||
avatar_url: None,
|
||||
banner_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
struct FoundFederatedProfileQuery;
|
||||
|
||||
#[async_trait]
|
||||
impl FederatedProfileQuery for FoundFederatedProfileQuery {
|
||||
async fn get_federated_profile(
|
||||
&self,
|
||||
_synthetic_user_id: uuid::Uuid,
|
||||
) -> Result<Option<FederatedProfile>, DomainError> {
|
||||
Ok(Some(a_profile()))
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyFederatedProfileQuery;
|
||||
|
||||
#[async_trait]
|
||||
impl FederatedProfileQuery for EmptyFederatedProfileQuery {
|
||||
async fn get_federated_profile(
|
||||
&self,
|
||||
_synthetic_user_id: uuid::Uuid,
|
||||
) -> Result<Option<FederatedProfile>, DomainError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
struct FailingFederatedProfileQuery;
|
||||
|
||||
#[async_trait]
|
||||
impl FederatedProfileQuery for FailingFederatedProfileQuery {
|
||||
async fn get_federated_profile(
|
||||
&self,
|
||||
_synthetic_user_id: uuid::Uuid,
|
||||
) -> Result<Option<FederatedProfile>, DomainError> {
|
||||
Err(DomainError::InfrastructureError(
|
||||
"remote lookup failed".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_the_profile_when_the_port_finds_one() {
|
||||
let deps = GetFederatedProfileDeps {
|
||||
federated_profile: Some(Arc::new(FoundFederatedProfileQuery)),
|
||||
};
|
||||
|
||||
let result = get_federated_profile::execute(&deps, uuid::Uuid::new_v4())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result.map(|p| p.handle),
|
||||
Some("alice@remote.example".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_ok_none_when_the_port_finds_nothing() {
|
||||
let deps = GetFederatedProfileDeps {
|
||||
federated_profile: Some(Arc::new(EmptyFederatedProfileQuery)),
|
||||
};
|
||||
|
||||
let result = get_federated_profile::execute(&deps, uuid::Uuid::new_v4())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_ok_none_when_federation_is_disabled() {
|
||||
let deps = GetFederatedProfileDeps {
|
||||
federated_profile: None,
|
||||
};
|
||||
|
||||
let result = get_federated_profile::execute(&deps, uuid::Uuid::new_v4())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"an absent port must collapse to Ok(None), the same as an empty lookup — \
|
||||
the handler cannot and must not tell the two apart"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn propagates_an_error_from_the_port_unchanged() {
|
||||
let deps = GetFederatedProfileDeps {
|
||||
federated_profile: Some(Arc::new(FailingFederatedProfileQuery)),
|
||||
};
|
||||
|
||||
let err = get_federated_profile::execute(&deps, uuid::Uuid::new_v4())
|
||||
.await
|
||||
.expect_err("a repository error must not be swallowed inside the use case");
|
||||
|
||||
assert!(matches!(err, DomainError::InfrastructureError(_)));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
use crate::users::deps::GetFederatedProfileStatsDeps;
|
||||
use crate::users::get_federated_profile_stats;
|
||||
use crate::users::queries::{GetUserProfileQuery, ProfileView};
|
||||
|
||||
/// Mirrors exactly how `build_federated_profile_response`
|
||||
/// (`crates/presentation/src/handlers/users.rs`) calls `execute`: a `user_id` that
|
||||
/// has no row in the local `users` table (a federated/remote profile uses a
|
||||
/// synthetic id — see `FederatedProfileQuery`), `is_own_profile: false`,
|
||||
/// `include_remote: true`. Before ADR-0004's split this was the tolerant branch of
|
||||
/// the unified `get_profile`; now it's simply this function's only behavior — it
|
||||
/// never looks at the local `users` table at all, so there is nothing to be
|
||||
/// tolerant about.
|
||||
#[tokio::test]
|
||||
async fn succeeds_for_a_user_id_with_no_local_row() {
|
||||
let b = TestContextBuilder::new();
|
||||
let deps = GetFederatedProfileStatsDeps {
|
||||
stats: b.stats_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
social_query: b.social_query_unified.clone(),
|
||||
};
|
||||
|
||||
let synthetic_user_id = uuid::Uuid::new_v4();
|
||||
|
||||
let result = get_federated_profile_stats::execute(
|
||||
&deps,
|
||||
GetUserProfileQuery {
|
||||
user_id: synthetic_user_id,
|
||||
view: ProfileView::Recent,
|
||||
limit: None,
|
||||
offset: None,
|
||||
sort_by: domain::models::FeedSortBy::Date,
|
||||
search: None,
|
||||
is_own_profile: false,
|
||||
include_remote: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"execute must not hard-fail for a user_id absent from the local `users` table \
|
||||
(the federated-profile call shape) — got Err"
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::UserRole;
|
||||
use domain::value_objects::Email;
|
||||
|
||||
@@ -5,9 +6,11 @@ use crate::auth::commands::RegisterCommand;
|
||||
use crate::auth::deps::RegisterDeps;
|
||||
use crate::auth::register;
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
use crate::users::deps::GetProfileDeps;
|
||||
use crate::users::get_profile;
|
||||
use crate::users::commands::UpdateProfileCommand;
|
||||
use crate::users::deps::{GetLocalProfileDeps, UpdateProfileDeps};
|
||||
use crate::users::get_local_profile;
|
||||
use crate::users::queries::{GetUserProfileQuery, ProfileView};
|
||||
use crate::users::update_profile;
|
||||
|
||||
async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) {
|
||||
let deps = RegisterDeps {
|
||||
@@ -28,15 +31,51 @@ async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn deps(b: &TestContextBuilder) -> GetLocalProfileDeps {
|
||||
GetLocalProfileDeps {
|
||||
stats: b.stats_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
social_query: b.social_query_unified.clone(),
|
||||
user: b.user_repo.clone(),
|
||||
instance: domain::value_objects::InstanceIdentity::new(b.config.base_url.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn unknown_query(user_id: uuid::Uuid) -> GetUserProfileQuery {
|
||||
GetUserProfileQuery {
|
||||
user_id,
|
||||
view: ProfileView::Recent,
|
||||
limit: None,
|
||||
offset: None,
|
||||
sort_by: domain::models::FeedSortBy::Date,
|
||||
search: None,
|
||||
is_own_profile: false,
|
||||
include_remote: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `get_local_profile` is the local-only half of the former `get_profile` split
|
||||
/// (ADR-0004's closed wart). Unlike the old tolerant behavior, a user id with no
|
||||
/// local row must be `NotFound` here — the empty-string identity sentinel this
|
||||
/// replaces cannot be represented in `LocalProfileData` at all.
|
||||
#[tokio::test]
|
||||
async fn get_local_profile_is_not_found_for_unknown_user() {
|
||||
let b = TestContextBuilder::new();
|
||||
let d = deps(&b);
|
||||
|
||||
let unknown_id = uuid::Uuid::new_v4();
|
||||
let err = match get_local_profile::execute(&d, unknown_query(unknown_id)).await {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("expected Err(NotFound) for an unknown user id, got Ok"),
|
||||
};
|
||||
assert!(matches!(err, DomainError::NotFound(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_profile_with_empty_stats() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
let deps = GetProfileDeps {
|
||||
stats: b.stats_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
social_query: b.social_query_unified.clone(),
|
||||
};
|
||||
let d = deps(&b);
|
||||
|
||||
setup_user(&b, "profile@test.com", "profuser").await;
|
||||
|
||||
@@ -44,8 +83,8 @@ async fn returns_profile_with_empty_stats() {
|
||||
let user = user_repo.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let result = get_profile::execute(
|
||||
&deps,
|
||||
let result = get_local_profile::execute(
|
||||
&d,
|
||||
GetUserProfileQuery {
|
||||
user_id: uid,
|
||||
view: ProfileView::Recent,
|
||||
@@ -67,11 +106,7 @@ async fn returns_profile_with_empty_stats() {
|
||||
async fn returns_history_view() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
let deps = GetProfileDeps {
|
||||
stats: b.stats_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
social_query: b.social_query_unified.clone(),
|
||||
};
|
||||
let d = deps(&b);
|
||||
|
||||
setup_user(&b, "hist@test.com", "histuser").await;
|
||||
|
||||
@@ -79,8 +114,8 @@ async fn returns_history_view() {
|
||||
let user = user_repo.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let result = get_profile::execute(
|
||||
&deps,
|
||||
let result = get_local_profile::execute(
|
||||
&d,
|
||||
GetUserProfileQuery {
|
||||
user_id: uid,
|
||||
view: ProfileView::History,
|
||||
@@ -104,11 +139,7 @@ async fn returns_history_view() {
|
||||
async fn returns_trends_view() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
let deps = GetProfileDeps {
|
||||
stats: b.stats_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
social_query: b.social_query_unified.clone(),
|
||||
};
|
||||
let d = deps(&b);
|
||||
|
||||
setup_user(&b, "trends@test.com", "trendsuser").await;
|
||||
|
||||
@@ -116,8 +147,8 @@ async fn returns_trends_view() {
|
||||
let user = user_repo.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let result = get_profile::execute(
|
||||
&deps,
|
||||
let result = get_local_profile::execute(
|
||||
&d,
|
||||
GetUserProfileQuery {
|
||||
user_id: uid,
|
||||
view: ProfileView::Trends,
|
||||
@@ -141,11 +172,7 @@ async fn returns_trends_view() {
|
||||
async fn returns_ratings_view() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
let deps = GetProfileDeps {
|
||||
stats: b.stats_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
social_query: b.social_query_unified.clone(),
|
||||
};
|
||||
let d = deps(&b);
|
||||
|
||||
setup_user(&b, "ratings@test.com", "ratingsuser").await;
|
||||
|
||||
@@ -153,8 +180,8 @@ async fn returns_ratings_view() {
|
||||
let user = user_repo.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let result = get_profile::execute(
|
||||
&deps,
|
||||
let result = get_local_profile::execute(
|
||||
&d,
|
||||
GetUserProfileQuery {
|
||||
user_id: uid,
|
||||
view: ProfileView::Ratings,
|
||||
@@ -176,11 +203,7 @@ async fn returns_ratings_view() {
|
||||
async fn returns_recent_with_search() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
let deps = GetProfileDeps {
|
||||
stats: b.stats_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
social_query: b.social_query_unified.clone(),
|
||||
};
|
||||
let d = deps(&b);
|
||||
|
||||
setup_user(&b, "search@test.com", "searchuser").await;
|
||||
|
||||
@@ -188,8 +211,8 @@ async fn returns_recent_with_search() {
|
||||
let user = user_repo.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let result = get_profile::execute(
|
||||
&deps,
|
||||
let result = get_local_profile::execute(
|
||||
&d,
|
||||
GetUserProfileQuery {
|
||||
user_id: uid,
|
||||
view: ProfileView::Recent,
|
||||
@@ -211,11 +234,7 @@ async fn returns_recent_with_search() {
|
||||
async fn non_own_profile_skips_pending_followers() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
let deps = GetProfileDeps {
|
||||
stats: b.stats_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
social_query: b.social_query_unified.clone(),
|
||||
};
|
||||
let d = deps(&b);
|
||||
|
||||
setup_user(&b, "other@test.com", "otheruser").await;
|
||||
|
||||
@@ -223,8 +242,8 @@ async fn non_own_profile_skips_pending_followers() {
|
||||
let user = user_repo.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let result = get_profile::execute(
|
||||
&deps,
|
||||
let result = get_local_profile::execute(
|
||||
&d,
|
||||
GetUserProfileQuery {
|
||||
user_id: uid,
|
||||
view: ProfileView::Recent,
|
||||
@@ -241,3 +260,96 @@ async fn non_own_profile_skips_pending_followers() {
|
||||
|
||||
assert!(result.pending_followers.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn populates_handle_and_actor_url_for_a_local_user() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
let d = deps(&b);
|
||||
|
||||
setup_user(&b, "handle@test.com", "gabriel").await;
|
||||
let email = Email::new("handle@test.com".into()).unwrap();
|
||||
let user = user_repo.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
// `InMemoryUserRepository::update_profile` now actually persists (previously a
|
||||
// no-op stub) — use it to give this test a real display_name/bio to assert,
|
||||
// rather than asserting `None` for a mapping that could just as easily be
|
||||
// wrong, inverted, or hardcoded.
|
||||
let update_deps = UpdateProfileDeps {
|
||||
user: user_repo.clone(),
|
||||
object_storage: b.object_storage.clone(),
|
||||
event_publisher: b.event_publisher.clone(),
|
||||
};
|
||||
update_profile::execute(
|
||||
&update_deps,
|
||||
UpdateProfileCommand {
|
||||
user_id: uid,
|
||||
display_name: Some("Gabriel K".into()),
|
||||
bio: Some("Movies and diaries.".into()),
|
||||
avatar_bytes: None,
|
||||
avatar_content_type: None,
|
||||
banner_bytes: None,
|
||||
banner_content_type: None,
|
||||
also_known_as: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = get_local_profile::execute(
|
||||
&d,
|
||||
GetUserProfileQuery {
|
||||
user_id: uid,
|
||||
view: ProfileView::Recent,
|
||||
limit: None,
|
||||
offset: None,
|
||||
sort_by: domain::models::FeedSortBy::Date,
|
||||
search: None,
|
||||
is_own_profile: true,
|
||||
include_remote: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.identity.username, "gabriel");
|
||||
assert_eq!(result.identity.handle, "@gabriel@localhost:3000");
|
||||
assert_eq!(
|
||||
result.identity.actor_url,
|
||||
format!("http://localhost:3000/users/{}", uid)
|
||||
);
|
||||
assert_eq!(result.identity.display_name, Some("Gabriel K".to_string()));
|
||||
assert_eq!(result.identity.bio, Some("Movies and diaries.".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn identity_display_name_and_bio_are_none_when_unset() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
let d = deps(&b);
|
||||
|
||||
setup_user(&b, "unset@test.com", "unsetuser").await;
|
||||
let email = Email::new("unset@test.com".into()).unwrap();
|
||||
let user = user_repo.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let result = get_local_profile::execute(
|
||||
&d,
|
||||
GetUserProfileQuery {
|
||||
user_id: uid,
|
||||
view: ProfileView::Recent,
|
||||
limit: None,
|
||||
offset: None,
|
||||
sort_by: domain::models::FeedSortBy::Date,
|
||||
search: None,
|
||||
is_own_profile: true,
|
||||
include_remote: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.identity.display_name, None);
|
||||
assert_eq!(result.identity.bio, None);
|
||||
}
|
||||
66
crates/application/src/users/tests/get_page_viewer.rs
Normal file
66
crates/application/src/users/tests/get_page_viewer.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use domain::models::UserRole;
|
||||
use domain::value_objects::{Email, FollowTarget, SocialIdentity, UserId};
|
||||
|
||||
use crate::auth::commands::RegisterCommand;
|
||||
use crate::auth::deps::RegisterDeps;
|
||||
use crate::auth::register;
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
use crate::users::deps::GetPageViewerDeps;
|
||||
use crate::users::get_page_viewer;
|
||||
|
||||
async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) -> uuid::Uuid {
|
||||
let deps = RegisterDeps {
|
||||
user: b.user_repo.clone(),
|
||||
password_hasher: b.password_hasher.clone(),
|
||||
config: b.config.clone(),
|
||||
};
|
||||
register::execute(
|
||||
&deps,
|
||||
RegisterCommand {
|
||||
email: email.into(),
|
||||
username: username.into(),
|
||||
password: "password123".into(),
|
||||
role: UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let user = b
|
||||
.user_repo
|
||||
.find_by_email(&Email::new(email.into()).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
user.id().value()
|
||||
}
|
||||
|
||||
/// `get_page_viewer` absorbs `helpers.rs::build_page_context`'s inline pending-count
|
||||
/// computation. It must reuse `FollowGraphQuery::count_pending_followers` rather than
|
||||
/// re-deriving the count from raw follow rows.
|
||||
#[tokio::test]
|
||||
async fn get_page_viewer_reports_pending_follower_count() {
|
||||
let b = TestContextBuilder::new();
|
||||
let owner_uuid = setup_user(&b, "owner@test.com", "pageowner").await;
|
||||
let follower1 = setup_user(&b, "follower1@test.com", "pagefollower1").await;
|
||||
let follower2 = setup_user(&b, "follower2@test.com", "pagefollower2").await;
|
||||
|
||||
let owner_id = UserId::from_uuid(owner_uuid);
|
||||
let target = FollowTarget::Identity(SocialIdentity::Local(owner_id.clone()));
|
||||
b.social_command
|
||||
.follow(&UserId::from_uuid(follower1), &target)
|
||||
.await
|
||||
.unwrap();
|
||||
b.social_command
|
||||
.follow(&UserId::from_uuid(follower2), &target)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let deps = GetPageViewerDeps {
|
||||
user: b.user_repo.clone(),
|
||||
follow_graph: b.social_query_unified.clone(),
|
||||
};
|
||||
|
||||
let v = get_page_viewer::execute(&deps, owner_uuid).await.unwrap();
|
||||
assert_eq!(v.pending_follow_count, 2);
|
||||
assert!(!v.is_admin);
|
||||
}
|
||||
64
crates/application/src/users/tests/get_profile_settings.rs
Normal file
64
crates/application/src/users/tests/get_profile_settings.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use domain::models::{ProfileField, UserRole};
|
||||
use domain::value_objects::{Email, UserId};
|
||||
|
||||
use crate::auth::commands::RegisterCommand;
|
||||
use crate::auth::deps::RegisterDeps;
|
||||
use crate::auth::register;
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
use crate::users::deps::GetProfileSettingsDeps;
|
||||
use crate::users::get_profile_settings;
|
||||
|
||||
/// `get_profile_settings` absorbs BOTH of the handler's calls: the user lookup
|
||||
/// (`repos.user.find_by_id`) and the profile_fields join
|
||||
/// (`repos.profile_fields.get_fields`). This proves the join actually moved in,
|
||||
/// not just the user lookup.
|
||||
#[tokio::test]
|
||||
async fn get_profile_settings_includes_profile_fields() {
|
||||
let b = TestContextBuilder::new();
|
||||
let register_deps = RegisterDeps {
|
||||
user: b.user_repo.clone(),
|
||||
password_hasher: b.password_hasher.clone(),
|
||||
config: b.config.clone(),
|
||||
};
|
||||
register::execute(
|
||||
®ister_deps,
|
||||
RegisterCommand {
|
||||
email: "settings@test.com".into(),
|
||||
username: "settingsuser".into(),
|
||||
password: "password123".into(),
|
||||
role: UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let user = b
|
||||
.user_repo
|
||||
.find_by_email(&Email::new("settings@test.com".into()).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
b.profile_fields_repo
|
||||
.set_fields(
|
||||
&UserId::from_uuid(uid),
|
||||
vec![ProfileField {
|
||||
name: "pronouns".into(),
|
||||
value: "they/them".into(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let deps = GetProfileSettingsDeps {
|
||||
user: b.user_repo.clone(),
|
||||
profile_fields: b.profile_fields_repo.clone(),
|
||||
instance: domain::value_objects::InstanceIdentity::new(b.config.base_url.clone()),
|
||||
};
|
||||
|
||||
let s = get_profile_settings::execute(&deps, uid).await.unwrap();
|
||||
assert_eq!(
|
||||
s.fields,
|
||||
vec![("pronouns".to_string(), "they/them".to_string())]
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{test_helpers::TestContextBuilder, users::get_settings};
|
||||
use crate::{
|
||||
test_helpers::TestContextBuilder,
|
||||
users::{deps::GetSettingsDeps, get_settings},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_default_settings() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_settings = b.user_settings_repo.clone();
|
||||
let deps = GetSettingsDeps {
|
||||
user_settings: b.user_settings_repo.clone(),
|
||||
};
|
||||
|
||||
let settings = get_settings::execute(user_settings, Uuid::nil())
|
||||
.await
|
||||
.unwrap();
|
||||
let settings = get_settings::execute(&deps, Uuid::nil()).await.unwrap();
|
||||
|
||||
assert!(settings.federate_goals());
|
||||
assert!(settings.federate_reviews());
|
||||
|
||||
23
crates/application/src/users/tests/resolve_username_to_id.rs
Normal file
23
crates/application/src/users/tests/resolve_username_to_id.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use domain::value_objects::Username;
|
||||
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
use crate::users::deps::ResolveUsernameDeps;
|
||||
use crate::users::resolve_username_to_id;
|
||||
|
||||
/// `resolve_username_to_id` absorbs `handlers/users.rs::get_user_by_username`'s
|
||||
/// `repos.user.find_by_username()` call. Unknown usernames must resolve to `None`,
|
||||
/// not an error — the handler turns `None` into a 404, same as today.
|
||||
#[tokio::test]
|
||||
async fn resolve_username_to_id_returns_none_for_unknown() {
|
||||
let b = TestContextBuilder::new();
|
||||
let deps = ResolveUsernameDeps {
|
||||
user: b.user_repo.clone(),
|
||||
};
|
||||
let unknown = Username::new("nosuchuser".into()).unwrap();
|
||||
assert!(
|
||||
resolve_username_to_id::execute(&deps, &unknown)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,9 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
test_helpers::TestContextBuilder,
|
||||
users::{commands::UpdateProfileFieldsCommand, update_profile_fields},
|
||||
users::{
|
||||
commands::UpdateProfileFieldsCommand, deps::UpdateProfileFieldsDeps, update_profile_fields,
|
||||
},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
@@ -17,12 +19,13 @@ async fn saves_profile_fields() {
|
||||
let b = TestContextBuilder::new()
|
||||
.with_profile_fields(Arc::clone(&fields_repo) as _)
|
||||
.with_event_publisher(Arc::clone(&events) as _);
|
||||
let profile_fields = b.profile_fields_repo.clone();
|
||||
let event_publisher = b.event_publisher.clone();
|
||||
let deps = UpdateProfileFieldsDeps {
|
||||
profile_fields: b.profile_fields_repo.clone(),
|
||||
event_publisher: b.event_publisher.clone(),
|
||||
};
|
||||
|
||||
update_profile_fields::execute(
|
||||
profile_fields,
|
||||
event_publisher,
|
||||
&deps,
|
||||
UpdateProfileFieldsCommand {
|
||||
user_id: Uuid::nil(),
|
||||
fields: vec![
|
||||
@@ -51,8 +54,10 @@ async fn saves_profile_fields() {
|
||||
#[tokio::test]
|
||||
async fn rejects_more_than_four_fields() {
|
||||
let b = TestContextBuilder::new();
|
||||
let profile_fields = b.profile_fields_repo.clone();
|
||||
let event_publisher = b.event_publisher.clone();
|
||||
let deps = UpdateProfileFieldsDeps {
|
||||
profile_fields: b.profile_fields_repo.clone(),
|
||||
event_publisher: b.event_publisher.clone(),
|
||||
};
|
||||
|
||||
let fields: Vec<ProfileField> = (0..5)
|
||||
.map(|i| ProfileField {
|
||||
@@ -62,8 +67,7 @@ async fn rejects_more_than_four_fields() {
|
||||
.collect();
|
||||
|
||||
let result = update_profile_fields::execute(
|
||||
profile_fields,
|
||||
event_publisher,
|
||||
&deps,
|
||||
UpdateProfileFieldsCommand {
|
||||
user_id: Uuid::nil(),
|
||||
fields,
|
||||
|
||||
@@ -5,7 +5,11 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
test_helpers::TestContextBuilder,
|
||||
users::{get_settings, update_settings::UpdateUserSettingsCommand},
|
||||
users::{
|
||||
deps::{GetSettingsDeps, UpdateSettingsDeps},
|
||||
get_settings,
|
||||
update_settings::UpdateUserSettingsCommand,
|
||||
},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
@@ -16,7 +20,9 @@ async fn updates_federate_goals() {
|
||||
let uid = Uuid::nil();
|
||||
|
||||
crate::users::update_settings::execute(
|
||||
user_settings.clone(),
|
||||
&UpdateSettingsDeps {
|
||||
user_settings: user_settings.clone(),
|
||||
},
|
||||
UpdateUserSettingsCommand {
|
||||
user_id: uid,
|
||||
federate_goals: false,
|
||||
@@ -27,7 +33,9 @@ async fn updates_federate_goals() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let settings = get_settings::execute(user_settings, uid).await.unwrap();
|
||||
let settings = get_settings::execute(&GetSettingsDeps { user_settings }, uid)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!settings.federate_goals());
|
||||
assert!(settings.federate_reviews());
|
||||
assert!(settings.federate_watchlist());
|
||||
@@ -41,7 +49,9 @@ async fn updates_federate_reviews() {
|
||||
let uid = Uuid::nil();
|
||||
|
||||
crate::users::update_settings::execute(
|
||||
user_settings.clone(),
|
||||
&UpdateSettingsDeps {
|
||||
user_settings: user_settings.clone(),
|
||||
},
|
||||
UpdateUserSettingsCommand {
|
||||
user_id: uid,
|
||||
federate_goals: true,
|
||||
@@ -52,7 +62,9 @@ async fn updates_federate_reviews() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let settings = get_settings::execute(user_settings, uid).await.unwrap();
|
||||
let settings = get_settings::execute(&GetSettingsDeps { user_settings }, uid)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(settings.federate_goals());
|
||||
assert!(!settings.federate_reviews());
|
||||
assert!(settings.federate_watchlist());
|
||||
@@ -66,7 +78,9 @@ async fn updates_federate_watchlist() {
|
||||
let uid = Uuid::nil();
|
||||
|
||||
crate::users::update_settings::execute(
|
||||
user_settings.clone(),
|
||||
&UpdateSettingsDeps {
|
||||
user_settings: user_settings.clone(),
|
||||
},
|
||||
UpdateUserSettingsCommand {
|
||||
user_id: uid,
|
||||
federate_goals: true,
|
||||
@@ -77,7 +91,9 @@ async fn updates_federate_watchlist() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let settings = get_settings::execute(user_settings, uid).await.unwrap();
|
||||
let settings = get_settings::execute(&GetSettingsDeps { user_settings }, uid)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(settings.federate_goals());
|
||||
assert!(settings.federate_reviews());
|
||||
assert!(!settings.federate_watchlist());
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::UserProfile,
|
||||
ports::{EventPublisher, UserProfileFieldsRepository},
|
||||
value_objects::UserId,
|
||||
errors::DomainError, events::DomainEvent, models::UserProfile, value_objects::UserId,
|
||||
};
|
||||
|
||||
use crate::users::commands::UpdateProfileFieldsCommand;
|
||||
use crate::users::deps::UpdateProfileFieldsDeps;
|
||||
|
||||
pub async fn execute(
|
||||
profile_fields: Arc<dyn UserProfileFieldsRepository>,
|
||||
event_publisher: Arc<dyn EventPublisher>,
|
||||
deps: &UpdateProfileFieldsDeps,
|
||||
cmd: UpdateProfileFieldsCommand,
|
||||
) -> Result<(), DomainError> {
|
||||
UserProfile::validate_custom_fields(&cmd.fields)?;
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
profile_fields.set_fields(&user_id, cmd.fields).await?;
|
||||
event_publisher
|
||||
deps.profile_fields.set_fields(&user_id, cmd.fields).await?;
|
||||
deps.event_publisher
|
||||
.publish(&DomainEvent::UserUpdated { user_id })
|
||||
.await?;
|
||||
Ok(())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
use domain::{errors::DomainError, value_objects::UserId};
|
||||
|
||||
use domain::{errors::DomainError, ports::UserSettingsRepository, value_objects::UserId};
|
||||
use crate::users::deps::UpdateSettingsDeps;
|
||||
|
||||
pub struct UpdateUserSettingsCommand {
|
||||
pub user_id: uuid::Uuid,
|
||||
@@ -10,15 +10,15 @@ pub struct UpdateUserSettingsCommand {
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
user_settings: Arc<dyn UserSettingsRepository>,
|
||||
deps: &UpdateSettingsDeps,
|
||||
cmd: UpdateUserSettingsCommand,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = UserId::from_uuid(cmd.user_id);
|
||||
let mut settings = user_settings.get(&uid).await?;
|
||||
let mut settings = deps.user_settings.get(&uid).await?;
|
||||
settings.set_federate_goals(cmd.federate_goals);
|
||||
settings.set_federate_reviews(cmd.federate_reviews);
|
||||
settings.set_federate_watchlist(cmd.federate_watchlist);
|
||||
user_settings.save(&settings).await
|
||||
deps.user_settings.save(&settings).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in New Issue
Block a user