feat: atomic goal, watchlist, profile writes
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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 _,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ async fn get_profile_settings_includes_profile_fields() {
|
||||
name: "pronouns".into(),
|
||||
value: "they/them".into(),
|
||||
}],
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -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"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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"
|
||||
));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user