code refactor
Some checks failed
CI / Check / Test (push) Has been cancelled

Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
2026-10-26 09:36:23 +00:00
parent 2ce7db12c2
commit 017b58d6fa
804 changed files with 33423 additions and 28955 deletions

View File

@@ -0,0 +1,737 @@
use std::sync::Arc;
use domain::{
events::DomainEvent,
testing::{InMemoryStore, RefusingSocialCommand},
value_objects::{FollowStatus, FollowTarget, SocialIdentity, UserId},
};
use uuid::Uuid;
use application::social::{
commands::SocialCmd,
deps::{SocialCommandDeps, SocialQueryDeps},
execute::execute_command,
};
fn make_cmd_deps() -> (Arc<InMemoryStore>, SocialCommandDeps) {
let social = InMemoryStore::new();
let deps = SocialCommandDeps {
social_command: Arc::clone(&social) as _,
};
(social, deps)
}
// ── Follow ──────────────────────────────────────────────────────────────────
#[tokio::test]
async fn follow_emits_follow_requested_event() {
let (events, 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 published = events.recorded_events();
assert!(
published
.iter()
.any(|e| matches!(e, DomainEvent::FollowRequested { .. }))
);
}
#[tokio::test]
async fn cannot_follow_yourself() {
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());
}
#[tokio::test]
async fn cannot_follow_same_target_twice() {
let (_social, deps) = make_cmd_deps();
let follower_id = Uuid::new_v4();
let target = FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4())));
execute_command(
&deps,
SocialCmd::Follow {
follower_id,
target: target.clone(),
},
)
.await
.unwrap();
let result = execute_command(
&deps,
SocialCmd::Follow {
follower_id,
target,
},
)
.await;
assert!(result.is_err());
}
// ── Unfollow ────────────────────────────────────────────────────────────────
#[tokio::test]
async fn unfollow_emits_unfollowed_event() {
let (events, deps) = make_cmd_deps();
let follower_id = Uuid::new_v4();
let target = SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4()));
execute_command(
&deps,
SocialCmd::Follow {
follower_id,
target: FollowTarget::Identity(target.clone()),
},
)
.await
.unwrap();
execute_command(
&deps,
SocialCmd::Unfollow {
follower_id,
target,
},
)
.await
.unwrap();
let published = events.recorded_events();
assert!(
published
.iter()
.any(|e| matches!(e, DomainEvent::Unfollowed { .. }))
);
}
// ── Accept ──────────────────────────────────────────────────────────────────
#[tokio::test]
async fn accept_follow_emits_follow_accepted_event() {
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));
execute_command(
&deps,
SocialCmd::Follow {
follower_id,
target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(owner_id))),
},
)
.await
.unwrap();
execute_command(
&deps,
SocialCmd::AcceptFollow {
owner_id,
requester,
},
)
.await
.unwrap();
let published = events.recorded_events();
assert!(
published
.iter()
.any(|e| matches!(e, DomainEvent::FollowAccepted { .. }))
);
}
// ── Reject ──────────────────────────────────────────────────────────────────
#[tokio::test]
async fn reject_follow_emits_follow_rejected_event() {
let (events, deps) = make_cmd_deps();
let follower_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
execute_command(
&deps,
SocialCmd::Follow {
follower_id,
target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(owner_id))),
},
)
.await
.unwrap();
execute_command(
&deps,
SocialCmd::RejectFollow {
owner_id,
requester: SocialIdentity::Local(UserId::from_uuid(follower_id)),
},
)
.await
.unwrap();
let published = events.recorded_events();
assert!(
published
.iter()
.any(|e| matches!(e, DomainEvent::FollowRejected { .. }))
);
}
// ── Remove follower ─────────────────────────────────────────────────────────
#[tokio::test]
async fn remove_follower_emits_follower_removed_event() {
let (events, deps) = make_cmd_deps();
let follower_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
execute_command(
&deps,
SocialCmd::Follow {
follower_id,
target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(owner_id))),
},
)
.await
.unwrap();
execute_command(
&deps,
SocialCmd::AcceptFollow {
owner_id,
requester: SocialIdentity::Local(UserId::from_uuid(follower_id)),
},
)
.await
.unwrap();
execute_command(
&deps,
SocialCmd::RemoveFollower {
owner_id,
follower: SocialIdentity::Local(UserId::from_uuid(follower_id)),
},
)
.await
.unwrap();
let published = events.recorded_events();
assert!(
published
.iter()
.any(|e| matches!(e, DomainEvent::FollowerRemoved { .. }))
);
}
// ── Block ───────────────────────────────────────────────────────────────────
#[tokio::test]
async fn block_emits_actor_blocked_event() {
let (events, deps) = make_cmd_deps();
execute_command(
&deps,
SocialCmd::Block {
blocker_id: Uuid::new_v4(),
target: SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4())),
},
)
.await
.unwrap();
let published = events.recorded_events();
assert!(
published
.iter()
.any(|e| matches!(e, DomainEvent::ActorBlocked { .. }))
);
}
// ── Unblock ─────────────────────────────────────────────────────────────────
#[tokio::test]
async fn unblock_emits_actor_unblocked_event() {
let (events, deps) = make_cmd_deps();
let target = SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4()));
let blocker_id = Uuid::new_v4();
execute_command(
&deps,
SocialCmd::Block {
blocker_id,
target: target.clone(),
},
)
.await
.unwrap();
execute_command(&deps, SocialCmd::Unblock { blocker_id, target })
.await
.unwrap();
let published = events.recorded_events();
assert!(
published
.iter()
.any(|e| matches!(e, DomainEvent::ActorUnblocked { .. }))
);
}
// ── Get following ───────────────────────────────────────────────────────────
#[tokio::test]
async fn get_following_returns_accepted_targets() {
let (social, cmd_deps) = make_cmd_deps();
let query_deps = SocialQueryDeps {
follow_graph: Arc::clone(&social) as _,
block_query: Arc::clone(&social) as _,
};
let follower = Uuid::new_v4();
let target = UserId::from_uuid(Uuid::new_v4());
execute_command(
&cmd_deps,
SocialCmd::Follow {
follower_id: follower,
target: FollowTarget::Identity(SocialIdentity::Local(target.clone())),
},
)
.await
.unwrap();
let before = application::social::get_following::execute(&query_deps, follower)
.await
.unwrap();
assert!(
before.is_empty(),
"a pending follow must not appear in get_following"
);
execute_command(
&cmd_deps,
SocialCmd::AcceptFollow {
owner_id: target.value(),
requester: SocialIdentity::Local(UserId::from_uuid(follower)),
},
)
.await
.unwrap();
let actors = application::social::get_following::execute(&query_deps, follower)
.await
.unwrap();
assert_eq!(actors.len(), 1);
assert_eq!(actors[0].identity, SocialIdentity::Local(target));
}
// ── Get followers ───────────────────────────────────────────────────────────
#[tokio::test]
async fn returns_accepted_followers() {
let social = InMemoryStore::new();
let cmd_deps = SocialCommandDeps {
social_command: Arc::clone(&social) as _,
};
let query_deps = SocialQueryDeps {
follow_graph: Arc::clone(&social) as _,
block_query: Arc::clone(&social) as _,
};
let follower_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
execute_command(
&cmd_deps,
SocialCmd::Follow {
follower_id,
target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(owner_id))),
},
)
.await
.unwrap();
execute_command(
&cmd_deps,
SocialCmd::AcceptFollow {
owner_id,
requester: SocialIdentity::Local(UserId::from_uuid(follower_id)),
},
)
.await
.unwrap();
let followers = application::social::get_followers::execute(&query_deps, owner_id)
.await
.unwrap();
assert_eq!(followers.len(), 1);
}
// ── Get pending ─────────────────────────────────────────────────────────────
#[tokio::test]
async fn returns_only_pending_followers() {
let social = InMemoryStore::new();
let cmd_deps = SocialCommandDeps {
social_command: Arc::clone(&social) as _,
};
let query_deps = SocialQueryDeps {
follow_graph: Arc::clone(&social) as _,
block_query: Arc::clone(&social) as _,
};
let follower_id = Uuid::new_v4();
let owner_id = Uuid::new_v4();
execute_command(
&cmd_deps,
SocialCmd::Follow {
follower_id,
target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(owner_id))),
},
)
.await
.unwrap();
let pending = application::social::get_pending_followers::execute(&query_deps, owner_id)
.await
.unwrap();
assert_eq!(pending.len(), 1);
}
// ── get_relation ────────────────────────────────────────────────────────────
#[tokio::test]
async fn get_relation_reports_none_for_strangers() {
use domain::ports::FollowGraphQuery;
let (social, _deps) = make_cmd_deps();
let rel = FollowGraphQuery::get_relation(
&*social,
&UserId::from_uuid(Uuid::new_v4()),
&SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4())),
)
.await
.unwrap();
assert_eq!(rel.following, None);
assert_eq!(rel.followed_by, None);
}
#[tokio::test]
async fn get_relation_reports_pending_before_acceptance() {
use domain::ports::FollowGraphQuery;
let (social, deps) = make_cmd_deps();
let a = Uuid::new_v4();
let b_id = UserId::from_uuid(Uuid::new_v4());
execute_command(
&deps,
SocialCmd::Follow {
follower_id: a,
target: FollowTarget::Identity(SocialIdentity::Local(b_id.clone())),
},
)
.await
.unwrap();
let rel = FollowGraphQuery::get_relation(
&*social,
&UserId::from_uuid(a),
&SocialIdentity::Local(b_id),
)
.await
.unwrap();
assert_eq!(rel.following, Some(FollowStatus::Pending));
assert_eq!(rel.followed_by, None);
}
#[tokio::test]
async fn get_relation_reports_accepted_in_both_directions_after_mutual_follow() {
use domain::ports::FollowGraphQuery;
let (social, deps) = make_cmd_deps();
let a = Uuid::new_v4();
let b = Uuid::new_v4();
let a_id = UserId::from_uuid(a);
let b_id = UserId::from_uuid(b);
for (from, to) in [(a, b_id.clone()), (b, a_id.clone())] {
execute_command(
&deps,
SocialCmd::Follow {
follower_id: from,
target: FollowTarget::Identity(SocialIdentity::Local(to.clone())),
},
)
.await
.unwrap();
let owner = if from == a { b } else { a };
execute_command(
&deps,
SocialCmd::AcceptFollow {
owner_id: owner,
requester: SocialIdentity::Local(UserId::from_uuid(from)),
},
)
.await
.unwrap();
}
let rel = FollowGraphQuery::get_relation(&*social, &a_id, &SocialIdentity::Local(b_id))
.await
.unwrap();
assert_eq!(rel.following, Some(FollowStatus::Accepted));
assert_eq!(rel.followed_by, Some(FollowStatus::Accepted));
}
// ── get_pending_following ───────────────────────────────────────────────────
#[tokio::test]
async fn unaccepted_follow_appears_in_pending_following_not_in_following() {
use domain::ports::FollowGraphQuery;
let (social, deps) = make_cmd_deps();
let viewer = Uuid::new_v4();
let target = UserId::from_uuid(Uuid::new_v4());
execute_command(
&deps,
SocialCmd::Follow {
follower_id: viewer,
target: FollowTarget::Identity(SocialIdentity::Local(target.clone())),
},
)
.await
.unwrap();
let pending = FollowGraphQuery::get_pending_following(&*social, &UserId::from_uuid(viewer))
.await
.unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].identity, SocialIdentity::Local(target.clone()));
let following = FollowGraphQuery::get_following(&*social, &UserId::from_uuid(viewer))
.await
.unwrap();
assert!(
following.is_empty(),
"an unaccepted follow must not appear in get_following"
);
}
// ── get_pending_following use case ──────────────────────────────────────────
#[tokio::test]
async fn get_pending_following_returns_unaccepted_targets_and_get_following_does_not() {
let (social, cmd_deps) = make_cmd_deps();
let query_deps = SocialQueryDeps {
follow_graph: Arc::clone(&social) as _,
block_query: Arc::clone(&social) as _,
};
let follower = Uuid::new_v4();
let target = UserId::from_uuid(Uuid::new_v4());
execute_command(
&cmd_deps,
SocialCmd::Follow {
follower_id: follower,
target: FollowTarget::Identity(SocialIdentity::Local(target.clone())),
},
)
.await
.unwrap();
// deliberately NOT accepted
let pending = application::social::get_pending_following::execute(&query_deps, follower)
.await
.unwrap();
let accepted = application::social::get_following::execute(&query_deps, follower)
.await
.unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].identity, SocialIdentity::Local(target));
assert!(
accepted.is_empty(),
"a pending follow must not leak into get_following — the privacy invariant"
);
}
// ── count_pending_followers use case ────────────────────────────────────────
#[tokio::test]
async fn count_pending_followers_drops_as_requests_are_accepted() {
let (social, cmd_deps) = make_cmd_deps();
let query_deps = SocialQueryDeps {
follow_graph: Arc::clone(&social) as _,
block_query: Arc::clone(&social) as _,
};
let owner = UserId::from_uuid(Uuid::new_v4());
let a = Uuid::new_v4();
let b = Uuid::new_v4();
for follower in [a, b] {
execute_command(
&cmd_deps,
SocialCmd::Follow {
follower_id: follower,
target: FollowTarget::Identity(SocialIdentity::Local(owner.clone())),
},
)
.await
.unwrap();
}
let before = application::social::count_pending_followers::execute(&query_deps, owner.value())
.await
.unwrap();
assert_eq!(before, 2);
execute_command(
&cmd_deps,
SocialCmd::AcceptFollow {
owner_id: owner.value(),
requester: SocialIdentity::Local(UserId::from_uuid(a)),
},
)
.await
.unwrap();
let after = application::social::count_pending_followers::execute(&query_deps, owner.value())
.await
.unwrap();
assert_eq!(
after, 1,
"accepting a request must remove it from the pending count"
);
}
// ── get_relation use case ───────────────────────────────────────────────────
#[tokio::test]
async fn get_relation_use_case_reports_direction_asymmetrically() {
let (social, cmd_deps) = make_cmd_deps();
let query_deps = SocialQueryDeps {
follow_graph: Arc::clone(&social) as _,
block_query: Arc::clone(&social) as _,
};
let viewer = Uuid::new_v4();
let target = UserId::from_uuid(Uuid::new_v4());
execute_command(
&cmd_deps,
SocialCmd::Follow {
follower_id: viewer,
target: FollowTarget::Identity(SocialIdentity::Local(target.clone())),
},
)
.await
.unwrap();
let rel = application::social::get_relation::execute(
&query_deps,
viewer,
SocialIdentity::Local(target),
)
.await
.unwrap();
assert_eq!(
rel.following,
Some(FollowStatus::Pending),
"viewer -> target"
);
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"
));
}

