Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
91
crates/application/tests/users/authorize_admin_test.rs
Normal file
91
crates/application/tests/users/authorize_admin_test.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
use crate::support::TestContextBuilder;
|
||||
use crate::support::register_command;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::models::UserRole;
|
||||
use domain::testing::InMemoryStore;
|
||||
|
||||
use application::{
|
||||
auth::{commands::RegisterCommand, deps::RegisterDeps, register},
|
||||
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 {
|
||||
role,
|
||||
..register_command(email, username)
|
||||
},
|
||||
)
|
||||
.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 = InMemoryStore::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 = InMemoryStore::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);
|
||||
}
|
||||
84
crates/application/tests/users/ensure_active_test.rs
Normal file
84
crates/application/tests/users/ensure_active_test.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::UserRepository,
|
||||
testing::InMemoryStore,
|
||||
value_objects::{Email, UserId},
|
||||
};
|
||||
|
||||
use application::{
|
||||
auth::{deps::RegisterDeps, register},
|
||||
users::{deps::EnsureActiveDeps, ensure_active},
|
||||
};
|
||||
|
||||
use crate::support::{TestContextBuilder, register_command};
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_live_user_is_active() {
|
||||
let store = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&store) as _);
|
||||
let reg = RegisterDeps {
|
||||
user: b.user_repo.clone(),
|
||||
password_hasher: b.password_hasher.clone(),
|
||||
config: b.config.clone(),
|
||||
};
|
||||
register::execute(®, register_command("alice@example.com", "alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
let id = store
|
||||
.find_by_email(&Email::new("alice@example.com".into()).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id()
|
||||
.clone();
|
||||
let deps = EnsureActiveDeps {
|
||||
user: b.user_repo.clone(),
|
||||
};
|
||||
|
||||
ensure_active::execute(&deps, &id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_deleted_user_is_rejected() {
|
||||
let store = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&store) as _);
|
||||
let reg = RegisterDeps {
|
||||
user: b.user_repo.clone(),
|
||||
password_hasher: b.password_hasher.clone(),
|
||||
config: b.config.clone(),
|
||||
};
|
||||
register::execute(®, register_command("alice@example.com", "alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
let id = store
|
||||
.find_by_email(&Email::new("alice@example.com".into()).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id()
|
||||
.clone();
|
||||
store.mark_deleted(&id, &[]).await.unwrap();
|
||||
let deps = EnsureActiveDeps {
|
||||
user: b.user_repo.clone(),
|
||||
};
|
||||
|
||||
let err = ensure_active::execute(&deps, &id).await.unwrap_err();
|
||||
|
||||
assert!(matches!(err, DomainError::Unauthorized(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unknown_user_is_rejected() {
|
||||
let b = TestContextBuilder::new();
|
||||
let deps = EnsureActiveDeps {
|
||||
user: b.user_repo.clone(),
|
||||
};
|
||||
|
||||
let err = ensure_active::execute(&deps, &UserId::generate())
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, DomainError::Unauthorized(_)), "got {err:?}");
|
||||
}
|
||||
113
crates/application/tests/users/get_current_profile_test.rs
Normal file
113
crates/application/tests/users/get_current_profile_test.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use crate::support::TestContextBuilder;
|
||||
use crate::support::register_command;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::models::{ProfileField, User, UserProfile, UserRole};
|
||||
use domain::ports::UserRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
use domain::value_objects::{Email, PasswordHash, UserId, Username};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
auth::{deps::RegisterDeps, register},
|
||||
users::{deps::GetCurrentProfileDeps, get_current_profile, queries::GetCurrentProfileQuery},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_profile_for_existing_user() {
|
||||
let users = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
let user_repo = b.user_repo.clone();
|
||||
let reg_deps = RegisterDeps {
|
||||
user: b.user_repo.clone(),
|
||||
password_hasher: b.password_hasher.clone(),
|
||||
config: b.config.clone(),
|
||||
};
|
||||
|
||||
register::execute(®_deps, register_command("alice@example.com", "alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user = users
|
||||
.find_by_email(&domain::value_objects::Email::new("alice@example.com".into()).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let deps = GetCurrentProfileDeps { user: user_repo };
|
||||
let profile = get_current_profile::execute(
|
||||
&deps,
|
||||
GetCurrentProfileQuery {
|
||||
user_id: user.id().value(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(profile.username, "alice");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
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(
|
||||
&deps,
|
||||
GetCurrentProfileQuery {
|
||||
user_id: Uuid::new_v4(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_profile_with_avatar_banner_and_fields() {
|
||||
let users = InMemoryStore::new();
|
||||
let uid = UserId::generate();
|
||||
|
||||
let user = User::from_persistence(
|
||||
uid.clone(),
|
||||
Email::new("full@example.com".into()).unwrap(),
|
||||
Username::new("fulluser".into()).unwrap(),
|
||||
PasswordHash::new("hashed".into()).unwrap(),
|
||||
UserRole::Standard,
|
||||
UserProfile {
|
||||
display_name: Some("Full Name".into()),
|
||||
bio: Some("My bio".into()),
|
||||
avatar_path: Some("avatars/abc123".into()),
|
||||
banner_path: Some("banners/def456".into()),
|
||||
also_known_as: None,
|
||||
profile_fields: vec![ProfileField {
|
||||
name: "Website".into(),
|
||||
value: "https://example.com".into(),
|
||||
}],
|
||||
},
|
||||
);
|
||||
users.seed_user(user);
|
||||
|
||||
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(
|
||||
&deps,
|
||||
GetCurrentProfileQuery {
|
||||
user_id: uid.value(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(profile.username, "fulluser");
|
||||
assert_eq!(profile.display_name.as_deref(), Some("Full Name"));
|
||||
assert_eq!(profile.bio.as_deref(), Some("My bio"));
|
||||
assert_eq!(profile.avatar_path.as_deref(), Some("avatars/abc123"));
|
||||
assert_eq!(profile.banner_path.as_deref(), Some("banners/def456"));
|
||||
assert_eq!(profile.fields.len(), 1);
|
||||
assert_eq!(profile.fields[0].name, "Website");
|
||||
assert_eq!(profile.fields[0].value, "https://example.com");
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use crate::support::TestContextBuilder;
|
||||
use application::users::deps::GetFederatedProfileStatsDeps;
|
||||
use application::users::get_federated_profile_stats;
|
||||
use application::users::queries::{GetUserProfileQuery, ProfileView};
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
115
crates/application/tests/users/get_federated_profile_test.rs
Normal file
115
crates/application/tests/users/get_federated_profile_test.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 application::users::deps::GetFederatedProfileDeps;
|
||||
use application::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(_)));
|
||||
}
|
||||
333
crates/application/tests/users/get_local_profile_test.rs
Normal file
333
crates/application/tests/users/get_local_profile_test.rs
Normal file
@@ -0,0 +1,333 @@
|
||||
use crate::support::{register_command, update_profile_command};
|
||||
use domain::errors::DomainError;
|
||||
use domain::value_objects::Email;
|
||||
|
||||
use crate::support::TestContextBuilder;
|
||||
use application::auth::deps::RegisterDeps;
|
||||
use application::auth::register;
|
||||
use application::users::commands::UpdateProfileCommand;
|
||||
use application::users::deps::{GetLocalProfileDeps, UpdateProfileDeps};
|
||||
use application::users::get_local_profile;
|
||||
use application::users::queries::{GetUserProfileQuery, ProfileView};
|
||||
use application::users::update_profile;
|
||||
|
||||
async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) {
|
||||
let deps = RegisterDeps {
|
||||
user: b.user_repo.clone(),
|
||||
password_hasher: b.password_hasher.clone(),
|
||||
config: b.config.clone(),
|
||||
};
|
||||
register::execute(&deps, register_command(email, username))
|
||||
.await
|
||||
.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,
|
||||
}
|
||||
}
|
||||
|
||||
#[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 d = deps(&b);
|
||||
|
||||
setup_user(&b, "profile@test.com", "profuser").await;
|
||||
|
||||
let email = Email::new("profile@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!(result.entries.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_history_view() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
let d = deps(&b);
|
||||
|
||||
setup_user(&b, "hist@test.com", "histuser").await;
|
||||
|
||||
let email = Email::new("hist@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::History,
|
||||
limit: None,
|
||||
offset: None,
|
||||
sort_by: domain::models::FeedSortBy::Date,
|
||||
search: None,
|
||||
is_own_profile: true,
|
||||
include_remote: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.history.is_some());
|
||||
assert!(result.entries.is_none());
|
||||
assert!(result.trends.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_trends_view() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
let d = deps(&b);
|
||||
|
||||
setup_user(&b, "trends@test.com", "trendsuser").await;
|
||||
|
||||
let email = Email::new("trends@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::Trends,
|
||||
limit: None,
|
||||
offset: None,
|
||||
sort_by: domain::models::FeedSortBy::Date,
|
||||
search: None,
|
||||
is_own_profile: true,
|
||||
include_remote: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.trends.is_some());
|
||||
assert!(result.entries.is_none());
|
||||
assert!(result.history.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_ratings_view() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
let d = deps(&b);
|
||||
|
||||
setup_user(&b, "ratings@test.com", "ratingsuser").await;
|
||||
|
||||
let email = Email::new("ratings@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::Ratings,
|
||||
limit: None,
|
||||
offset: None,
|
||||
sort_by: domain::models::FeedSortBy::Rating,
|
||||
search: None,
|
||||
is_own_profile: true,
|
||||
include_remote: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.entries.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_recent_with_search() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
let d = deps(&b);
|
||||
|
||||
setup_user(&b, "search@test.com", "searchuser").await;
|
||||
|
||||
let email = Email::new("search@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: Some(10),
|
||||
offset: Some(0),
|
||||
sort_by: domain::models::FeedSortBy::Date,
|
||||
search: Some("blade".into()),
|
||||
is_own_profile: true,
|
||||
include_remote: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.entries.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_own_profile_skips_pending_followers() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
let d = deps(&b);
|
||||
|
||||
setup_user(&b, "other@test.com", "otheruser").await;
|
||||
|
||||
let email = Email::new("other@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: false,
|
||||
include_remote: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
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();
|
||||
|
||||
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 {
|
||||
display_name: Some("Gabriel K".into()),
|
||||
bio: Some("Movies and diaries.".into()),
|
||||
..update_profile_command(uid)
|
||||
},
|
||||
)
|
||||
.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);
|
||||
}
|
||||
54
crates/application/tests/users/get_page_viewer_test.rs
Normal file
54
crates/application/tests/users/get_page_viewer_test.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use crate::support::register_command;
|
||||
use domain::value_objects::{Email, FollowTarget, SocialIdentity, UserId};
|
||||
|
||||
use crate::support::TestContextBuilder;
|
||||
use application::auth::deps::RegisterDeps;
|
||||
use application::auth::register;
|
||||
use application::users::deps::GetPageViewerDeps;
|
||||
use application::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, register_command(email, username))
|
||||
.await
|
||||
.unwrap();
|
||||
let user = b
|
||||
.user_repo
|
||||
.find_by_email(&Email::new(email.into()).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
user.id().value()
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
56
crates/application/tests/users/get_profile_settings_test.rs
Normal file
56
crates/application/tests/users/get_profile_settings_test.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use crate::support::register_command;
|
||||
use domain::models::ProfileField;
|
||||
use domain::value_objects::{Email, UserId};
|
||||
|
||||
use crate::support::TestContextBuilder;
|
||||
use application::auth::deps::RegisterDeps;
|
||||
use application::auth::register;
|
||||
use application::users::deps::GetProfileSettingsDeps;
|
||||
use application::users::get_profile_settings;
|
||||
|
||||
#[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,
|
||||
register_command("settings@test.com", "settingsuser"),
|
||||
)
|
||||
.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())]
|
||||
);
|
||||
}
|
||||
18
crates/application/tests/users/get_settings_test.rs
Normal file
18
crates/application/tests/users/get_settings_test.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use crate::support::TestContextBuilder;
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::users::{deps::GetSettingsDeps, get_settings};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_default_settings() {
|
||||
let b = TestContextBuilder::new();
|
||||
let deps = GetSettingsDeps {
|
||||
user_settings: b.user_settings_repo.clone(),
|
||||
};
|
||||
|
||||
let settings = get_settings::execute(&deps, Uuid::nil()).await.unwrap();
|
||||
|
||||
assert!(settings.federate_goals());
|
||||
assert!(settings.federate_reviews());
|
||||
assert!(settings.federate_watchlist());
|
||||
}
|
||||
36
crates/application/tests/users/get_users_test.rs
Normal file
36
crates/application/tests/users/get_users_test.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use crate::support::TestContextBuilder;
|
||||
use domain::testing::test_user;
|
||||
|
||||
use application::users::deps::GetUsersListDeps;
|
||||
use application::users::get_users;
|
||||
use application::users::queries::GetUsersQuery;
|
||||
|
||||
fn deps(b: &TestContextBuilder) -> GetUsersListDeps {
|
||||
GetUsersListDeps {
|
||||
user: b.user_repo.clone(),
|
||||
federation_admin: b.federation_admin.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_lists_when_nothing_exists() {
|
||||
let b = TestContextBuilder::new();
|
||||
|
||||
let result = get_users::execute(&deps(&b), GetUsersQuery).await.unwrap();
|
||||
|
||||
assert!(result.users.is_empty());
|
||||
assert!(result.remote_actors.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lists_saved_user() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user = test_user("alice");
|
||||
b.user_repo.save(&user).await.unwrap();
|
||||
|
||||
let result = get_users::execute(&deps(&b), GetUsersQuery).await.unwrap();
|
||||
|
||||
assert_eq!(result.users.len(), 1);
|
||||
assert_eq!(result.users[0].username(), "alice");
|
||||
assert!(result.remote_actors.is_empty());
|
||||
}
|
||||
274
crates/application/tests/users/purge_account_test.rs
Normal file
274
crates/application/tests/users/purge_account_test.rs
Normal file
@@ -0,0 +1,274 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use application::users::purge_account::AccountPurgeHandler;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::{DomainEvent, EventEnvelope, EventId},
|
||||
ports::{
|
||||
AccountPurger, EventConsumer, EventHandler, FederationDeletionNotifier, ObjectStorage,
|
||||
UserRepository,
|
||||
},
|
||||
testing::{InMemoryStore, NoopAckHandle, test_user},
|
||||
value_objects::UserId,
|
||||
};
|
||||
use futures::stream::{self, BoxStream};
|
||||
|
||||
type Log = Arc<Mutex<Vec<String>>>;
|
||||
|
||||
struct Notifier {
|
||||
log: Log,
|
||||
fail: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FederationDeletionNotifier for Notifier {
|
||||
async fn notify_deleted(&self, _: &UserId) -> Result<(), DomainError> {
|
||||
self.log.lock().unwrap().push("notify".into());
|
||||
if self.fail {
|
||||
Err(DomainError::InfrastructureError("peer unreachable".into()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Purger {
|
||||
log: Log,
|
||||
purged: Mutex<bool>,
|
||||
keys: Vec<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AccountPurger for Purger {
|
||||
async fn is_purged(&self, _: &UserId) -> Result<bool, DomainError> {
|
||||
Ok(*self.purged.lock().unwrap())
|
||||
}
|
||||
async fn owned_image_keys(&self, _: &UserId) -> Result<Vec<String>, DomainError> {
|
||||
Ok(self.keys.clone())
|
||||
}
|
||||
async fn purge(&self, _: &UserId) -> Result<(), DomainError> {
|
||||
self.log.lock().unwrap().push("purge".into());
|
||||
*self.purged.lock().unwrap() = true;
|
||||
Ok(())
|
||||
}
|
||||
async fn forget_images(&self, _: &UserId) -> Result<(), DomainError> {
|
||||
self.log.lock().unwrap().push("forget".into());
|
||||
Ok(())
|
||||
}
|
||||
async fn purge_expired_tombstones(&self, _: chrono::NaiveDateTime) -> Result<u64, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
struct Storage {
|
||||
log: Log,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ObjectStorage for Storage {
|
||||
async fn store(&self, _: &str, _: &[u8]) -> Result<String, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get(&self, _: &str) -> Result<Vec<u8>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_stream(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> Result<BoxStream<'static, Result<bytes::Bytes, DomainError>>, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn delete(&self, key: &str) -> Result<(), DomainError> {
|
||||
self.log.lock().unwrap().push(format!("delete {key}"));
|
||||
Ok(())
|
||||
}
|
||||
async fn exists(&self, _: &str) -> Result<bool, DomainError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
handler: Arc<AccountPurgeHandler>,
|
||||
purger: Arc<Purger>,
|
||||
log: Log,
|
||||
store: Arc<InMemoryStore>,
|
||||
user: UserId,
|
||||
}
|
||||
|
||||
async fn fixture(notify_fails: bool, tombstoned: bool) -> Fixture {
|
||||
let store = InMemoryStore::new();
|
||||
let alice = test_user("alice");
|
||||
store.save(&alice).await.unwrap();
|
||||
if tombstoned {
|
||||
store.mark_deleted(alice.id(), &[]).await.unwrap();
|
||||
}
|
||||
let log: Log = Arc::default();
|
||||
let purger = Arc::new(Purger {
|
||||
log: log.clone(),
|
||||
purged: Mutex::new(false),
|
||||
keys: vec!["avatars/a.webp".into(), "banners/a.webp".into()],
|
||||
});
|
||||
let handler = Arc::new(AccountPurgeHandler::new(
|
||||
store.clone(),
|
||||
Arc::new(Notifier {
|
||||
log: log.clone(),
|
||||
fail: notify_fails,
|
||||
}),
|
||||
purger.clone(),
|
||||
Arc::new(Storage { log: log.clone() }),
|
||||
));
|
||||
Fixture {
|
||||
handler,
|
||||
purger,
|
||||
log,
|
||||
store,
|
||||
user: alice.id().clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn deleted(user: &UserId) -> DomainEvent {
|
||||
DomainEvent::UserDeleted {
|
||||
user_id: user.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn entries(log: &Log) -> Vec<String> {
|
||||
log.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_handler_name_is_stable() {
|
||||
let f = tokio::runtime::Runtime::new()
|
||||
.unwrap()
|
||||
.block_on(fixture(false, true));
|
||||
assert_eq!(f.handler.name(), "account_purge");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notifies_first_then_purges_then_removes_images_then_forgets_them() {
|
||||
let f = fixture(false, true).await;
|
||||
|
||||
f.handler.handle(&deleted(&f.user)).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
entries(&f.log),
|
||||
[
|
||||
"notify",
|
||||
"purge",
|
||||
"delete avatars/a.webp",
|
||||
"delete banners/a.webp",
|
||||
"forget"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn purge_continues_when_federation_notify_fails() {
|
||||
let f = fixture(true, true).await;
|
||||
|
||||
f.handler.handle(&deleted(&f.user)).await.unwrap();
|
||||
|
||||
let log = entries(&f.log);
|
||||
assert_eq!(log[0], "notify");
|
||||
assert!(log.contains(&"purge".to_string()), "{log:?}");
|
||||
assert!(log.contains(&"forget".to_string()), "{log:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_rerun_does_not_notify_again() {
|
||||
let f = fixture(false, true).await;
|
||||
f.handler.handle(&deleted(&f.user)).await.unwrap();
|
||||
|
||||
f.handler.handle(&deleted(&f.user)).await.unwrap();
|
||||
|
||||
let notices = entries(&f.log).iter().filter(|e| *e == "notify").count();
|
||||
assert_eq!(notices, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn does_nothing_when_the_account_is_not_tombstoned() {
|
||||
let live = fixture(false, false).await;
|
||||
live.handler.handle(&deleted(&live.user)).await.unwrap();
|
||||
assert!(entries(&live.log).is_empty(), "a live user is never purged");
|
||||
|
||||
let unknown = fixture(false, true).await;
|
||||
unknown
|
||||
.handler
|
||||
.handle(&deleted(&UserId::generate()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(entries(&unknown.log).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ignores_other_events() {
|
||||
let f = fixture(false, true).await;
|
||||
f.handler
|
||||
.handle(&DomainEvent::UserUpdated {
|
||||
user_id: f.user.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(entries(&f.log).is_empty());
|
||||
}
|
||||
|
||||
struct TwiceConsumer {
|
||||
id: EventId,
|
||||
event: DomainEvent,
|
||||
}
|
||||
|
||||
impl EventConsumer for TwiceConsumer {
|
||||
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
||||
let deliveries: Vec<Result<EventEnvelope, DomainError>> = (0..2)
|
||||
.map(|_| {
|
||||
Ok(EventEnvelope::new(
|
||||
Some(self.id),
|
||||
self.event.clone(),
|
||||
Box::new(NoopAckHandle),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
Box::pin(stream::iter(deliveries))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_redelivered_event_is_skipped_by_the_ledger() {
|
||||
let f = fixture(false, true).await;
|
||||
let consumer = TwiceConsumer {
|
||||
id: EventId::new(),
|
||||
event: deleted(&f.user),
|
||||
};
|
||||
|
||||
application::worker::WorkerService::new(
|
||||
Arc::new(consumer),
|
||||
vec![f.handler.clone() as Arc<dyn EventHandler>],
|
||||
f.store.clone(),
|
||||
)
|
||||
.unwrap()
|
||||
.run(tokio::sync::watch::channel(false).1)
|
||||
.await;
|
||||
|
||||
let purges = entries(&f.log).iter().filter(|e| *e == "purge").count();
|
||||
assert_eq!(purges, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_rerun_after_a_crash_following_the_commit_deletes_the_images() {
|
||||
let f = fixture(false, true).await;
|
||||
*f.purger.purged.lock().unwrap() = true;
|
||||
|
||||
f.handler.handle(&deleted(&f.user)).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
entries(&f.log),
|
||||
[
|
||||
"purge",
|
||||
"delete avatars/a.webp",
|
||||
"delete banners/a.webp",
|
||||
"forget"
|
||||
],
|
||||
"no second federation notice, images removed and forgotten"
|
||||
);
|
||||
}
|
||||
302
crates/application/tests/users/request_account_deletion_test.rs
Normal file
302
crates/application/tests/users/request_account_deletion_test.rs
Normal file
@@ -0,0 +1,302 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::UserRole,
|
||||
ports::UserRepository,
|
||||
testing::InMemoryStore,
|
||||
value_objects::{Email, UserId},
|
||||
};
|
||||
|
||||
use application::{
|
||||
auth::{deps::RegisterDeps, register},
|
||||
users::{deps::RequestAccountDeletionDeps, request_account_deletion},
|
||||
};
|
||||
|
||||
use crate::support::{TestContextBuilder, register_command};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::models::{User, UserSummary};
|
||||
use domain::ports::UserRepository as UserRepositoryPort;
|
||||
use domain::value_objects::Username;
|
||||
|
||||
struct StaleAdminCount(Arc<InMemoryStore>);
|
||||
|
||||
#[async_trait]
|
||||
impl UserRepositoryPort for StaleAdminCount {
|
||||
async fn find_by_email(&self, e: &Email) -> Result<Option<User>, DomainError> {
|
||||
self.0.find_by_email(e).await
|
||||
}
|
||||
async fn find_by_username(&self, u: &Username) -> Result<Option<User>, DomainError> {
|
||||
self.0.find_by_username(u).await
|
||||
}
|
||||
async fn save(&self, u: &User) -> Result<(), DomainError> {
|
||||
UserRepositoryPort::save(&*self.0, u).await
|
||||
}
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
self.0.find_by_id(id).await
|
||||
}
|
||||
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
|
||||
self.0.list_with_stats().await
|
||||
}
|
||||
async fn update_profile(
|
||||
&self,
|
||||
id: &UserId,
|
||||
p: &domain::models::UserProfile,
|
||||
ev: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
self.0.update_profile(id, p, ev).await
|
||||
}
|
||||
async fn mark_deleted(&self, id: &UserId, ev: &[DomainEvent]) -> Result<(), DomainError> {
|
||||
self.0.mark_deleted(id, ev).await
|
||||
}
|
||||
async fn find_tombstoned_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
self.0.find_tombstoned_by_id(id).await
|
||||
}
|
||||
async fn is_username_retired(&self, u: &Username) -> Result<bool, DomainError> {
|
||||
self.0.is_username_retired(u).await
|
||||
}
|
||||
async fn count_active_admins(&self) -> Result<u64, DomainError> {
|
||||
Ok(2)
|
||||
}
|
||||
}
|
||||
|
||||
const PASSWORD: &str = "password123";
|
||||
|
||||
struct Fixture {
|
||||
store: Arc<InMemoryStore>,
|
||||
deps: RequestAccountDeletionDeps,
|
||||
register: RegisterDeps,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let store = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&store) as _);
|
||||
Fixture {
|
||||
deps: RequestAccountDeletionDeps {
|
||||
user: b.user_repo.clone(),
|
||||
password_hasher: b.password_hasher.clone(),
|
||||
},
|
||||
register: RegisterDeps {
|
||||
user: b.user_repo.clone(),
|
||||
password_hasher: b.password_hasher.clone(),
|
||||
config: b.config.clone(),
|
||||
},
|
||||
store,
|
||||
}
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
async fn user(&self, name: &str, role: UserRole) -> UserId {
|
||||
let email = format!("{name}@example.com");
|
||||
let mut cmd = register_command(email.clone(), name);
|
||||
cmd.role = role;
|
||||
register::execute(&self.register, cmd).await.unwrap();
|
||||
self.store
|
||||
.find_by_email(&Email::new(email).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.id()
|
||||
.clone()
|
||||
}
|
||||
|
||||
async fn is_live(&self, id: &UserId) -> bool {
|
||||
self.store.find_by_id(id).await.unwrap().is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_user_deletes_their_own_account_with_the_right_password() {
|
||||
let f = fixture();
|
||||
let alice = f.user("alice", UserRole::Standard).await;
|
||||
|
||||
request_account_deletion::execute(&f.deps, alice.clone(), alice.clone(), PASSWORD)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!f.is_live(&alice).await);
|
||||
assert!(
|
||||
matches!(
|
||||
f.store.recorded_events().as_slice(),
|
||||
[DomainEvent::UserDeleted { user_id }] if *user_id == alice
|
||||
),
|
||||
"events: {:?}",
|
||||
f.store.recorded_events()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_wrong_password_is_unauthorized_and_changes_nothing() {
|
||||
let f = fixture();
|
||||
let alice = f.user("alice", UserRole::Standard).await;
|
||||
|
||||
let err = request_account_deletion::execute(&f.deps, alice.clone(), alice.clone(), "nope")
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(
|
||||
matches!(err, DomainError::InvalidCredentials(_)),
|
||||
"got {err:?}"
|
||||
);
|
||||
assert!(f.is_live(&alice).await);
|
||||
assert!(f.store.recorded_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_non_admin_cannot_delete_someone_else() {
|
||||
let f = fixture();
|
||||
let alice = f.user("alice", UserRole::Standard).await;
|
||||
let bob = f.user("bob", UserRole::Standard).await;
|
||||
|
||||
let err = request_account_deletion::execute(&f.deps, alice, bob.clone(), PASSWORD)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, DomainError::Forbidden(_)), "got {err:?}");
|
||||
assert!(f.is_live(&bob).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_admin_deletes_another_user_with_their_own_password() {
|
||||
let f = fixture();
|
||||
let admin = f.user("root", UserRole::Admin).await;
|
||||
let bob = f.user("bob", UserRole::Standard).await;
|
||||
|
||||
let err = request_account_deletion::execute(&f.deps, admin.clone(), bob.clone(), "bobs-pw")
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, DomainError::InvalidCredentials(_)),
|
||||
"got {err:?}"
|
||||
);
|
||||
assert!(f.is_live(&bob).await);
|
||||
|
||||
request_account_deletion::execute(&f.deps, admin.clone(), bob.clone(), PASSWORD)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!f.is_live(&bob).await);
|
||||
assert!(f.is_live(&admin).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_last_active_admin_cannot_delete_themselves() {
|
||||
let f = fixture();
|
||||
let admin = f.user("root", UserRole::Admin).await;
|
||||
|
||||
let err = request_account_deletion::execute(&f.deps, admin.clone(), admin.clone(), PASSWORD)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, DomainError::Conflict(_)), "got {err:?}");
|
||||
assert!(f.is_live(&admin).await);
|
||||
assert!(f.store.recorded_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_admin_can_delete_themselves_when_another_admin_remains() {
|
||||
let f = fixture();
|
||||
let first = f.user("root", UserRole::Admin).await;
|
||||
let _second = f.user("root2", UserRole::Admin).await;
|
||||
|
||||
request_account_deletion::execute(&f.deps, first.clone(), first.clone(), PASSWORD)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!f.is_live(&first).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unknown_target_is_not_found() {
|
||||
let f = fixture();
|
||||
let admin = f.user("root", UserRole::Admin).await;
|
||||
|
||||
let err = request_account_deletion::execute(&f.deps, admin, UserId::generate(), PASSWORD)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, DomainError::NotFound(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_already_deleted_target_is_not_found() {
|
||||
let f = fixture();
|
||||
let admin = f.user("root", UserRole::Admin).await;
|
||||
let bob = f.user("bob", UserRole::Standard).await;
|
||||
request_account_deletion::execute(&f.deps, admin.clone(), bob.clone(), PASSWORD)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let err = request_account_deletion::execute(&f.deps, admin, bob, PASSWORD)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, DomainError::NotFound(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_conflict_from_the_repository_is_propagated() {
|
||||
let f = fixture();
|
||||
let admin = f.user("root", UserRole::Admin).await;
|
||||
let deps = RequestAccountDeletionDeps {
|
||||
user: Arc::new(StaleAdminCount(Arc::clone(&f.store))),
|
||||
password_hasher: f.deps.password_hasher.clone(),
|
||||
};
|
||||
|
||||
let err = request_account_deletion::execute(&deps, admin.clone(), admin.clone(), PASSWORD)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, DomainError::Conflict(_)), "got {err:?}");
|
||||
assert!(f.is_live(&admin).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unknown_or_deleted_actor_is_unauthorized() {
|
||||
let f = fixture();
|
||||
let admin = f.user("root", UserRole::Admin).await;
|
||||
let bob = f.user("bob", UserRole::Standard).await;
|
||||
|
||||
let err = request_account_deletion::execute(&f.deps, UserId::generate(), bob.clone(), PASSWORD)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::Unauthorized(_)), "got {err:?}");
|
||||
|
||||
request_account_deletion::execute(&f.deps, admin.clone(), bob.clone(), PASSWORD)
|
||||
.await
|
||||
.unwrap();
|
||||
let err = request_account_deletion::execute(&f.deps, bob.clone(), bob, PASSWORD)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, DomainError::Unauthorized(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_non_admin_gets_forbidden_before_any_password_check() {
|
||||
let f = fixture();
|
||||
let alice = f.user("alice", UserRole::Standard).await;
|
||||
let bob = f.user("bob", UserRole::Standard).await;
|
||||
|
||||
let err = request_account_deletion::execute(&f.deps, alice, bob, "wrong")
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, DomainError::Forbidden(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_service_actor_cannot_be_deleted_even_by_an_admin() {
|
||||
let f = fixture();
|
||||
let admin = f.user("root", UserRole::Admin).await;
|
||||
let other = f.user("second", UserRole::Admin).await;
|
||||
let _ = other;
|
||||
|
||||
let err = request_account_deletion::execute(&f.deps, admin, UserId::service_actor(), PASSWORD)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(matches!(err, DomainError::Forbidden(_)), "got {err:?}");
|
||||
assert!(f.store.recorded_events().is_empty());
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
use domain::value_objects::Username;
|
||||
|
||||
use crate::support::TestContextBuilder;
|
||||
use application::users::deps::ResolveUsernameDeps;
|
||||
use application::users::resolve_username_to_id;
|
||||
|
||||
#[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()
|
||||
);
|
||||
}
|
||||
89
crates/application/tests/users/update_profile_fields_test.rs
Normal file
89
crates/application/tests/users/update_profile_fields_test.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use crate::support::TestContextBuilder;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::events::DomainEvent;
|
||||
use domain::models::ProfileField;
|
||||
use domain::testing::{InMemoryStore, RefusingProfileFieldsRepository};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::users::{
|
||||
commands::UpdateProfileFieldsCommand, deps::UpdateProfileFieldsDeps, update_profile_fields,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn saving_profile_fields_records_the_change_and_its_event_together() {
|
||||
let fields_repo = InMemoryStore::new();
|
||||
let deps = UpdateProfileFieldsDeps {
|
||||
profile_fields: Arc::clone(&fields_repo) as _,
|
||||
};
|
||||
|
||||
update_profile_fields::execute(
|
||||
&deps,
|
||||
UpdateProfileFieldsCommand {
|
||||
user_id: Uuid::nil(),
|
||||
fields: vec![
|
||||
ProfileField {
|
||||
name: "Website".into(),
|
||||
value: "https://example.com".into(),
|
||||
},
|
||||
ProfileField {
|
||||
name: "Location".into(),
|
||||
value: "Berlin".into(),
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let recorded = fields_repo.recorded_events();
|
||||
assert_eq!(recorded.len(), 1);
|
||||
assert!(matches!(recorded[0], DomainEvent::UserUpdated { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_more_than_four_fields() {
|
||||
let b = TestContextBuilder::new();
|
||||
let deps = UpdateProfileFieldsDeps {
|
||||
profile_fields: b.profile_fields_repo.clone(),
|
||||
};
|
||||
|
||||
let fields: Vec<ProfileField> = (0..5)
|
||||
.map(|i| ProfileField {
|
||||
name: format!("field{i}"),
|
||||
value: format!("val{i}"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let result = update_profile_fields::execute(
|
||||
&deps,
|
||||
UpdateProfileFieldsCommand {
|
||||
user_id: Uuid::nil(),
|
||||
fields,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refused_profile_fields_propagate_and_record_nothing() {
|
||||
let deps = UpdateProfileFieldsDeps {
|
||||
profile_fields: Arc::new(RefusingProfileFieldsRepository),
|
||||
};
|
||||
|
||||
let result = update_profile_fields::execute(
|
||||
&deps,
|
||||
UpdateProfileFieldsCommand {
|
||||
user_id: Uuid::nil(),
|
||||
fields: vec![],
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(domain::errors::DomainError::InfrastructureError(m)) if m == "refused"
|
||||
));
|
||||
}
|
||||
365
crates/application/tests/users/update_profile_test.rs
Normal file
365
crates/application/tests/users/update_profile_test.rs
Normal file
@@ -0,0 +1,365 @@
|
||||
use crate::support::TestContextBuilder;
|
||||
use crate::support::{register_command, update_profile_command};
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ports::UserRepository;
|
||||
use domain::testing::{InMemoryStore, NoopEventPublisher};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
auth::{deps::RegisterDeps, register},
|
||||
users::{commands::UpdateProfileCommand, deps::UpdateProfileDeps, update_profile},
|
||||
};
|
||||
|
||||
async fn register_user(b: &TestContextBuilder, users: &Arc<InMemoryStore>) -> Uuid {
|
||||
let reg_deps = RegisterDeps {
|
||||
user: b.user_repo.clone(),
|
||||
password_hasher: b.password_hasher.clone(),
|
||||
config: b.config.clone(),
|
||||
};
|
||||
register::execute(®_deps, register_command("alice@example.com", "alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let user = users
|
||||
.find_by_email(&domain::value_objects::Email::new("alice@example.com".into()).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
user.id().value()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn updating_the_profile_records_the_change_and_its_event_together() {
|
||||
let users = InMemoryStore::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let b = TestContextBuilder::new()
|
||||
.with_users(Arc::clone(&users) as _)
|
||||
.with_event_publisher(Arc::clone(&events) as _);
|
||||
let deps = UpdateProfileDeps {
|
||||
user: b.user_repo.clone(),
|
||||
object_storage: b.object_storage.clone(),
|
||||
event_publisher: b.event_publisher.clone(),
|
||||
};
|
||||
let uid = register_user(&b, &users).await;
|
||||
|
||||
update_profile::execute(
|
||||
&deps,
|
||||
UpdateProfileCommand {
|
||||
display_name: Some("Alice W.".into()),
|
||||
..update_profile_command(uid)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let recorded = users.recorded_events();
|
||||
assert_eq!(recorded.len(), 1);
|
||||
assert!(matches!(recorded[0], DomainEvent::UserUpdated { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_avatar_content_type() {
|
||||
let users = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
let deps = UpdateProfileDeps {
|
||||
user: b.user_repo.clone(),
|
||||
object_storage: b.object_storage.clone(),
|
||||
event_publisher: b.event_publisher.clone(),
|
||||
};
|
||||
let uid = register_user(&b, &users).await;
|
||||
|
||||
let result = update_profile::execute(
|
||||
&deps,
|
||||
UpdateProfileCommand {
|
||||
avatar_bytes: Some(vec![0u8; 10]),
|
||||
avatar_content_type: Some("image/gif".into()),
|
||||
..update_profile_command(uid)
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uploads_avatar() {
|
||||
let users = InMemoryStore::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let b = TestContextBuilder::new()
|
||||
.with_users(Arc::clone(&users) as _)
|
||||
.with_event_publisher(Arc::clone(&events) as _);
|
||||
let deps = UpdateProfileDeps {
|
||||
user: b.user_repo.clone(),
|
||||
object_storage: b.object_storage.clone(),
|
||||
event_publisher: b.event_publisher.clone(),
|
||||
};
|
||||
let uid = register_user(&b, &users).await;
|
||||
|
||||
update_profile::execute(
|
||||
&deps,
|
||||
UpdateProfileCommand {
|
||||
avatar_bytes: Some(vec![0xFFu8, 0xD8, 0xFF]),
|
||||
avatar_content_type: Some("image/jpeg".into()),
|
||||
..update_profile_command(uid)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
users
|
||||
.recorded_events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
|
||||
);
|
||||
assert!(
|
||||
events
|
||||
.published()
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::ImageStored { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uploads_banner() {
|
||||
let users = InMemoryStore::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let b = TestContextBuilder::new()
|
||||
.with_users(Arc::clone(&users) as _)
|
||||
.with_event_publisher(Arc::clone(&events) as _);
|
||||
let deps = UpdateProfileDeps {
|
||||
user: b.user_repo.clone(),
|
||||
object_storage: b.object_storage.clone(),
|
||||
event_publisher: b.event_publisher.clone(),
|
||||
};
|
||||
let uid = register_user(&b, &users).await;
|
||||
|
||||
update_profile::execute(
|
||||
&deps,
|
||||
UpdateProfileCommand {
|
||||
banner_bytes: Some(vec![0x89, 0x50, 0x4E]),
|
||||
banner_content_type: Some("image/png".into()),
|
||||
..update_profile_command(uid)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
users
|
||||
.recorded_events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
|
||||
);
|
||||
assert!(
|
||||
events
|
||||
.published()
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::ImageStored { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_for_nonexistent_user() {
|
||||
let b = TestContextBuilder::new();
|
||||
let deps = UpdateProfileDeps {
|
||||
user: b.user_repo.clone(),
|
||||
object_storage: b.object_storage.clone(),
|
||||
event_publisher: b.event_publisher.clone(),
|
||||
};
|
||||
|
||||
let result = update_profile::execute(
|
||||
&deps,
|
||||
UpdateProfileCommand {
|
||||
display_name: Some("Ghost".into()),
|
||||
..update_profile_command(Uuid::new_v4())
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_banner_content_type() {
|
||||
let users = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
let deps = UpdateProfileDeps {
|
||||
user: b.user_repo.clone(),
|
||||
object_storage: b.object_storage.clone(),
|
||||
event_publisher: b.event_publisher.clone(),
|
||||
};
|
||||
let uid = register_user(&b, &users).await;
|
||||
|
||||
let result = update_profile::execute(
|
||||
&deps,
|
||||
UpdateProfileCommand {
|
||||
banner_bytes: Some(vec![0u8; 10]),
|
||||
banner_content_type: Some("text/plain".into()),
|
||||
..update_profile_command(uid)
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn text_only_update_emits_user_updated_no_image_stored() {
|
||||
let users = InMemoryStore::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let b = TestContextBuilder::new()
|
||||
.with_users(Arc::clone(&users) as _)
|
||||
.with_event_publisher(Arc::clone(&events) as _);
|
||||
let deps = UpdateProfileDeps {
|
||||
user: b.user_repo.clone(),
|
||||
object_storage: b.object_storage.clone(),
|
||||
event_publisher: b.event_publisher.clone(),
|
||||
};
|
||||
let uid = register_user(&b, &users).await;
|
||||
|
||||
update_profile::execute(
|
||||
&deps,
|
||||
UpdateProfileCommand {
|
||||
display_name: Some("Alice Updated".into()),
|
||||
bio: Some("Hello world".into()),
|
||||
..update_profile_command(uid)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
users
|
||||
.recorded_events()
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
|
||||
);
|
||||
assert!(
|
||||
!events
|
||||
.published()
|
||||
.iter()
|
||||
.any(|e| matches!(e, DomainEvent::ImageStored { .. })),
|
||||
"text-only update should not emit ImageStored"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changing_also_known_as_records_the_move_with_the_update() {
|
||||
let users = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
let deps = UpdateProfileDeps {
|
||||
user: b.user_repo.clone(),
|
||||
object_storage: b.object_storage.clone(),
|
||||
event_publisher: b.event_publisher.clone(),
|
||||
};
|
||||
let uid = register_user(&b, &users).await;
|
||||
|
||||
update_profile::execute(
|
||||
&deps,
|
||||
UpdateProfileCommand {
|
||||
also_known_as: Some("https://other.example/users/alice".into()),
|
||||
..update_profile_command(uid)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let recorded = users.recorded_events();
|
||||
assert_eq!(recorded.len(), 2);
|
||||
assert!(matches!(recorded[0], DomainEvent::UserUpdated { .. }));
|
||||
assert!(matches!(recorded[1], DomainEvent::UserAccountMoved { .. }));
|
||||
}
|
||||
|
||||
struct ProfileWriteRefusingUsers(Arc<InMemoryStore>);
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl UserRepository for ProfileWriteRefusingUsers {
|
||||
async fn find_by_email(
|
||||
&self,
|
||||
e: &domain::value_objects::Email,
|
||||
) -> Result<Option<domain::models::User>, domain::errors::DomainError> {
|
||||
self.0.find_by_email(e).await
|
||||
}
|
||||
async fn find_by_username(
|
||||
&self,
|
||||
u: &domain::value_objects::Username,
|
||||
) -> Result<Option<domain::models::User>, domain::errors::DomainError> {
|
||||
self.0.find_by_username(u).await
|
||||
}
|
||||
async fn save(&self, u: &domain::models::User) -> Result<(), domain::errors::DomainError> {
|
||||
self.0.save(u).await
|
||||
}
|
||||
async fn find_by_id(
|
||||
&self,
|
||||
id: &domain::value_objects::UserId,
|
||||
) -> Result<Option<domain::models::User>, domain::errors::DomainError> {
|
||||
self.0.find_by_id(id).await
|
||||
}
|
||||
async fn list_with_stats(
|
||||
&self,
|
||||
) -> Result<Vec<domain::models::UserSummary>, domain::errors::DomainError> {
|
||||
self.0.list_with_stats().await
|
||||
}
|
||||
async fn update_profile(
|
||||
&self,
|
||||
_: &domain::value_objects::UserId,
|
||||
_: &domain::models::UserProfile,
|
||||
_: &[DomainEvent],
|
||||
) -> Result<(), domain::errors::DomainError> {
|
||||
Err(domain::errors::DomainError::InfrastructureError(
|
||||
"refused".into(),
|
||||
))
|
||||
}
|
||||
async fn mark_deleted(
|
||||
&self,
|
||||
id: &domain::value_objects::UserId,
|
||||
events: &[DomainEvent],
|
||||
) -> Result<(), domain::errors::DomainError> {
|
||||
self.0.mark_deleted(id, events).await
|
||||
}
|
||||
async fn find_tombstoned_by_id(
|
||||
&self,
|
||||
id: &domain::value_objects::UserId,
|
||||
) -> Result<Option<domain::models::User>, domain::errors::DomainError> {
|
||||
self.0.find_tombstoned_by_id(id).await
|
||||
}
|
||||
async fn is_username_retired(
|
||||
&self,
|
||||
u: &domain::value_objects::Username,
|
||||
) -> Result<bool, domain::errors::DomainError> {
|
||||
self.0.is_username_retired(u).await
|
||||
}
|
||||
async fn count_active_admins(&self) -> Result<u64, domain::errors::DomainError> {
|
||||
self.0.count_active_admins().await
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_profile_update_propagates_and_records_nothing() {
|
||||
let users = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
let uid = register_user(&b, &users).await;
|
||||
let deps = UpdateProfileDeps {
|
||||
user: Arc::new(ProfileWriteRefusingUsers(Arc::clone(&users))),
|
||||
object_storage: b.object_storage.clone(),
|
||||
event_publisher: b.event_publisher.clone(),
|
||||
};
|
||||
|
||||
let result = update_profile::execute(
|
||||
&deps,
|
||||
UpdateProfileCommand {
|
||||
display_name: Some("Alice W.".into()),
|
||||
..update_profile_command(uid)
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(domain::errors::DomainError::InfrastructureError(m)) if m == "refused"
|
||||
));
|
||||
assert!(users.recorded_events().is_empty());
|
||||
}
|
||||
93
crates/application/tests/users/update_settings_test.rs
Normal file
93
crates/application/tests/users/update_settings_test.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use crate::support::TestContextBuilder;
|
||||
use crate::support::update_user_settings_command;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::InMemoryStore;
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::users::{
|
||||
deps::{GetSettingsDeps, UpdateSettingsDeps},
|
||||
get_settings,
|
||||
update_settings::UpdateUserSettingsCommand,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn updates_federate_goals() {
|
||||
let settings_repo = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_user_settings(Arc::clone(&settings_repo) as _);
|
||||
let user_settings = b.user_settings_repo.clone();
|
||||
let uid = Uuid::nil();
|
||||
|
||||
application::users::update_settings::execute(
|
||||
&UpdateSettingsDeps {
|
||||
user_settings: user_settings.clone(),
|
||||
},
|
||||
UpdateUserSettingsCommand {
|
||||
federate_goals: false,
|
||||
..update_user_settings_command(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());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn updates_federate_reviews() {
|
||||
let settings_repo = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_user_settings(Arc::clone(&settings_repo) as _);
|
||||
let user_settings = b.user_settings_repo.clone();
|
||||
let uid = Uuid::nil();
|
||||
|
||||
application::users::update_settings::execute(
|
||||
&UpdateSettingsDeps {
|
||||
user_settings: user_settings.clone(),
|
||||
},
|
||||
UpdateUserSettingsCommand {
|
||||
federate_reviews: false,
|
||||
..update_user_settings_command(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());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn updates_federate_watchlist() {
|
||||
let settings_repo = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_user_settings(Arc::clone(&settings_repo) as _);
|
||||
let user_settings = b.user_settings_repo.clone();
|
||||
let uid = Uuid::nil();
|
||||
|
||||
application::users::update_settings::execute(
|
||||
&UpdateSettingsDeps {
|
||||
user_settings: user_settings.clone(),
|
||||
},
|
||||
UpdateUserSettingsCommand {
|
||||
federate_watchlist: false,
|
||||
..update_user_settings_command(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());
|
||||
}
|
||||
Reference in New Issue
Block a user