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

@@ -57,3 +57,7 @@ pub async fn execute(
role: user.role().as_str().into(),
})
}
#[cfg(test)]
#[path = "tests/get_current_profile.rs"]
mod tests;

View File

@@ -183,3 +183,139 @@ fn format_year_month_long(ym: &str) -> String {
};
format!("{} {}", month, parts[0])
}
#[cfg(test)]
#[path = "tests/get_profile.rs"]
mod tests;
#[cfg(test)]
mod helper_tests {
use super::*;
#[test]
fn format_year_month_long_all_months() {
assert_eq!(format_year_month_long("2024-01"), "January 2024");
assert_eq!(format_year_month_long("2024-02"), "February 2024");
assert_eq!(format_year_month_long("2024-03"), "March 2024");
assert_eq!(format_year_month_long("2024-04"), "April 2024");
assert_eq!(format_year_month_long("2024-05"), "May 2024");
assert_eq!(format_year_month_long("2024-06"), "June 2024");
assert_eq!(format_year_month_long("2024-07"), "July 2024");
assert_eq!(format_year_month_long("2024-08"), "August 2024");
assert_eq!(format_year_month_long("2024-09"), "September 2024");
assert_eq!(format_year_month_long("2024-10"), "October 2024");
assert_eq!(format_year_month_long("2024-11"), "November 2024");
assert_eq!(format_year_month_long("2024-12"), "December 2024");
}
#[test]
fn format_year_month_long_invalid() {
assert_eq!(format_year_month_long("invalid"), "invalid");
assert_eq!(format_year_month_long("2024-99"), "99 2024");
}
#[test]
fn feed_sort_to_direction_all_variants() {
use domain::ports::FeedSortBy;
assert!(matches!(
feed_sort_to_direction(FeedSortBy::Date),
SortDirection::Descending
));
assert!(matches!(
feed_sort_to_direction(FeedSortBy::DateAsc),
SortDirection::Ascending
));
assert!(matches!(
feed_sort_to_direction(FeedSortBy::Rating),
SortDirection::ByRatingDesc
));
assert!(matches!(
feed_sort_to_direction(FeedSortBy::RatingAsc),
SortDirection::ByRatingAsc
));
}
#[test]
fn group_by_month_empty() {
assert!(group_by_month(vec![]).is_empty());
}
#[test]
fn group_by_month_groups_entries() {
use chrono::NaiveDateTime;
use domain::models::{Movie, Review};
use domain::value_objects::{MovieId, MovieTitle, Rating, ReleaseYear, UserId};
let movie = Movie::from_persistence(
MovieId::generate(),
None,
MovieTitle::new("Test".into()).unwrap(),
ReleaseYear::new(2024).unwrap(),
None,
None,
);
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
let jan =
NaiveDateTime::parse_from_str("2024-01-15 10:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
let jan2 =
NaiveDateTime::parse_from_str("2024-01-20 10:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
let mar =
NaiveDateTime::parse_from_str("2024-03-05 10:00:00", "%Y-%m-%d %H:%M:%S").unwrap();
let r1 = Review::new(
movie.id().clone(),
uid.clone(),
Rating::new(4).unwrap(),
None,
jan,
)
.unwrap();
let r2 = Review::new(
movie.id().clone(),
uid.clone(),
Rating::new(3).unwrap(),
None,
jan2,
)
.unwrap();
let r3 = Review::new(
movie.id().clone(),
uid.clone(),
Rating::new(5).unwrap(),
None,
mar,
)
.unwrap();
let entries = vec![
DiaryEntry::new(movie.clone(), r1),
DiaryEntry::new(movie.clone(), r2),
DiaryEntry::new(movie.clone(), r3),
];
let result = group_by_month(entries);
// Reversed: March first, then January
assert_eq!(result.len(), 2);
assert_eq!(result[0].month_label, "March 2024");
assert_eq!(result[0].count, 1);
assert_eq!(result[1].month_label, "January 2024");
assert_eq!(result[1].count, 2);
}
#[test]
fn paged_user_filter_builds_correctly() {
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
let filter = paged_user_filter(
uid.clone(),
SortDirection::Descending,
Some(20),
Some(5),
Some("blade".into()),
)
.unwrap();
assert_eq!(filter.user_id.unwrap().value(), uid.value());
assert_eq!(filter.search.as_deref(), Some("blade"));
}
}

View File

@@ -6,3 +6,7 @@ pub async fn execute(ctx: &AppContext, user_id: uuid::Uuid) -> Result<UserSettin
let uid = UserId::from_uuid(user_id);
ctx.repos.user_settings.get(&uid).await
}
#[cfg(test)]
#[path = "tests/get_settings.rs"]
mod tests;

View File

@@ -20,3 +20,7 @@ pub async fn execute(
remote_actors: actors_result?,
})
}
#[cfg(test)]
#[path = "tests/get_users.rs"]
mod tests;

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

View File

@@ -90,3 +90,7 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(),
Ok(())
}
#[cfg(test)]
#[path = "tests/update_profile.rs"]
mod tests;

View File

@@ -19,3 +19,7 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileFieldsCommand) -> Resul
.await?;
Ok(())
}
#[cfg(test)]
#[path = "tests/update_profile_fields.rs"]
mod tests;

View File

@@ -13,3 +13,7 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateUserSettingsCommand) -> Result
settings.set_federate_goals(cmd.federate_goals);
ctx.repos.user_settings.save(&settings).await
}
#[cfg(test)]
#[path = "tests/update_settings.rs"]
mod tests;