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:
@@ -1,10 +1,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{BlockQuery, EventPublisher, FollowGraphQuery, SocialCommand};
|
||||
use domain::ports::{BlockQuery, FollowGraphQuery, SocialCommand};
|
||||
|
||||
pub struct SocialCommandDeps {
|
||||
pub social_command: Arc<dyn SocialCommand>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
pub struct SocialQueryDeps {
|
||||
|
||||
@@ -2,61 +2,78 @@ use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId};
|
||||
|
||||
use super::{commands::SocialCmd, deps::SocialCommandDeps};
|
||||
|
||||
/// The command's event rides the write: the social port records it with the
|
||||
/// change (atomically where the adapter owns the transaction).
|
||||
pub async fn execute_command(deps: &SocialCommandDeps, cmd: SocialCmd) -> Result<(), DomainError> {
|
||||
let event = match cmd {
|
||||
let command = &deps.social_command;
|
||||
match cmd {
|
||||
SocialCmd::Follow {
|
||||
follower_id,
|
||||
target,
|
||||
} => {
|
||||
let follower = UserId::from_uuid(follower_id);
|
||||
deps.social_command.follow(&follower, &target).await?;
|
||||
DomainEvent::FollowRequested { follower, target }
|
||||
let events = [DomainEvent::FollowRequested {
|
||||
follower: follower.clone(),
|
||||
target: target.clone(),
|
||||
}];
|
||||
command.follow(&follower, &target, &events).await
|
||||
}
|
||||
SocialCmd::Unfollow {
|
||||
follower_id,
|
||||
target,
|
||||
} => {
|
||||
let follower = UserId::from_uuid(follower_id);
|
||||
deps.social_command.unfollow(&follower, &target).await?;
|
||||
DomainEvent::Unfollowed { follower, target }
|
||||
let events = [DomainEvent::Unfollowed {
|
||||
follower: follower.clone(),
|
||||
target: target.clone(),
|
||||
}];
|
||||
command.unfollow(&follower, &target, &events).await
|
||||
}
|
||||
SocialCmd::AcceptFollow {
|
||||
owner_id,
|
||||
requester,
|
||||
} => {
|
||||
let owner = UserId::from_uuid(owner_id);
|
||||
deps.social_command
|
||||
.accept_follow(&owner, &requester)
|
||||
.await?;
|
||||
DomainEvent::FollowAccepted { owner, requester }
|
||||
let events = [DomainEvent::FollowAccepted {
|
||||
owner: owner.clone(),
|
||||
requester: requester.clone(),
|
||||
}];
|
||||
command.accept_follow(&owner, &requester, &events).await
|
||||
}
|
||||
SocialCmd::RejectFollow {
|
||||
owner_id,
|
||||
requester,
|
||||
} => {
|
||||
let owner = UserId::from_uuid(owner_id);
|
||||
deps.social_command
|
||||
.reject_follow(&owner, &requester)
|
||||
.await?;
|
||||
DomainEvent::FollowRejected { owner, requester }
|
||||
let events = [DomainEvent::FollowRejected {
|
||||
owner: owner.clone(),
|
||||
requester: requester.clone(),
|
||||
}];
|
||||
command.reject_follow(&owner, &requester, &events).await
|
||||
}
|
||||
SocialCmd::RemoveFollower { owner_id, follower } => {
|
||||
let owner = UserId::from_uuid(owner_id);
|
||||
deps.social_command
|
||||
.remove_follower(&owner, &follower)
|
||||
.await?;
|
||||
DomainEvent::FollowerRemoved { owner, follower }
|
||||
let events = [DomainEvent::FollowerRemoved {
|
||||
owner: owner.clone(),
|
||||
follower: follower.clone(),
|
||||
}];
|
||||
command.remove_follower(&owner, &follower, &events).await
|
||||
}
|
||||
SocialCmd::Block { blocker_id, target } => {
|
||||
let blocker = UserId::from_uuid(blocker_id);
|
||||
deps.social_command.block(&blocker, &target).await?;
|
||||
DomainEvent::ActorBlocked { blocker, target }
|
||||
let events = [DomainEvent::ActorBlocked {
|
||||
blocker: blocker.clone(),
|
||||
target: target.clone(),
|
||||
}];
|
||||
command.block(&blocker, &target, &events).await
|
||||
}
|
||||
SocialCmd::Unblock { blocker_id, target } => {
|
||||
let blocker = UserId::from_uuid(blocker_id);
|
||||
deps.social_command.unblock(&blocker, &target).await?;
|
||||
DomainEvent::ActorUnblocked { blocker, target }
|
||||
let events = [DomainEvent::ActorUnblocked {
|
||||
blocker: blocker.clone(),
|
||||
target: target.clone(),
|
||||
}];
|
||||
command.unblock(&blocker, &target, &events).await
|
||||
}
|
||||
};
|
||||
deps.event_publisher.publish(&event).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
ports::{
|
||||
BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, FollowTargetResolver,
|
||||
ResolvedFollow, SocialCommand, UserRepository,
|
||||
FollowWrite, ResolvedFollow, SocialCommand, UserRepository,
|
||||
},
|
||||
value_objects::{
|
||||
FollowRelation, FollowStatus, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity,
|
||||
@@ -72,27 +73,41 @@ fn remote_unsupported(actor: &str) -> DomainError {
|
||||
|
||||
#[async_trait]
|
||||
impl SocialCommand for LocalSocialService {
|
||||
async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError> {
|
||||
async fn follow(
|
||||
&self,
|
||||
follower: &UserId,
|
||||
target: &FollowTarget,
|
||||
events: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
let identity = self.resolve_target_identity(target).await?;
|
||||
self.follow_resolved(follower, &identity).await
|
||||
self.follow_resolved(follower, &identity, events).await
|
||||
}
|
||||
|
||||
async fn unfollow(
|
||||
&self,
|
||||
follower: &UserId,
|
||||
target: &SocialIdentity,
|
||||
events: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = self.instance.actor_url_of(target);
|
||||
match target {
|
||||
SocialIdentity::Local(target_id) => {
|
||||
let follower_url = self.instance.actor_url_for(follower);
|
||||
self.follow_command
|
||||
.remove_follow(follower.value(), &actor_url)
|
||||
.await?;
|
||||
self.follow_command
|
||||
.remove_follower_record(target_id.value(), &follower_url)
|
||||
.await?;
|
||||
Ok(())
|
||||
.apply(
|
||||
&[
|
||||
FollowWrite::RemoveFollow {
|
||||
follower_id: follower.value(),
|
||||
target_actor_url: actor_url,
|
||||
},
|
||||
FollowWrite::RemoveFollowerRecord {
|
||||
local_user_id: target_id.value(),
|
||||
follower_actor_url: follower_url,
|
||||
},
|
||||
],
|
||||
events,
|
||||
)
|
||||
.await
|
||||
}
|
||||
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
|
||||
}
|
||||
@@ -102,18 +117,29 @@ impl SocialCommand for LocalSocialService {
|
||||
&self,
|
||||
owner: &UserId,
|
||||
requester: &SocialIdentity,
|
||||
events: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = self.instance.actor_url_of(requester);
|
||||
match requester {
|
||||
SocialIdentity::Local(requester_id) => {
|
||||
let owner_url = self.instance.actor_url_for(owner);
|
||||
self.follow_command
|
||||
.update_follower_status(owner.value(), &actor_url, FollowStatus::Accepted)
|
||||
.await?;
|
||||
self.follow_command
|
||||
.update_follow_status(requester_id.value(), &owner_url, FollowStatus::Accepted)
|
||||
.await?;
|
||||
Ok(())
|
||||
.apply(
|
||||
&[
|
||||
FollowWrite::UpdateFollowerStatus {
|
||||
local_user_id: owner.value(),
|
||||
follower_actor_url: actor_url,
|
||||
status: FollowStatus::Accepted,
|
||||
},
|
||||
FollowWrite::UpdateFollowStatus {
|
||||
follower_id: requester_id.value(),
|
||||
target_actor_url: owner_url,
|
||||
status: FollowStatus::Accepted,
|
||||
},
|
||||
],
|
||||
events,
|
||||
)
|
||||
.await
|
||||
}
|
||||
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
|
||||
}
|
||||
@@ -123,18 +149,28 @@ impl SocialCommand for LocalSocialService {
|
||||
&self,
|
||||
owner: &UserId,
|
||||
requester: &SocialIdentity,
|
||||
events: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = self.instance.actor_url_of(requester);
|
||||
match requester {
|
||||
SocialIdentity::Local(requester_id) => {
|
||||
let owner_url = self.instance.actor_url_for(owner);
|
||||
self.follow_command
|
||||
.update_follower_status(owner.value(), &actor_url, FollowStatus::Rejected)
|
||||
.await?;
|
||||
self.follow_command
|
||||
.remove_follow(requester_id.value(), &owner_url)
|
||||
.await?;
|
||||
Ok(())
|
||||
.apply(
|
||||
&[
|
||||
FollowWrite::UpdateFollowerStatus {
|
||||
local_user_id: owner.value(),
|
||||
follower_actor_url: actor_url,
|
||||
status: FollowStatus::Rejected,
|
||||
},
|
||||
FollowWrite::RemoveFollow {
|
||||
follower_id: requester_id.value(),
|
||||
target_actor_url: owner_url,
|
||||
},
|
||||
],
|
||||
events,
|
||||
)
|
||||
.await
|
||||
}
|
||||
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
|
||||
}
|
||||
@@ -144,24 +180,38 @@ impl SocialCommand for LocalSocialService {
|
||||
&self,
|
||||
owner: &UserId,
|
||||
follower: &SocialIdentity,
|
||||
events: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = self.instance.actor_url_of(follower);
|
||||
match follower {
|
||||
SocialIdentity::Local(follower_id) => {
|
||||
let owner_url = self.instance.actor_url_for(owner);
|
||||
self.follow_command
|
||||
.remove_follower_record(owner.value(), &actor_url)
|
||||
.await?;
|
||||
self.follow_command
|
||||
.remove_follow(follower_id.value(), &owner_url)
|
||||
.await?;
|
||||
Ok(())
|
||||
.apply(
|
||||
&[
|
||||
FollowWrite::RemoveFollowerRecord {
|
||||
local_user_id: owner.value(),
|
||||
follower_actor_url: actor_url,
|
||||
},
|
||||
FollowWrite::RemoveFollow {
|
||||
follower_id: follower_id.value(),
|
||||
target_actor_url: owner_url,
|
||||
},
|
||||
],
|
||||
events,
|
||||
)
|
||||
.await
|
||||
}
|
||||
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn block(&self, _blocker: &UserId, _target: &SocialIdentity) -> Result<(), DomainError> {
|
||||
async fn block(
|
||||
&self,
|
||||
_blocker: &UserId,
|
||||
_target: &SocialIdentity,
|
||||
_events: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
Err(DomainError::ValidationError(
|
||||
"blocking requires the federation feature".into(),
|
||||
))
|
||||
@@ -171,6 +221,7 @@ impl SocialCommand for LocalSocialService {
|
||||
&self,
|
||||
_blocker: &UserId,
|
||||
_target: &SocialIdentity,
|
||||
_events: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
Err(DomainError::ValidationError(
|
||||
"blocking requires the federation feature".into(),
|
||||
@@ -242,6 +293,7 @@ impl ResolvedFollow for LocalSocialService {
|
||||
&self,
|
||||
follower: &UserId,
|
||||
target: &SocialIdentity,
|
||||
events: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
let SocialIdentity::Local(target_id) = target else {
|
||||
let actor_url = self.instance.actor_url_of(target);
|
||||
@@ -255,11 +307,21 @@ impl ResolvedFollow for LocalSocialService {
|
||||
let follower_url = self.instance.actor_url_for(follower);
|
||||
let target_url = self.instance.actor_url_for(target_id);
|
||||
self.follow_command
|
||||
.add_follower(target_id.value(), &follower_url, FollowStatus::Pending)
|
||||
.await?;
|
||||
self.follow_command
|
||||
.add_follow(follower.value(), &target_url, FollowStatus::Pending)
|
||||
.await?;
|
||||
Ok(())
|
||||
.apply(
|
||||
&[
|
||||
FollowWrite::AddFollower {
|
||||
local_user_id: target_id.value(),
|
||||
follower_actor_url: follower_url,
|
||||
status: FollowStatus::Pending,
|
||||
},
|
||||
FollowWrite::AddFollow {
|
||||
follower_id: follower.value(),
|
||||
target_actor_url: target_url,
|
||||
status: FollowStatus::Pending,
|
||||
},
|
||||
],
|
||||
events,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
));
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ use std::sync::{Arc, Mutex};
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
ports::{
|
||||
BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, SocialCommand, UserRepository,
|
||||
BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, FollowWrite, SocialCommand,
|
||||
UserRepository,
|
||||
},
|
||||
testing::InMemoryStore,
|
||||
value_objects::{
|
||||
@@ -30,6 +32,8 @@ struct FollowFakeStore {
|
||||
follows: Mutex<Vec<(Uuid, String, FollowStatus)>>,
|
||||
// (local_user_id, follower_actor_url, status) — written by add_follower/update_follower_status/remove_follower_record
|
||||
followers: Mutex<Vec<(Uuid, String, FollowStatus)>>,
|
||||
recorded: Mutex<Vec<DomainEvent>>,
|
||||
refuse_writes: bool,
|
||||
}
|
||||
|
||||
impl FollowFakeStore {
|
||||
@@ -37,8 +41,23 @@ impl FollowFakeStore {
|
||||
Arc::new(Self {
|
||||
follows: Mutex::new(Vec::new()),
|
||||
followers: Mutex::new(Vec::new()),
|
||||
recorded: Mutex::new(Vec::new()),
|
||||
refuse_writes: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn refusing() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
follows: Mutex::new(Vec::new()),
|
||||
followers: Mutex::new(Vec::new()),
|
||||
recorded: Mutex::new(Vec::new()),
|
||||
refuse_writes: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn recorded_events(&self) -> Vec<DomainEvent> {
|
||||
self.recorded.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_actor(url: &str) -> SocialActor {
|
||||
@@ -54,86 +73,63 @@ fn fake_actor(url: &str) -> SocialActor {
|
||||
|
||||
#[async_trait]
|
||||
impl FollowCommand for FollowFakeStore {
|
||||
async fn add_follow(
|
||||
async fn apply(
|
||||
&self,
|
||||
follower_id: Uuid,
|
||||
target_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
writes: &[FollowWrite],
|
||||
events: &[DomainEvent],
|
||||
) -> Result<(), DomainError> {
|
||||
self.follows
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((follower_id, target_actor_url.to_string(), status));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_follow_status(
|
||||
&self,
|
||||
follower_id: Uuid,
|
||||
target_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let mut store = self.follows.lock().unwrap();
|
||||
if let Some(entry) = store
|
||||
.iter_mut()
|
||||
.find(|(f, t, _)| *f == follower_id && t == target_actor_url)
|
||||
{
|
||||
entry.2 = status;
|
||||
if self.refuse_writes {
|
||||
return Err(DomainError::InfrastructureError("refused".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_follow(
|
||||
&self,
|
||||
follower_id: Uuid,
|
||||
target_actor_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.follows
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|(f, t, _)| !(*f == follower_id && t == target_actor_url));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_follower(
|
||||
&self,
|
||||
local_user_id: Uuid,
|
||||
follower_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
self.followers.lock().unwrap().push((
|
||||
local_user_id,
|
||||
follower_actor_url.to_string(),
|
||||
status,
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_follower_status(
|
||||
&self,
|
||||
local_user_id: Uuid,
|
||||
follower_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let mut store = self.followers.lock().unwrap();
|
||||
if let Some(entry) = store
|
||||
.iter_mut()
|
||||
.find(|(u, f, _)| *u == local_user_id && f == follower_actor_url)
|
||||
{
|
||||
entry.2 = status;
|
||||
let mut follows = self.follows.lock().unwrap();
|
||||
let mut followers = self.followers.lock().unwrap();
|
||||
for write in writes {
|
||||
match write {
|
||||
FollowWrite::AddFollow {
|
||||
follower_id,
|
||||
target_actor_url,
|
||||
status,
|
||||
} => follows.push((*follower_id, target_actor_url.clone(), *status)),
|
||||
FollowWrite::UpdateFollowStatus {
|
||||
follower_id,
|
||||
target_actor_url,
|
||||
status,
|
||||
} => {
|
||||
if let Some(entry) = follows
|
||||
.iter_mut()
|
||||
.find(|(f, t, _)| f == follower_id && t == target_actor_url)
|
||||
{
|
||||
entry.2 = *status;
|
||||
}
|
||||
}
|
||||
FollowWrite::RemoveFollow {
|
||||
follower_id,
|
||||
target_actor_url,
|
||||
} => follows.retain(|(f, t, _)| !(f == follower_id && t == target_actor_url)),
|
||||
FollowWrite::AddFollower {
|
||||
local_user_id,
|
||||
follower_actor_url,
|
||||
status,
|
||||
} => followers.push((*local_user_id, follower_actor_url.clone(), *status)),
|
||||
FollowWrite::UpdateFollowerStatus {
|
||||
local_user_id,
|
||||
follower_actor_url,
|
||||
status,
|
||||
} => {
|
||||
if let Some(entry) = followers
|
||||
.iter_mut()
|
||||
.find(|(u, f, _)| u == local_user_id && f == follower_actor_url)
|
||||
{
|
||||
entry.2 = *status;
|
||||
}
|
||||
}
|
||||
FollowWrite::RemoveFollowerRecord {
|
||||
local_user_id,
|
||||
follower_actor_url,
|
||||
} => followers.retain(|(u, f, _)| !(u == local_user_id && f == follower_actor_url)),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_follower_record(
|
||||
&self,
|
||||
local_user_id: Uuid,
|
||||
follower_actor_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.followers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|(u, f, _)| !(*u == local_user_id && f == follower_actor_url));
|
||||
self.recorded.lock().unwrap().extend_from_slice(events);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -280,6 +276,7 @@ async fn follow_local_user_writes_both_sides() {
|
||||
svc.follow(
|
||||
&alice_id,
|
||||
&FollowTarget::Handle("@bob@md.example".to_string()),
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -311,6 +308,7 @@ async fn follow_self_is_rejected() {
|
||||
.follow(
|
||||
&alice_id,
|
||||
&FollowTarget::Handle("@alice@md.example".to_string()),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -344,6 +342,7 @@ async fn follow_remote_target_errors_rather_than_silently_succeeding() {
|
||||
.follow(
|
||||
&alice_id,
|
||||
&FollowTarget::Handle("@carol@other.example".to_string()),
|
||||
&[],
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -386,11 +385,12 @@ async fn reject_follow_updates_follower_status_but_removes_the_follow_row() {
|
||||
svc.follow(
|
||||
&requester_id,
|
||||
&FollowTarget::Handle("@owner@md.example".to_string()),
|
||||
&[],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
svc.reject_follow(&owner_id, &SocialIdentity::Local(requester_id.clone()))
|
||||
svc.reject_follow(&owner_id, &SocialIdentity::Local(requester_id.clone()), &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -431,9 +431,60 @@ async fn block_errors_and_get_blocked_is_empty() {
|
||||
actor_url: "https://other.example/users/carol".to_string(),
|
||||
};
|
||||
|
||||
let result = svc.block(&alice_id, &target).await;
|
||||
let result = svc.block(&alice_id, &target, &[]).await;
|
||||
assert!(matches!(result, Err(DomainError::ValidationError(_))));
|
||||
|
||||
let blocked = svc.get_blocked(&alice_id).await.unwrap();
|
||||
assert_eq!(blocked.len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_local_follow_records_both_rows_and_its_event_together() {
|
||||
let user_repo = InMemoryStore::new();
|
||||
let bob_id = register_user(&user_repo, "bob").await;
|
||||
let alice_id = register_user(&user_repo, "alice").await;
|
||||
let follow_store = FollowFakeStore::new();
|
||||
let svc = service(user_repo, &follow_store, instance());
|
||||
let event = DomainEvent::FollowRequested {
|
||||
follower: alice_id.clone(),
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(bob_id.clone())),
|
||||
};
|
||||
|
||||
svc.follow(
|
||||
&alice_id,
|
||||
&FollowTarget::Handle("@bob@md.example".to_string()),
|
||||
std::slice::from_ref(&event),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(svc.get_followers(&bob_id).await.unwrap().len(), 1);
|
||||
assert_eq!(svc.get_following(&alice_id).await.unwrap().len(), 1);
|
||||
assert_eq!(follow_store.recorded_events().len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refused_local_follow_write_propagates_and_records_nothing() {
|
||||
let user_repo = InMemoryStore::new();
|
||||
let bob_id = register_user(&user_repo, "bob").await;
|
||||
let alice_id = register_user(&user_repo, "alice").await;
|
||||
let follow_store = FollowFakeStore::refusing();
|
||||
let svc = service(user_repo, &follow_store, instance());
|
||||
let event = DomainEvent::FollowRequested {
|
||||
follower: alice_id.clone(),
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(bob_id.clone())),
|
||||
};
|
||||
|
||||
let result = svc
|
||||
.follow(
|
||||
&alice_id,
|
||||
&FollowTarget::Handle("@bob@md.example".to_string()),
|
||||
std::slice::from_ref(&event),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(DomainError::InfrastructureError(_))));
|
||||
assert!(svc.get_followers(&bob_id).await.unwrap().is_empty());
|
||||
assert!(svc.get_following(&alice_id).await.unwrap().is_empty());
|
||||
assert!(follow_store.recorded_events().is_empty());
|
||||
}
|
||||
|
||||
@@ -35,11 +35,11 @@ async fn get_page_viewer_reports_pending_follower_count() {
|
||||
let owner_id = UserId::from_uuid(owner_uuid);
|
||||
let target = FollowTarget::Identity(SocialIdentity::Local(owner_id.clone()));
|
||||
b.social_command
|
||||
.follow(&UserId::from_uuid(follower1), &target)
|
||||
.follow(&UserId::from_uuid(follower1), &target, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
b.social_command
|
||||
.follow(&UserId::from_uuid(follower2), &target)
|
||||
.follow(&UserId::from_uuid(follower2), &target, &[])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user