feat: atomic social local writes, remote review enrichment
SocialCommand methods take events. Local follow ops = one FollowCommand::apply tx (rows + events). k_ap path (remote, block/unblock): best-effort publish in CompositeSocialAdapter after k_ap write; not atomic, k_ap owns tx. save_remote_review takes events, enqueued in same tx via sqlite::persistence::enqueue_events. Deps drop event_publisher: social command, ReviewObjectHandler. Remaining .publish( sites: announce-only: users/delete_account UserDeleted, person/get + get_credits PersonEnrichmentRequested, jobs/enrichment_staleness, http-axum search, ImageStored (sync_poster, poster_sync_handler, image-converter backfill, update_profile; object storage), federation_event_bridge (handler reaction), nats relay (transport). non-atomic by necessity: CompositeSocialAdapter::announce (k_ap-owned write). Everything else rides its write.
This commit is contained in:
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
events::DomainEvent,
|
||||
testing::{InMemoryStore, NoopEventPublisher},
|
||||
testing::{InMemoryStore, RefusingSocialCommand},
|
||||
value_objects::{FollowStatus, FollowTarget, SocialIdentity, UserId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
@@ -13,25 +13,19 @@ use application::social::{
|
||||
execute::execute_command,
|
||||
};
|
||||
|
||||
fn make_cmd_deps() -> (
|
||||
Arc<InMemoryStore>,
|
||||
Arc<NoopEventPublisher>,
|
||||
SocialCommandDeps,
|
||||
) {
|
||||
fn make_cmd_deps() -> (Arc<InMemoryStore>, SocialCommandDeps) {
|
||||
let social = InMemoryStore::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let deps = SocialCommandDeps {
|
||||
social_command: Arc::clone(&social) as _,
|
||||
event_publisher: Arc::clone(&events) as _,
|
||||
};
|
||||
(social, events, deps)
|
||||
(social, deps)
|
||||
}
|
||||
|
||||
// ── Follow ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_emits_follow_requested_event() {
|
||||
let (_social, events, deps) = make_cmd_deps();
|
||||
let (events, deps) = make_cmd_deps();
|
||||
|
||||
execute_command(
|
||||
&deps,
|
||||
@@ -45,7 +39,7 @@ async fn follow_emits_follow_requested_event() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
let published = events.recorded_events();
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
@@ -55,7 +49,7 @@ async fn follow_emits_follow_requested_event() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn cannot_follow_yourself() {
|
||||
let (_social, _events, deps) = make_cmd_deps();
|
||||
let (_social, deps) = make_cmd_deps();
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let result = execute_command(
|
||||
@@ -72,7 +66,7 @@ async fn cannot_follow_yourself() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn cannot_follow_same_target_twice() {
|
||||
let (_social, _events, deps) = make_cmd_deps();
|
||||
let (_social, deps) = make_cmd_deps();
|
||||
let follower_id = Uuid::new_v4();
|
||||
let target = FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4())));
|
||||
|
||||
@@ -102,7 +96,7 @@ async fn cannot_follow_same_target_twice() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn unfollow_emits_unfollowed_event() {
|
||||
let (_social, events, deps) = make_cmd_deps();
|
||||
let (events, deps) = make_cmd_deps();
|
||||
let follower_id = Uuid::new_v4();
|
||||
let target = SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4()));
|
||||
|
||||
@@ -126,7 +120,7 @@ async fn unfollow_emits_unfollowed_event() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
let published = events.recorded_events();
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
@@ -138,7 +132,7 @@ async fn unfollow_emits_unfollowed_event() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn accept_follow_emits_follow_accepted_event() {
|
||||
let (_social, events, deps) = make_cmd_deps();
|
||||
let (events, deps) = make_cmd_deps();
|
||||
let follower_id = Uuid::new_v4();
|
||||
let owner_id = Uuid::new_v4();
|
||||
let requester = SocialIdentity::Local(UserId::from_uuid(follower_id));
|
||||
@@ -163,7 +157,7 @@ async fn accept_follow_emits_follow_accepted_event() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
let published = events.recorded_events();
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
@@ -175,7 +169,7 @@ async fn accept_follow_emits_follow_accepted_event() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn reject_follow_emits_follow_rejected_event() {
|
||||
let (_social, events, deps) = make_cmd_deps();
|
||||
let (events, deps) = make_cmd_deps();
|
||||
let follower_id = Uuid::new_v4();
|
||||
let owner_id = Uuid::new_v4();
|
||||
|
||||
@@ -199,7 +193,7 @@ async fn reject_follow_emits_follow_rejected_event() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
let published = events.recorded_events();
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
@@ -211,7 +205,7 @@ async fn reject_follow_emits_follow_rejected_event() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_follower_emits_follower_removed_event() {
|
||||
let (_social, events, deps) = make_cmd_deps();
|
||||
let (events, deps) = make_cmd_deps();
|
||||
let follower_id = Uuid::new_v4();
|
||||
let owner_id = Uuid::new_v4();
|
||||
|
||||
@@ -245,7 +239,7 @@ async fn remove_follower_emits_follower_removed_event() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
let published = events.recorded_events();
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
@@ -257,7 +251,7 @@ async fn remove_follower_emits_follower_removed_event() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn block_emits_actor_blocked_event() {
|
||||
let (_social, events, deps) = make_cmd_deps();
|
||||
let (events, deps) = make_cmd_deps();
|
||||
|
||||
execute_command(
|
||||
&deps,
|
||||
@@ -269,7 +263,7 @@ async fn block_emits_actor_blocked_event() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
let published = events.recorded_events();
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
@@ -281,7 +275,7 @@ async fn block_emits_actor_blocked_event() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn unblock_emits_actor_unblocked_event() {
|
||||
let (_social, events, deps) = make_cmd_deps();
|
||||
let (events, deps) = make_cmd_deps();
|
||||
let target = SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4()));
|
||||
let blocker_id = Uuid::new_v4();
|
||||
|
||||
@@ -299,7 +293,7 @@ async fn unblock_emits_actor_unblocked_event() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let published = events.published();
|
||||
let published = events.recorded_events();
|
||||
assert!(
|
||||
published
|
||||
.iter()
|
||||
@@ -311,7 +305,7 @@ async fn unblock_emits_actor_unblocked_event() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_following_returns_accepted_targets() {
|
||||
let (social, _events, cmd_deps) = make_cmd_deps();
|
||||
let (social, cmd_deps) = make_cmd_deps();
|
||||
let query_deps = SocialQueryDeps {
|
||||
follow_graph: Arc::clone(&social) as _,
|
||||
block_query: Arc::clone(&social) as _,
|
||||
@@ -360,10 +354,8 @@ async fn get_following_returns_accepted_targets() {
|
||||
#[tokio::test]
|
||||
async fn returns_accepted_followers() {
|
||||
let social = InMemoryStore::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let cmd_deps = SocialCommandDeps {
|
||||
social_command: Arc::clone(&social) as _,
|
||||
event_publisher: Arc::clone(&events) as _,
|
||||
};
|
||||
let query_deps = SocialQueryDeps {
|
||||
follow_graph: Arc::clone(&social) as _,
|
||||
@@ -404,10 +396,8 @@ async fn returns_accepted_followers() {
|
||||
#[tokio::test]
|
||||
async fn returns_only_pending_followers() {
|
||||
let social = InMemoryStore::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let cmd_deps = SocialCommandDeps {
|
||||
social_command: Arc::clone(&social) as _,
|
||||
event_publisher: Arc::clone(&events) as _,
|
||||
};
|
||||
let query_deps = SocialQueryDeps {
|
||||
follow_graph: Arc::clone(&social) as _,
|
||||
@@ -439,7 +429,7 @@ async fn returns_only_pending_followers() {
|
||||
async fn get_relation_reports_none_for_strangers() {
|
||||
use domain::ports::FollowGraphQuery;
|
||||
|
||||
let (social, _events, _deps) = make_cmd_deps();
|
||||
let (social, _deps) = make_cmd_deps();
|
||||
let rel = FollowGraphQuery::get_relation(
|
||||
&*social,
|
||||
&UserId::from_uuid(Uuid::new_v4()),
|
||||
@@ -456,7 +446,7 @@ async fn get_relation_reports_none_for_strangers() {
|
||||
async fn get_relation_reports_pending_before_acceptance() {
|
||||
use domain::ports::FollowGraphQuery;
|
||||
|
||||
let (social, _events, deps) = make_cmd_deps();
|
||||
let (social, deps) = make_cmd_deps();
|
||||
let a = Uuid::new_v4();
|
||||
let b_id = UserId::from_uuid(Uuid::new_v4());
|
||||
|
||||
@@ -486,7 +476,7 @@ async fn get_relation_reports_pending_before_acceptance() {
|
||||
async fn get_relation_reports_accepted_in_both_directions_after_mutual_follow() {
|
||||
use domain::ports::FollowGraphQuery;
|
||||
|
||||
let (social, _events, deps) = make_cmd_deps();
|
||||
let (social, deps) = make_cmd_deps();
|
||||
let a = Uuid::new_v4();
|
||||
let b = Uuid::new_v4();
|
||||
let a_id = UserId::from_uuid(a);
|
||||
@@ -528,7 +518,7 @@ async fn get_relation_reports_accepted_in_both_directions_after_mutual_follow()
|
||||
async fn unaccepted_follow_appears_in_pending_following_not_in_following() {
|
||||
use domain::ports::FollowGraphQuery;
|
||||
|
||||
let (social, _events, deps) = make_cmd_deps();
|
||||
let (social, deps) = make_cmd_deps();
|
||||
let viewer = Uuid::new_v4();
|
||||
let target = UserId::from_uuid(Uuid::new_v4());
|
||||
|
||||
@@ -561,7 +551,7 @@ async fn unaccepted_follow_appears_in_pending_following_not_in_following() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_pending_following_returns_unaccepted_targets_and_get_following_does_not() {
|
||||
let (social, _events, cmd_deps) = make_cmd_deps();
|
||||
let (social, cmd_deps) = make_cmd_deps();
|
||||
let query_deps = SocialQueryDeps {
|
||||
follow_graph: Arc::clone(&social) as _,
|
||||
block_query: Arc::clone(&social) as _,
|
||||
@@ -599,7 +589,7 @@ async fn get_pending_following_returns_unaccepted_targets_and_get_following_does
|
||||
|
||||
#[tokio::test]
|
||||
async fn count_pending_followers_drops_as_requests_are_accepted() {
|
||||
let (social, _events, cmd_deps) = make_cmd_deps();
|
||||
let (social, cmd_deps) = make_cmd_deps();
|
||||
let query_deps = SocialQueryDeps {
|
||||
follow_graph: Arc::clone(&social) as _,
|
||||
block_query: Arc::clone(&social) as _,
|
||||
@@ -648,7 +638,7 @@ async fn count_pending_followers_drops_as_requests_are_accepted() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_relation_use_case_reports_direction_asymmetrically() {
|
||||
let (social, _events, cmd_deps) = make_cmd_deps();
|
||||
let (social, cmd_deps) = make_cmd_deps();
|
||||
let query_deps = SocialQueryDeps {
|
||||
follow_graph: Arc::clone(&social) as _,
|
||||
block_query: Arc::clone(&social) as _,
|
||||
@@ -681,3 +671,67 @@ async fn get_relation_use_case_reports_direction_asymmetrically() {
|
||||
);
|
||||
assert_eq!(rel.followed_by, None, "target has not followed back");
|
||||
}
|
||||
|
||||
// ── Events ride the write ───────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_follow_records_exactly_its_event_with_the_write() {
|
||||
let (social, deps) = make_cmd_deps();
|
||||
|
||||
execute_command(
|
||||
&deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id: Uuid::new_v4(),
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(
|
||||
UserId::from_uuid(Uuid::new_v4()),
|
||||
)),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let recorded = social.recorded_events();
|
||||
assert_eq!(recorded.len(), 1);
|
||||
assert!(matches!(recorded[0], DomainEvent::FollowRequested { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_rejected_self_follow_records_nothing() {
|
||||
let (social, deps) = make_cmd_deps();
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let result = execute_command(
|
||||
&deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id: user_id,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(user_id))),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(social.recorded_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_follow_propagates() {
|
||||
let deps = SocialCommandDeps {
|
||||
social_command: Arc::new(RefusingSocialCommand),
|
||||
};
|
||||
|
||||
let result = execute_command(
|
||||
&deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id: Uuid::new_v4(),
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(
|
||||
UserId::from_uuid(Uuid::new_v4()),
|
||||
)),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(domain::errors::DomainError::InfrastructureError(m)) if m == "refused"
|
||||
));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user