feat: atomic goal, watchlist, profile writes

This commit is contained in:
2026-10-26 00:49:28 +01:00
parent c8a1e8bfe4
commit 62ad9f4017
48 changed files with 1296 additions and 264 deletions

View File

@@ -94,19 +94,17 @@ impl ReviewLogger for DefaultReviewLogger {
events.push(review_event);
self.review_repo.save_review(&review, &events).await?;
let was_on_watchlist = self
.watchlist_repo
.remove_if_present(review.user_id(), review.movie_id())
.await?;
if was_on_watchlist {
let _ = self
.event_publisher
.publish(&DomainEvent::WatchlistEntryRemoved {
// The removal event is recorded only if an entry was actually removed.
self.watchlist_repo
.remove_if_present(
review.user_id(),
review.movie_id(),
&[DomainEvent::WatchlistEntryRemoved {
user_id: review.user_id().clone(),
movie_id: review.movie_id().clone(),
})
.await;
}
}],
)
.await?;
Ok(())
}

View File

@@ -29,19 +29,16 @@ pub async fn execute(
cmd.target_count,
GoalType::Movies,
)?;
deps.goal_command.save(&g).await?;
let event = DomainEvent::GoalCreated {
goal_id: g.id().clone(),
user_id: user_id.clone(),
year: cmd.year,
target_count: cmd.target_count,
};
deps.goal_command.save(&g, &[event]).await?;
let current_count = deps.stats.count_reviews_in_year(&user_id, cmd.year).await?;
deps.event_publisher
.publish(&DomainEvent::GoalCreated {
goal_id: g.id().clone(),
user_id,
year: cmd.year,
target_count: cmd.target_count,
})
.await?;
Ok(GoalWithProgress {
goal: g,
current_count,

View File

@@ -11,15 +11,12 @@ pub async fn execute(deps: &GoalCommandDeps, cmd: DeleteGoalCommand) -> Result<(
.await?
.ok_or_else(|| DomainError::NotFound(format!("Goal for year {}", cmd.year)))?;
deps.goal_command.delete(g.id(), &user_id).await?;
deps.event_publisher
.publish(&DomainEvent::GoalDeleted {
goal_id: g.id().clone(),
user_id,
year: cmd.year,
})
.await?;
let event = DomainEvent::GoalDeleted {
goal_id: g.id().clone(),
user_id: user_id.clone(),
year: cmd.year,
};
deps.goal_command.delete(g.id(), &user_id, &[event]).await?;
Ok(())
}

View File

@@ -1,12 +1,11 @@
use std::sync::Arc;
use domain::ports::{EventPublisher, GoalCommand, GoalQuery, StatsRepository};
use domain::ports::{GoalCommand, GoalQuery, StatsRepository};
pub struct GoalCommandDeps {
pub goal_command: Arc<dyn GoalCommand>,
pub goal_query: Arc<dyn GoalQuery>,
pub stats: Arc<dyn StatsRepository>,
pub event_publisher: Arc<dyn EventPublisher>,
}
pub struct GoalQueryDeps {

View File

@@ -17,19 +17,16 @@ pub async fn execute(
.ok_or_else(|| DomainError::NotFound(format!("Goal for year {}", cmd.year)))?;
g.update_target(cmd.target_count)?;
deps.goal_command.update(&g).await?;
let event = DomainEvent::GoalUpdated {
goal_id: g.id().clone(),
user_id: user_id.clone(),
year: cmd.year,
target_count: cmd.target_count,
};
deps.goal_command.update(&g, &[event]).await?;
let current_count = deps.stats.count_reviews_in_year(&user_id, cmd.year).await?;
deps.event_publisher
.publish(&DomainEvent::GoalUpdated {
goal_id: g.id().clone(),
user_id,
year: cmd.year,
target_count: cmd.target_count,
})
.await?;
Ok(GoalWithProgress {
goal: g,
current_count,

View File

@@ -58,7 +58,6 @@ pub struct GetCurrentProfileDeps {
pub struct UpdateProfileFieldsDeps {
pub profile_fields: Arc<dyn UserProfileFieldsRepository>,
pub event_publisher: Arc<dyn EventPublisher>,
}
pub struct GetSettingsDeps {

View File

@@ -91,6 +91,16 @@ pub async fn execute(
}
});
let mut events = vec![DomainEvent::UserUpdated {
user_id: user_id.clone(),
}];
if let Some(new_actor_url) = moved_to {
events.push(DomainEvent::UserAccountMoved {
user_id: user_id.clone(),
new_actor_url,
});
}
deps.user
.update_profile(
&user_id,
@@ -102,24 +112,9 @@ pub async fn execute(
also_known_as: cmd.also_known_as,
profile_fields: vec![],
},
&events,
)
.await?;
deps.event_publisher
.publish(&DomainEvent::UserUpdated {
user_id: user_id.clone(),
})
.await?;
if let Some(new_actor_url) = moved_to {
let _ = deps
.event_publisher
.publish(&DomainEvent::UserAccountMoved {
user_id,
new_actor_url,
})
.await;
}
Ok(())
}

View File

@@ -11,9 +11,10 @@ pub async fn execute(
) -> Result<(), DomainError> {
UserProfile::validate_custom_fields(&cmd.fields)?;
let user_id = UserId::from_uuid(cmd.user_id);
deps.profile_fields.set_fields(&user_id, cmd.fields).await?;
deps.event_publisher
.publish(&DomainEvent::UserUpdated { user_id })
.await?;
Ok(())
let event = DomainEvent::UserUpdated {
user_id: user_id.clone(),
};
deps.profile_fields
.set_fields(&user_id, cmd.fields, &[event])
.await
}

View File

@@ -23,19 +23,15 @@ pub async fn execute(
.await?;
let entry = WatchlistEntry::new(user_id.clone(), movie.id().clone());
deps.watchlist.add(&entry).await?;
let _ = deps
.event_publisher
.publish(&DomainEvent::WatchlistEntryAdded {
user_id,
movie_id: movie.id().clone(),
movie_title: movie.title().value().to_string(),
release_year: movie.release_year().value(),
external_metadata_id: movie.external_metadata_id().map(|e| e.value().to_string()),
added_at: entry.added_at,
})
.await;
let event = DomainEvent::WatchlistEntryAdded {
user_id,
movie_id: movie.id().clone(),
movie_title: movie.title().value().to_string(),
release_year: movie.release_year().value(),
external_metadata_id: movie.external_metadata_id().map(|e| e.value().to_string()),
added_at: entry.added_at,
};
deps.watchlist.add(&entry, &[event]).await?;
Ok(())
}

View File

@@ -29,5 +29,4 @@ pub struct IsOnWatchlistDeps {
pub struct RemoveFromWatchlistDeps {
pub watchlist: Arc<dyn WatchlistRepository>,
pub event_publisher: Arc<dyn EventPublisher>,
}

View File

@@ -13,12 +13,11 @@ pub async fn execute(
) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);
let movie_id = MovieId::from_uuid(cmd.movie_id);
deps.watchlist.remove(&user_id, &movie_id).await?;
let _ = deps
.event_publisher
.publish(&DomainEvent::WatchlistEntryRemoved { user_id, movie_id })
.await;
let event = DomainEvent::WatchlistEntryRemoved {
user_id: user_id.clone(),
movie_id: movie_id.clone(),
};
deps.watchlist.remove(&user_id, &movie_id, &[event]).await?;
Ok(())
}

View File

@@ -122,7 +122,7 @@ async fn deleting_a_review_removes_it_and_records_its_event_together() {
}
#[tokio::test]
async fn a_refused_delete_records_neither_the_removal_nor_its_event() {
async fn a_refused_delete_propagates_and_records_nothing() {
let movies = InMemoryStore::new();
let diary = FakeDiaryQuery::new();
let events = NoopEventPublisher::new();

View File

@@ -151,7 +151,7 @@ async fn editing_a_review_saves_the_change_and_its_event_together() {
}
#[tokio::test]
async fn a_refused_edit_records_neither_the_change_nor_its_event() {
async fn a_refused_edit_propagates_and_records_nothing() {
let (reviews, review_id, user_id) = setup().await;
let deps = EditReviewDeps {
review: Arc::new(WriteRefusingReviews(Arc::clone(&reviews))),

View File

@@ -68,7 +68,7 @@ async fn removes_from_watchlist_on_review() {
// Add to watchlist
let entry = WatchlistEntry::new(user_id.clone(), MovieId::from_uuid(movie_id));
watchlist.add(&entry).await.unwrap();
watchlist.add(&entry, &[]).await.unwrap();
assert_eq!(watchlist.watchlist_count(), 1);
// Log review for same movie
@@ -80,6 +80,20 @@ async fn removes_from_watchlist_on_review() {
logger.log_review(cmd).await.unwrap();
assert_eq!(watchlist.watchlist_count(), 0);
assert!(
movies
.recorded_events()
.iter()
.any(|e| matches!(e, domain::events::DomainEvent::WatchlistEntryRemoved { .. })),
"the removal event rides the watchlist write"
);
assert!(
!events
.published()
.iter()
.any(|e| matches!(e, domain::events::DomainEvent::WatchlistEntryRemoved { .. })),
"no longer published out of band"
);
}
#[tokio::test]
@@ -150,7 +164,7 @@ async fn watchlist_not_present_does_not_publish_removed() {
logger.log_review(cmd).await.unwrap();
let published = events.published();
let published = movies.recorded_events();
assert!(
!published
.iter()
@@ -260,7 +274,7 @@ async fn logging_a_review_saves_the_review_and_its_event_together() {
}
#[tokio::test]
async fn a_refused_save_records_neither_the_review_nor_its_event() {
async fn a_refused_save_propagates_and_records_nothing() {
let movies = InMemoryStore::new();
let logger = DefaultReviewLogger::new(
Arc::clone(&movies) as _,

View File

@@ -1,8 +1,9 @@
use crate::support::create_goal_command;
use std::sync::Arc;
use domain::errors::DomainError;
use domain::events::DomainEvent;
use domain::testing::{FakeStatsRepository, InMemoryStore, NoopEventPublisher};
use domain::testing::{FakeStatsRepository, InMemoryStore, RefusingGoalCommand};
use uuid::Uuid;
use crate::support::TestContextBuilder;
@@ -13,12 +14,10 @@ use application::goals::deps::GoalCommandDeps;
async fn creates_goal_and_returns_progress() {
let goals = InMemoryStore::new();
let stats = FakeStatsRepository::new();
let events = NoopEventPublisher::new();
let deps = GoalCommandDeps {
goal_command: Arc::clone(&goals) as _,
goal_query: Arc::clone(&goals) as _,
stats: Arc::clone(&stats) as _,
event_publisher: Arc::clone(&events) as _,
};
let result = create::execute(&deps, create_goal_command(2025, 50))
@@ -36,12 +35,10 @@ async fn creates_goal_with_review_count() {
let goals = InMemoryStore::new();
let stats = FakeStatsRepository::new();
stats.set_review_count(Uuid::nil(), 2025, 5);
let events = NoopEventPublisher::new();
let deps = GoalCommandDeps {
goal_command: Arc::clone(&goals) as _,
goal_query: Arc::clone(&goals) as _,
stats: Arc::clone(&stats) as _,
event_publisher: Arc::clone(&events) as _,
};
let result = create::execute(&deps, create_goal_command(2025, 50))
@@ -53,26 +50,45 @@ async fn creates_goal_with_review_count() {
}
#[tokio::test]
async fn emits_goal_created_event() {
let b = TestContextBuilder::new();
let events = NoopEventPublisher::new();
async fn creating_a_goal_records_the_goal_and_its_event_together() {
let goals = InMemoryStore::new();
let deps = GoalCommandDeps {
goal_command: b.goal_command.clone(),
goal_query: b.goal_query.clone(),
stats: b.stats_repo.clone(),
event_publisher: Arc::clone(&events) as _,
goal_command: Arc::clone(&goals) as _,
goal_query: Arc::clone(&goals) as _,
stats: FakeStatsRepository::new() as _,
};
create::execute(&deps, create_goal_command(2025, 10))
.await
.unwrap();
let published = events.published();
assert!(
published
.iter()
.any(|e| matches!(e, DomainEvent::GoalCreated { year: 2025, .. }))
);
assert_eq!(goals.goal_count(), 1);
let recorded = goals.recorded_events();
assert_eq!(recorded.len(), 1);
assert!(matches!(
recorded[0],
DomainEvent::GoalCreated {
year: 2025,
target_count: 10,
..
}
));
}
#[tokio::test]
async fn a_refused_create_propagates_and_records_nothing() {
let goals = InMemoryStore::new();
let deps = GoalCommandDeps {
goal_command: Arc::new(RefusingGoalCommand),
goal_query: Arc::clone(&goals) as _,
stats: FakeStatsRepository::new() as _,
};
let result = create::execute(&deps, create_goal_command(2025, 10)).await;
assert!(matches!(result, Err(DomainError::InfrastructureError(m)) if m == "refused"));
assert_eq!(goals.goal_count(), 0);
assert!(goals.recorded_events().is_empty());
}
#[tokio::test]
@@ -82,7 +98,6 @@ async fn rejects_duplicate_year() {
goal_command: b.goal_command.clone(),
goal_query: b.goal_query.clone(),
stats: b.stats_repo.clone(),
event_publisher: b.event_publisher.clone(),
};
let cmd = create_goal_command(2025, 10);
@@ -100,7 +115,6 @@ async fn rejects_year_before_2020() {
goal_command: b.goal_command.clone(),
goal_query: b.goal_query.clone(),
stats: b.stats_repo.clone(),
event_publisher: b.event_publisher.clone(),
};
let result = create::execute(&deps, create_goal_command(2019, 10)).await;
@@ -114,7 +128,6 @@ async fn rejects_zero_target() {
goal_command: b.goal_command.clone(),
goal_query: b.goal_query.clone(),
stats: b.stats_repo.clone(),
event_publisher: b.event_publisher.clone(),
};
let result = create::execute(&deps, create_goal_command(2025, 0)).await;

View File

@@ -1,7 +1,9 @@
use crate::support::create_goal_command;
use std::sync::Arc;
use domain::testing::{FakeStatsRepository, InMemoryStore, NoopEventPublisher};
use domain::errors::DomainError;
use domain::events::DomainEvent;
use domain::testing::{FakeStatsRepository, InMemoryStore, RefusingGoalCommand};
use uuid::Uuid;
use crate::support::TestContextBuilder;
@@ -12,12 +14,10 @@ use application::goals::{commands::DeleteGoalCommand, create, delete};
async fn deletes_existing_goal() {
let goals = InMemoryStore::new();
let stats = FakeStatsRepository::new();
let events = NoopEventPublisher::new();
let deps = GoalCommandDeps {
goal_command: Arc::clone(&goals) as _,
goal_query: Arc::clone(&goals) as _,
stats: Arc::clone(&stats) as _,
event_publisher: Arc::clone(&events) as _,
};
create::execute(&deps, create_goal_command(2025, 10))
@@ -45,7 +45,6 @@ async fn fails_when_not_found() {
goal_command: b.goal_command.clone(),
goal_query: b.goal_query.clone(),
stats: b.stats_repo.clone(),
event_publisher: b.event_publisher.clone(),
};
let result = delete::execute(
&deps,
@@ -58,3 +57,65 @@ async fn fails_when_not_found() {
assert!(result.is_err());
}
#[tokio::test]
async fn deleting_a_goal_removes_it_and_records_its_event_together() {
let goals = InMemoryStore::new();
let deps = GoalCommandDeps {
goal_command: Arc::clone(&goals) as _,
goal_query: Arc::clone(&goals) as _,
stats: FakeStatsRepository::new() as _,
};
create::execute(&deps, create_goal_command(2025, 10))
.await
.unwrap();
delete::execute(
&deps,
DeleteGoalCommand {
user_id: Uuid::nil(),
year: 2025,
},
)
.await
.unwrap();
assert_eq!(goals.goal_count(), 0);
let recorded = goals.recorded_events();
assert_eq!(recorded.len(), 2, "created + deleted");
assert!(matches!(
recorded[1],
DomainEvent::GoalDeleted { year: 2025, .. }
));
}
#[tokio::test]
async fn a_refused_delete_propagates_and_records_nothing() {
let goals = InMemoryStore::new();
let seed = GoalCommandDeps {
goal_command: Arc::clone(&goals) as _,
goal_query: Arc::clone(&goals) as _,
stats: FakeStatsRepository::new() as _,
};
create::execute(&seed, create_goal_command(2025, 10))
.await
.unwrap();
let before = goals.recorded_events().len();
let deps = GoalCommandDeps {
goal_command: Arc::new(RefusingGoalCommand),
..seed
};
let result = delete::execute(
&deps,
DeleteGoalCommand {
user_id: Uuid::nil(),
year: 2025,
},
)
.await;
assert!(matches!(result, Err(DomainError::InfrastructureError(m)) if m == "refused"));
assert_eq!(goals.goal_count(), 1);
assert_eq!(goals.recorded_events().len(), before);
}

View File

@@ -12,7 +12,6 @@ async fn returns_goal_when_exists() {
goal_command: b.goal_command.clone(),
goal_query: b.goal_query.clone(),
stats: b.stats_repo.clone(),
event_publisher: b.event_publisher.clone(),
};
let query_deps = GoalQueryDeps {
goal_query: b.goal_query.clone(),

View File

@@ -31,7 +31,6 @@ async fn returns_all_goals_for_user() {
goal_command: b.goal_command.clone(),
goal_query: b.goal_query.clone(),
stats: b.stats_repo.clone(),
event_publisher: b.event_publisher.clone(),
};
let query_deps = GoalQueryDeps {
goal_query: b.goal_query.clone(),

View File

@@ -1,5 +1,11 @@
use crate::support::{create_goal_command, update_goal_command};
use std::sync::Arc;
use domain::errors::DomainError;
use domain::events::DomainEvent;
use domain::testing::{FakeStatsRepository, InMemoryStore, RefusingGoalCommand};
use crate::support::TestContextBuilder;
use application::goals::deps::GoalCommandDeps;
use application::goals::{create, update};
@@ -11,7 +17,6 @@ async fn updates_target_count() {
goal_command: b.goal_command.clone(),
goal_query: b.goal_query.clone(),
stats: b.stats_repo.clone(),
event_publisher: b.event_publisher.clone(),
};
create::execute(&deps, create_goal_command(2025, 10))
@@ -32,7 +37,6 @@ async fn fails_when_goal_not_found() {
goal_command: b.goal_command.clone(),
goal_query: b.goal_query.clone(),
stats: b.stats_repo.clone(),
event_publisher: b.event_publisher.clone(),
};
let result = update::execute(&deps, update_goal_command(2025, 10)).await;
@@ -46,7 +50,6 @@ async fn rejects_zero_target() {
goal_command: b.goal_command.clone(),
goal_query: b.goal_query.clone(),
stats: b.stats_repo.clone(),
event_publisher: b.event_publisher.clone(),
};
create::execute(&deps, create_goal_command(2025, 10))
@@ -57,3 +60,54 @@ async fn rejects_zero_target() {
assert!(result.is_err());
}
#[tokio::test]
async fn updating_a_goal_records_the_change_and_its_event_together() {
let goals = InMemoryStore::new();
let deps = GoalCommandDeps {
goal_command: Arc::clone(&goals) as _,
goal_query: Arc::clone(&goals) as _,
stats: FakeStatsRepository::new() as _,
};
create::execute(&deps, create_goal_command(2025, 10))
.await
.unwrap();
update::execute(&deps, update_goal_command(2025, 100))
.await
.unwrap();
let recorded = goals.recorded_events();
assert_eq!(recorded.len(), 2, "created + updated");
assert!(matches!(
recorded[1],
DomainEvent::GoalUpdated {
year: 2025,
target_count: 100,
..
}
));
}
#[tokio::test]
async fn a_refused_update_propagates_and_records_nothing() {
let goals = InMemoryStore::new();
let seed = GoalCommandDeps {
goal_command: Arc::clone(&goals) as _,
goal_query: Arc::clone(&goals) as _,
stats: FakeStatsRepository::new() as _,
};
create::execute(&seed, create_goal_command(2025, 10))
.await
.unwrap();
let before = goals.recorded_events().len();
let deps = GoalCommandDeps {
goal_command: Arc::new(RefusingGoalCommand),
..seed
};
let result = update::execute(&deps, update_goal_command(2025, 100)).await;
assert!(matches!(result, Err(DomainError::InfrastructureError(m)) if m == "refused"));
assert_eq!(goals.recorded_events().len(), before);
}

View File

@@ -37,6 +37,7 @@ async fn get_profile_settings_includes_profile_fields() {
name: "pronouns".into(),
value: "they/them".into(),
}],
&[],
)
.await
.unwrap();

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use domain::events::DomainEvent;
use domain::models::ProfileField;
use domain::testing::{InMemoryStore, NoopEventPublisher};
use domain::testing::{InMemoryStore, RefusingProfileFieldsRepository};
use uuid::Uuid;
use application::users::{
@@ -11,15 +11,10 @@ use application::users::{
};
#[tokio::test]
async fn saves_profile_fields() {
async fn saving_profile_fields_records_the_change_and_its_event_together() {
let fields_repo = InMemoryStore::new();
let events = NoopEventPublisher::new();
let b = TestContextBuilder::new()
.with_profile_fields(Arc::clone(&fields_repo) as _)
.with_event_publisher(Arc::clone(&events) as _);
let deps = UpdateProfileFieldsDeps {
profile_fields: b.profile_fields_repo.clone(),
event_publisher: b.event_publisher.clone(),
profile_fields: Arc::clone(&fields_repo) as _,
};
update_profile_fields::execute(
@@ -41,12 +36,9 @@ async fn saves_profile_fields() {
.await
.unwrap();
let published = events.published();
assert!(
published
.iter()
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
);
let recorded = fields_repo.recorded_events();
assert_eq!(recorded.len(), 1);
assert!(matches!(recorded[0], DomainEvent::UserUpdated { .. }));
}
#[tokio::test]
@@ -54,7 +46,6 @@ async fn rejects_more_than_four_fields() {
let b = TestContextBuilder::new();
let deps = UpdateProfileFieldsDeps {
profile_fields: b.profile_fields_repo.clone(),
event_publisher: b.event_publisher.clone(),
};
let fields: Vec<ProfileField> = (0..5)
@@ -75,3 +66,24 @@ async fn rejects_more_than_four_fields() {
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"
));
}

View File

@@ -31,7 +31,7 @@ async fn register_user(b: &TestContextBuilder, users: &Arc<InMemoryStore>) -> Uu
}
#[tokio::test]
async fn updates_display_name() {
async fn updating_the_profile_records_the_change_and_its_event_together() {
let users = InMemoryStore::new();
let events = NoopEventPublisher::new();
let b = TestContextBuilder::new()
@@ -54,12 +54,9 @@ async fn updates_display_name() {
.await
.unwrap();
let published = events.published();
assert!(
published
.iter()
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
);
let recorded = users.recorded_events();
assert_eq!(recorded.len(), 1);
assert!(matches!(recorded[0], DomainEvent::UserUpdated { .. }));
}
#[tokio::test]
@@ -111,14 +108,15 @@ async fn uploads_avatar() {
.await
.unwrap();
let published = events.published();
assert!(
published
users
.recorded_events()
.iter()
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
);
assert!(
published
events
.published()
.iter()
.any(|e| matches!(e, DomainEvent::ImageStored { .. }))
);
@@ -149,14 +147,15 @@ async fn uploads_banner() {
.await
.unwrap();
let published = events.published();
assert!(
published
users
.recorded_events()
.iter()
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
);
assert!(
published
events
.published()
.iter()
.any(|e| matches!(e, DomainEvent::ImageStored { .. }))
);
@@ -232,16 +231,114 @@ async fn text_only_update_emits_user_updated_no_image_stored() {
.await
.unwrap();
let published = events.published();
assert!(
published
users
.recorded_events()
.iter()
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
);
assert!(
!published
!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 { .. }));
}
/// Loads from the store but refuses the profile write.
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(),
))
}
}
#[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());
}

View File

@@ -3,8 +3,9 @@ use domain::testing::test_movie_with_year;
use std::sync::Arc;
use domain::{
events::DomainEvent,
ports::MovieCommand,
testing::{InMemoryStore, NoopEventPublisher},
testing::{InMemoryStore, NoopEventPublisher, RefusingWatchlistRepository},
};
use application::watchlist::{add, commands::AddToWatchlistCommand, deps::WatchlistAddDeps};
@@ -108,3 +109,50 @@ async fn adding_the_same_movie_to_a_watchlist_twice_keeps_a_single_entry_and_suc
assert_eq!(movies.watchlist_count(), 1);
}
#[tokio::test]
async fn adding_an_entry_records_the_entry_and_its_event_together() {
let movies = InMemoryStore::new();
let movie = test_movie_with_year("Heat", 1995);
movies.upsert_movie(&movie).await.unwrap();
let deps = make_deps(Arc::clone(&movies), Arc::clone(&movies));
add::execute(
&deps,
add_to_watchlist_command(uuid::Uuid::new_v4(), movie.id().value()),
)
.await
.unwrap();
assert_eq!(movies.watchlist_count(), 1);
let recorded = movies.recorded_events();
assert_eq!(recorded.len(), 1);
assert!(matches!(
recorded[0],
DomainEvent::WatchlistEntryAdded { .. }
));
}
#[tokio::test]
async fn a_refused_add_propagates_and_records_nothing() {
let movies = InMemoryStore::new();
let movie = test_movie_with_year("Heat", 1995);
movies.upsert_movie(&movie).await.unwrap();
let deps = WatchlistAddDeps {
watchlist: Arc::new(RefusingWatchlistRepository),
..make_deps(Arc::clone(&movies), Arc::clone(&movies))
};
let result = add::execute(
&deps,
add_to_watchlist_command(uuid::Uuid::new_v4(), movie.id().value()),
)
.await;
assert!(matches!(
result,
Err(domain::errors::DomainError::InfrastructureError(m)) if m == "refused"
));
assert_eq!(movies.watchlist_count(), 0);
assert!(movies.recorded_events().is_empty());
}

View File

@@ -15,10 +15,10 @@ async fn returns_true_when_present() {
let uid = Uuid::new_v4();
let mid = Uuid::new_v4();
watchlist
.add(&WatchlistEntry::new(
UserId::from_uuid(uid),
MovieId::from_uuid(mid),
))
.add(
&WatchlistEntry::new(UserId::from_uuid(uid), MovieId::from_uuid(mid)),
&[],
)
.await
.unwrap();

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use domain::events::DomainEvent;
use domain::models::WatchlistEntry;
use domain::ports::WatchlistRepository;
use domain::testing::{InMemoryStore, NoopEventPublisher};
use domain::testing::{InMemoryStore, RefusingWatchlistRepository};
use domain::value_objects::{MovieId, UserId};
use uuid::Uuid;
@@ -13,22 +13,20 @@ use application::watchlist::{
};
#[tokio::test]
async fn removes_entry_and_emits_event() {
async fn removing_an_entry_records_the_removal_and_its_event_together() {
let watchlist = InMemoryStore::new();
let events = NoopEventPublisher::new();
let uid = Uuid::new_v4();
let mid = Uuid::new_v4();
watchlist
.add(&WatchlistEntry::new(
UserId::from_uuid(uid),
MovieId::from_uuid(mid),
))
.add(
&WatchlistEntry::new(UserId::from_uuid(uid), MovieId::from_uuid(mid)),
&[],
)
.await
.unwrap();
let deps = RemoveFromWatchlistDeps {
watchlist: Arc::clone(&watchlist) as _,
event_publisher: Arc::clone(&events) as _,
};
remove::execute(
&deps,
@@ -41,12 +39,12 @@ async fn removes_entry_and_emits_event() {
.unwrap();
assert_eq!(watchlist.watchlist_count(), 0);
let published = events.published();
assert!(
published
.iter()
.any(|e| matches!(e, DomainEvent::WatchlistEntryRemoved { .. }))
);
let recorded = watchlist.recorded_events();
assert_eq!(recorded.len(), 1);
assert!(matches!(
recorded[0],
DomainEvent::WatchlistEntryRemoved { .. }
));
}
#[tokio::test]
@@ -54,7 +52,6 @@ async fn fails_when_not_on_watchlist() {
let b = TestContextBuilder::new();
let deps = RemoveFromWatchlistDeps {
watchlist: b.watchlist_repo.clone(),
event_publisher: b.event_publisher.clone(),
};
let result = remove::execute(
&deps,
@@ -67,3 +64,44 @@ async fn fails_when_not_on_watchlist() {
assert!(result.is_err());
}
#[tokio::test]
async fn a_failed_removal_records_no_event() {
let store = InMemoryStore::new();
let deps = RemoveFromWatchlistDeps {
watchlist: Arc::clone(&store) as _,
};
let result = remove::execute(
&deps,
RemoveFromWatchlistCommand {
user_id: Uuid::new_v4(),
movie_id: Uuid::new_v4(),
},
)
.await;
assert!(result.is_err());
assert!(store.recorded_events().is_empty());
}
#[tokio::test]
async fn a_refused_remove_propagates_the_refusal() {
let deps = RemoveFromWatchlistDeps {
watchlist: Arc::new(RefusingWatchlistRepository),
};
let result = remove::execute(
&deps,
RemoveFromWatchlistCommand {
user_id: Uuid::new_v4(),
movie_id: Uuid::new_v4(),
},
)
.await;
assert!(matches!(
result,
Err(domain::errors::DomainError::InfrastructureError(m)) if m == "refused"
));
}