View File

@@ -0,0 +1,490 @@
use domain::testing::test_user;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{
BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, FollowWrite, SocialCommand,
UserRepository,
},
testing::InMemoryStore,
value_objects::{
FollowRelation, FollowStatus, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity,
UserId,
},
};
use uuid::Uuid;
use application::social::local_service::LocalSocialService;
// ── Fakes ────────────────────────────────────────────────────────────────────
//
// No fake exists anywhere for FollowCommand/FollowQuery. `InMemoryStore`
// (domain::testing) implements SocialCommand/FollowGraphQuery/BlockQuery — the same
// level as LocalSocialService itself — so it cannot stand in as its dependency.
// A single struct backs both ports, mirroring how one database table serves both
// sides of the follow edge in production.
struct FollowFakeStore {
// (follower_id, target_actor_url, status) — written by add_follow/update_follow_status/remove_follow
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 {
fn new() -> Arc<Self> {
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 {
SocialActor {
identity: SocialIdentity::Remote {
actor_url: url.to_string(),
},
handle: url.to_string(),
display_name: None,
avatar_url: None,
}
}
#[async_trait]
impl FollowCommand for FollowFakeStore {
async fn apply(
&self,
writes: &[FollowWrite],
events: &[DomainEvent],
) -> Result<(), DomainError> {
if self.refuse_writes {
return Err(DomainError::InfrastructureError("refused".into()));
}
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)),
}
}
self.recorded.lock().unwrap().extend_from_slice(events);
Ok(())
}
}
#[async_trait]
impl FollowQuery for FollowFakeStore {
async fn get_following(&self, user_id: Uuid) -> Result<Vec<SocialActor>, DomainError> {
Ok(self
.follows
.lock()
.unwrap()
.iter()
.filter(|(f, _, _)| *f == user_id)
.map(|(_, t, _)| fake_actor(t))
.collect())
}
async fn get_followers(&self, user_id: Uuid) -> Result<Vec<SocialActor>, DomainError> {
Ok(self
.followers
.lock()
.unwrap()
.iter()
.filter(|(u, _, _)| *u == user_id)
.map(|(_, f, _)| fake_actor(f))
.collect())
}
async fn get_pending_followers(&self, user_id: Uuid) -> Result<Vec<SocialActor>, DomainError> {
Ok(self
.followers
.lock()
.unwrap()
.iter()
.filter(|(u, _, s)| *u == user_id && *s == FollowStatus::Pending)
.map(|(_, f, _)| fake_actor(f))
.collect())
}
async fn get_pending_following(&self, user_id: Uuid) -> Result<Vec<SocialActor>, DomainError> {
Ok(self
.follows
.lock()
.unwrap()
.iter()
.filter(|(f, _, s)| *f == user_id && *s == FollowStatus::Pending)
.map(|(_, t, _)| fake_actor(t))
.collect())
}
async fn count_following(&self, user_id: Uuid) -> Result<usize, DomainError> {
Ok(self
.follows
.lock()
.unwrap()
.iter()
.filter(|(f, _, _)| *f == user_id)
.count())
}
async fn count_followers(&self, user_id: Uuid) -> Result<usize, DomainError> {
Ok(self
.followers
.lock()
.unwrap()
.iter()
.filter(|(u, _, _)| *u == user_id)
.count())
}
async fn count_pending_followers(&self, user_id: Uuid) -> Result<usize, DomainError> {
Ok(self
.followers
.lock()
.unwrap()
.iter()
.filter(|(u, _, s)| *u == user_id && *s == FollowStatus::Pending)
.count())
}
async fn get_relation(
&self,
viewer_id: Uuid,
target_actor_url: &str,
) -> Result<FollowRelation, DomainError> {
let following = self
.follows
.lock()
.unwrap()
.iter()
.find(|(f, t, _)| *f == viewer_id && t == target_actor_url)
.map(|(_, _, s)| *s);
let followed_by = self
.followers
.lock()
.unwrap()
.iter()
.find(|(u, f, _)| *u == viewer_id && f == target_actor_url)
.map(|(_, _, s)| *s);
Ok(FollowRelation {
following,
followed_by,
})
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
fn instance() -> InstanceIdentity {
InstanceIdentity::new("http://md.example")
}
async fn register_user(repo: &Arc<InMemoryStore>, username: &str) -> UserId {
let user = test_user(username);
let id = user.id().clone();
repo.save(&user).await.unwrap();
id
}
fn service(
user_repo: Arc<InMemoryStore>,
follow_store: &Arc<FollowFakeStore>,
instance: InstanceIdentity,
) -> LocalSocialService {
LocalSocialService::new(
user_repo as Arc<dyn UserRepository>,
follow_store.clone() as Arc<dyn FollowCommand>,
follow_store.clone() as Arc<dyn FollowQuery>,
instance,
)
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn follow_local_user_writes_both_sides() {
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 instance = instance();
let svc = service(user_repo, &follow_store, instance.clone());
svc.follow(
&alice_id,
&FollowTarget::Handle("@bob@md.example".to_string()),
&[],
)
.await
.unwrap();
let bob_url = instance.actor_url_for(&bob_id);
let alice_url = instance.actor_url_for(&alice_id);
let followers = svc.get_followers(&bob_id).await.unwrap();
assert!(
followers.iter().any(|a| a.handle == alice_url),
"add_follower should have recorded alice as bob's follower"
);
let following = svc.get_following(&alice_id).await.unwrap();
assert!(
following.iter().any(|a| a.handle == bob_url),
"add_follow should have recorded bob in alice's following list"
);
}
#[tokio::test]
async fn follow_self_is_rejected() {
let user_repo = InMemoryStore::new();
let alice_id = register_user(&user_repo, "alice").await;
let follow_store = FollowFakeStore::new();
let svc = service(user_repo, &follow_store, instance());
let result = svc
.follow(
&alice_id,
&FollowTarget::Handle("@alice@md.example".to_string()),
&[],
)
.await;
match result {
Err(DomainError::ValidationError(msg)) => assert_eq!(
msg, "Cannot follow yourself",
"the self-follow guard must not collapse into the generic remote-target error"
),
other => panic!("expected ValidationError, got {other:?}"),
}
assert_eq!(
follow_store.follows.lock().unwrap().len(),
0,
"self-follow must write nothing to the follows side"
);
assert_eq!(
follow_store.followers.lock().unwrap().len(),
0,
"self-follow must write nothing to the followers side"
);
}
#[tokio::test]
async fn follow_remote_target_errors_rather_than_silently_succeeding() {
let user_repo = InMemoryStore::new();
let alice_id = register_user(&user_repo, "alice").await;
let follow_store = FollowFakeStore::new();
let svc = service(user_repo, &follow_store, instance());
let result = svc
.follow(
&alice_id,
&FollowTarget::Handle("@carol@other.example".to_string()),
&[],
)
.await;
match result {
Err(DomainError::ValidationError(msg)) => {
assert!(
msg.contains("@carol@other.example"),
"the remote-target error must name the specific handle that failed, \
not a generic message: {msg}"
);
assert!(
msg != "Cannot follow yourself",
"the remote-target error must not collapse into the self-follow message"
);
}
other => panic!("expected ValidationError, got {other:?}"),
}
assert_eq!(
follow_store.follows.lock().unwrap().len(),
0,
"a remote target must write nothing — this replaces NoopSocialCommand's silent Ok(())"
);
assert_eq!(follow_store.followers.lock().unwrap().len(), 0);
}
#[tokio::test]
async fn reject_follow_updates_follower_status_but_removes_the_follow_row() {
// Asymmetry with accept_follow, which updates the follow row's status instead
// of removing it: reject_follow marks the follower row Rejected but deletes
// the follow row outright. A test that only checked the follower row would
// pass against a reject_follow "corrected" into accept_follow's symmetry —
// the second assertion is the one that carries the weight.
let user_repo = InMemoryStore::new();
let owner_id = register_user(&user_repo, "owner").await;
let requester_id = register_user(&user_repo, "requester").await;
let follow_store = FollowFakeStore::new();
let instance = instance();
let svc = service(user_repo, &follow_store, instance.clone());
svc.follow(
&requester_id,
&FollowTarget::Handle("@owner@md.example".to_string()),
&[],
)
.await
.unwrap();
svc.reject_follow(&owner_id, &SocialIdentity::Local(requester_id.clone()), &[])
.await
.unwrap();
let requester_url = instance.actor_url_for(&requester_id);
let owner_url = instance.actor_url_for(&owner_id);
{
let followers = follow_store.followers.lock().unwrap();
let follower_row = followers
.iter()
.find(|(u, f, _)| *u == owner_id.value() && f == &requester_url)
.expect("the follower row must still exist after rejection");
assert_eq!(
follower_row.2,
FollowStatus::Rejected,
"reject_follow must mark the follower row Rejected"
);
}
let follows = follow_store.follows.lock().unwrap();
assert!(
!follows
.iter()
.any(|(f, t, _)| *f == requester_id.value() && t == &owner_url),
"reject_follow must remove the follow row outright, not merely mark it Rejected — \
this is the deliberate asymmetry with accept_follow"
);
}
#[tokio::test]
async fn block_errors_and_get_blocked_is_empty() {
let user_repo = InMemoryStore::new();
let alice_id = register_user(&user_repo, "alice").await;
let follow_store = FollowFakeStore::new();
let svc = service(user_repo, &follow_store, instance());
let target = SocialIdentity::Remote {
actor_url: "https://other.example/users/carol".to_string(),
};
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());
}