add 400+ unit tests for domain and application layers
Some checks failed
CI / Check / Test (push) Has been cancelled

Extract ReviewLogger trait to decouple import/integrations
from diary::log_review (cross-module coupling smell).

Add in-memory fakes for all repository ports, enabling
isolated testing of every use case module without a database.

Coverage: domain+application 22% → 80%, 427 tests.
This commit is contained in:
2026-06-09 02:07:26 +02:00
parent 30a6200b5b
commit d867a14b28
122 changed files with 7033 additions and 151 deletions

View File

@@ -0,0 +1,115 @@
use std::sync::Arc;
use domain::models::{ProfileField, User, UserProfile, UserRole};
use domain::ports::UserRepository;
use domain::testing::InMemoryUserRepository;
use domain::value_objects::{Email, PasswordHash, UserId, Username};
use uuid::Uuid;
use crate::{
auth::{commands::RegisterCommand, register},
test_helpers::TestContextBuilder,
users::{get_current_profile, queries::GetCurrentProfileQuery},
};
#[tokio::test]
async fn returns_profile_for_existing_user() {
let users = InMemoryUserRepository::new();
let ctx = TestContextBuilder::new()
.with_users(Arc::clone(&users) as _)
.build();
register::execute(
&ctx,
RegisterCommand {
email: "alice@example.com".into(),
username: "alice".into(),
password: "password123".into(),
role: UserRole::Standard,
},
)
.await
.unwrap();
let user = users
.find_by_email(&domain::value_objects::Email::new("alice@example.com".into()).unwrap())
.await
.unwrap()
.unwrap();
let profile = get_current_profile::execute(
&ctx,
GetCurrentProfileQuery {
user_id: user.id().value(),
},
)
.await
.unwrap();
assert_eq!(profile.username, "alice");
}
#[tokio::test]
async fn fails_for_nonexistent_user() {
let ctx = TestContextBuilder::new().build();
let result = get_current_profile::execute(
&ctx,
GetCurrentProfileQuery {
user_id: Uuid::new_v4(),
},
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn returns_profile_with_avatar_banner_and_fields() {
let users = InMemoryUserRepository::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.store.lock().unwrap().insert(uid.value(), user);
let ctx = TestContextBuilder::new()
.with_users(Arc::clone(&users) as _)
.build();
let profile = get_current_profile::execute(
&ctx,
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!(profile.avatar_url.is_some());
assert!(profile.avatar_url.unwrap().contains("avatars/abc123"));
assert!(profile.banner_url.is_some());
assert!(profile.banner_url.unwrap().contains("banners/def456"));
assert_eq!(profile.fields.len(), 1);
assert_eq!(profile.fields[0].name, "Website");
assert_eq!(profile.fields[0].value, "https://example.com");
}

View File

@@ -0,0 +1,240 @@
use domain::models::UserRole;
use domain::value_objects::Email;
use crate::auth::commands::RegisterCommand;
use crate::auth::register;
use crate::test_helpers::TestContextBuilder;
use crate::users::get_profile;
use crate::users::queries::{GetUserProfileQuery, ProfileView};
#[tokio::test]
async fn returns_profile_with_empty_stats() {
let ctx = TestContextBuilder::new().build();
register::execute(
&ctx,
RegisterCommand {
email: "profile@test.com".into(),
username: "profuser".into(),
password: "password123".into(),
role: UserRole::Standard,
},
)
.await
.unwrap();
let email = Email::new("profile@test.com".into()).unwrap();
let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap();
let uid = user.id().value();
let result = get_profile::execute(
&ctx,
GetUserProfileQuery {
user_id: uid,
view: ProfileView::Recent,
limit: None,
offset: None,
sort_by: domain::ports::FeedSortBy::Date,
search: None,
is_own_profile: true,
},
)
.await
.unwrap();
assert!(result.entries.is_some());
}
#[tokio::test]
async fn returns_history_view() {
let ctx = TestContextBuilder::new().build();
register::execute(
&ctx,
RegisterCommand {
email: "hist@test.com".into(),
username: "histuser".into(),
password: "password123".into(),
role: UserRole::Standard,
},
)
.await
.unwrap();
let email = Email::new("hist@test.com".into()).unwrap();
let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap();
let uid = user.id().value();
let result = get_profile::execute(
&ctx,
GetUserProfileQuery {
user_id: uid,
view: ProfileView::History,
limit: None,
offset: None,
sort_by: domain::ports::FeedSortBy::Date,
search: None,
is_own_profile: true,
},
)
.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 ctx = TestContextBuilder::new().build();
register::execute(
&ctx,
RegisterCommand {
email: "trends@test.com".into(),
username: "trendsuser".into(),
password: "password123".into(),
role: UserRole::Standard,
},
)
.await
.unwrap();
let email = Email::new("trends@test.com".into()).unwrap();
let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap();
let uid = user.id().value();
let result = get_profile::execute(
&ctx,
GetUserProfileQuery {
user_id: uid,
view: ProfileView::Trends,
limit: None,
offset: None,
sort_by: domain::ports::FeedSortBy::Date,
search: None,
is_own_profile: true,
},
)
.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 ctx = TestContextBuilder::new().build();
register::execute(
&ctx,
RegisterCommand {
email: "ratings@test.com".into(),
username: "ratingsuser".into(),
password: "password123".into(),
role: UserRole::Standard,
},
)
.await
.unwrap();
let email = Email::new("ratings@test.com".into()).unwrap();
let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap();
let uid = user.id().value();
let result = get_profile::execute(
&ctx,
GetUserProfileQuery {
user_id: uid,
view: ProfileView::Ratings,
limit: None,
offset: None,
sort_by: domain::ports::FeedSortBy::Rating,
search: None,
is_own_profile: true,
},
)
.await
.unwrap();
assert!(result.entries.is_some());
}
#[tokio::test]
async fn returns_recent_with_search() {
let ctx = TestContextBuilder::new().build();
register::execute(
&ctx,
RegisterCommand {
email: "search@test.com".into(),
username: "searchuser".into(),
password: "password123".into(),
role: UserRole::Standard,
},
)
.await
.unwrap();
let email = Email::new("search@test.com".into()).unwrap();
let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap();
let uid = user.id().value();
let result = get_profile::execute(
&ctx,
GetUserProfileQuery {
user_id: uid,
view: ProfileView::Recent,
limit: Some(10),
offset: Some(0),
sort_by: domain::ports::FeedSortBy::Date,
search: Some("blade".into()),
is_own_profile: true,
},
)
.await
.unwrap();
assert!(result.entries.is_some());
}
#[tokio::test]
async fn non_own_profile_skips_pending_followers() {
let ctx = TestContextBuilder::new().build();
register::execute(
&ctx,
RegisterCommand {
email: "other@test.com".into(),
username: "otheruser".into(),
password: "password123".into(),
role: UserRole::Standard,
},
)
.await
.unwrap();
let email = Email::new("other@test.com".into()).unwrap();
let user = ctx.repos.user.find_by_email(&email).await.unwrap().unwrap();
let uid = user.id().value();
let result = get_profile::execute(
&ctx,
GetUserProfileQuery {
user_id: uid,
view: ProfileView::Recent,
limit: None,
offset: None,
sort_by: domain::ports::FeedSortBy::Date,
search: None,
is_own_profile: false,
},
)
.await
.unwrap();
assert!(result.pending_followers.is_empty());
}

View File

@@ -0,0 +1,12 @@
use uuid::Uuid;
use crate::{test_helpers::TestContextBuilder, users::get_settings};
#[tokio::test]
async fn returns_default_settings() {
let ctx = TestContextBuilder::new().build();
let settings = get_settings::execute(&ctx, Uuid::nil()).await.unwrap();
assert!(!settings.federate_goals());
}

View File

@@ -0,0 +1,13 @@
use crate::test_helpers::TestContextBuilder;
use crate::users::get_users;
use crate::users::queries::GetUsersQuery;
#[tokio::test]
async fn returns_empty_when_no_users() {
let ctx = TestContextBuilder::new().build();
let result = get_users::execute(&ctx, GetUsersQuery).await.unwrap();
assert!(result.users.is_empty());
assert!(result.remote_actors.is_empty());
}

View File

@@ -0,0 +1,269 @@
use std::sync::Arc;
use domain::events::DomainEvent;
use domain::models::UserRole;
use domain::ports::UserRepository;
use domain::testing::{InMemoryUserRepository, NoopEventPublisher};
use uuid::Uuid;
use crate::{
auth::{commands::RegisterCommand, register},
test_helpers::TestContextBuilder,
users::{commands::UpdateProfileCommand, update_profile},
};
async fn register_user(
ctx: &crate::context::AppContext,
users: &Arc<InMemoryUserRepository>,
) -> Uuid {
register::execute(
ctx,
RegisterCommand {
email: "alice@example.com".into(),
username: "alice".into(),
password: "password123".into(),
role: UserRole::Standard,
},
)
.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 updates_display_name() {
let users = InMemoryUserRepository::new();
let events = NoopEventPublisher::new();
let ctx = TestContextBuilder::new()
.with_users(Arc::clone(&users) as _)
.with_event_publisher(Arc::clone(&events) as _)
.build();
let uid = register_user(&ctx, &users).await;
update_profile::execute(
&ctx,
UpdateProfileCommand {
user_id: uid,
display_name: Some("Alice W.".into()),
bio: None,
avatar_bytes: None,
avatar_content_type: None,
banner_bytes: None,
banner_content_type: None,
also_known_as: None,
},
)
.await
.unwrap();
let published = events.published();
assert!(
published
.iter()
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
);
}
#[tokio::test]
async fn rejects_invalid_avatar_content_type() {
let users = InMemoryUserRepository::new();
let ctx = TestContextBuilder::new()
.with_users(Arc::clone(&users) as _)
.build();
let uid = register_user(&ctx, &users).await;
let result = update_profile::execute(
&ctx,
UpdateProfileCommand {
user_id: uid,
display_name: None,
bio: None,
avatar_bytes: Some(vec![0u8; 10]),
avatar_content_type: Some("image/gif".into()),
banner_bytes: None,
banner_content_type: None,
also_known_as: None,
},
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn uploads_avatar() {
let users = InMemoryUserRepository::new();
let events = NoopEventPublisher::new();
let ctx = TestContextBuilder::new()
.with_users(Arc::clone(&users) as _)
.with_event_publisher(Arc::clone(&events) as _)
.build();
let uid = register_user(&ctx, &users).await;
update_profile::execute(
&ctx,
UpdateProfileCommand {
user_id: uid,
display_name: None,
bio: None,
avatar_bytes: Some(vec![0xFFu8, 0xD8, 0xFF]),
avatar_content_type: Some("image/jpeg".into()),
banner_bytes: None,
banner_content_type: None,
also_known_as: None,
},
)
.await
.unwrap();
let published = events.published();
assert!(
published
.iter()
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
);
assert!(
published
.iter()
.any(|e| matches!(e, DomainEvent::ImageStored { .. }))
);
}
#[tokio::test]
async fn uploads_banner() {
let users = InMemoryUserRepository::new();
let events = NoopEventPublisher::new();
let ctx = TestContextBuilder::new()
.with_users(Arc::clone(&users) as _)
.with_event_publisher(Arc::clone(&events) as _)
.build();
let uid = register_user(&ctx, &users).await;
update_profile::execute(
&ctx,
UpdateProfileCommand {
user_id: uid,
display_name: None,
bio: None,
avatar_bytes: None,
avatar_content_type: None,
banner_bytes: Some(vec![0x89, 0x50, 0x4E]),
banner_content_type: Some("image/png".into()),
also_known_as: None,
},
)
.await
.unwrap();
let published = events.published();
assert!(
published
.iter()
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
);
assert!(
published
.iter()
.any(|e| matches!(e, DomainEvent::ImageStored { .. }))
);
}
#[tokio::test]
async fn fails_for_nonexistent_user() {
let ctx = TestContextBuilder::new().build();
let result = update_profile::execute(
&ctx,
UpdateProfileCommand {
user_id: Uuid::new_v4(),
display_name: Some("Ghost".into()),
bio: None,
avatar_bytes: None,
avatar_content_type: None,
banner_bytes: None,
banner_content_type: None,
also_known_as: None,
},
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn rejects_invalid_banner_content_type() {
let users = InMemoryUserRepository::new();
let ctx = TestContextBuilder::new()
.with_users(Arc::clone(&users) as _)
.build();
let uid = register_user(&ctx, &users).await;
let result = update_profile::execute(
&ctx,
UpdateProfileCommand {
user_id: uid,
display_name: None,
bio: None,
avatar_bytes: None,
avatar_content_type: None,
banner_bytes: Some(vec![0u8; 10]),
banner_content_type: Some("text/plain".into()),
also_known_as: None,
},
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn text_only_update_emits_user_updated_no_image_stored() {
let users = InMemoryUserRepository::new();
let events = NoopEventPublisher::new();
let ctx = TestContextBuilder::new()
.with_users(Arc::clone(&users) as _)
.with_event_publisher(Arc::clone(&events) as _)
.build();
let uid = register_user(&ctx, &users).await;
update_profile::execute(
&ctx,
UpdateProfileCommand {
user_id: uid,
display_name: Some("Alice Updated".into()),
bio: Some("Hello world".into()),
avatar_bytes: None,
avatar_content_type: None,
banner_bytes: None,
banner_content_type: None,
also_known_as: None,
},
)
.await
.unwrap();
let published = events.published();
assert!(
published
.iter()
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
);
assert!(
!published
.iter()
.any(|e| matches!(e, DomainEvent::ImageStored { .. })),
"text-only update should not emit ImageStored"
);
}

View File

@@ -0,0 +1,70 @@
use std::sync::Arc;
use domain::events::DomainEvent;
use domain::models::ProfileField;
use domain::testing::{InMemoryProfileFieldsRepo, NoopEventPublisher};
use uuid::Uuid;
use crate::{
test_helpers::TestContextBuilder,
users::{commands::UpdateProfileFieldsCommand, update_profile_fields},
};
#[tokio::test]
async fn saves_profile_fields() {
let fields_repo = InMemoryProfileFieldsRepo::new();
let events = NoopEventPublisher::new();
let ctx = TestContextBuilder::new()
.with_profile_fields(Arc::clone(&fields_repo) as _)
.with_event_publisher(Arc::clone(&events) as _)
.build();
update_profile_fields::execute(
&ctx,
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 published = events.published();
assert!(
published
.iter()
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
);
}
#[tokio::test]
async fn rejects_more_than_four_fields() {
let ctx = TestContextBuilder::new().build();
let fields: Vec<ProfileField> = (0..5)
.map(|i| ProfileField {
name: format!("field{i}"),
value: format!("val{i}"),
})
.collect();
let result = update_profile_fields::execute(
&ctx,
UpdateProfileFieldsCommand {
user_id: Uuid::nil(),
fields,
},
)
.await;
assert!(result.is_err());
}

View File

@@ -0,0 +1,32 @@
use std::sync::Arc;
use domain::testing::InMemoryUserSettingsRepository;
use uuid::Uuid;
use crate::{
test_helpers::TestContextBuilder,
users::{get_settings, update_settings::UpdateUserSettingsCommand},
};
#[tokio::test]
async fn updates_federate_goals() {
let settings_repo = InMemoryUserSettingsRepository::new();
let ctx = TestContextBuilder::new()
.with_user_settings(Arc::clone(&settings_repo) as _)
.build();
let uid = Uuid::nil();
crate::users::update_settings::execute(
&ctx,
UpdateUserSettingsCommand {
user_id: uid,
federate_goals: true,
},
)
.await
.unwrap();
let settings = get_settings::execute(&ctx, uid).await.unwrap();
assert!(settings.federate_goals());
}