From d60c47199c3320ca9d4b08b41181ed5da9b53bb6 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 10 Jul 2026 16:18:57 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20FollowTarget=20enum=20=E2=80=94=20actor?= =?UTF-8?q?=5Furl=20no=20longer=20lies=20about=20holding=20a=20handle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FollowCommand.target is now FollowTarget (Identity|Handle) instead of SocialIdentity. actor_url field always holds a URL; handles go through FollowTarget::Handle. Adapter resolves handles explicitly. Type system prevents misuse in future commands. --- .../activitypub/src/social_adapter.rs | 11 +++++---- crates/adapters/event-payload/src/lib.rs | 23 ++++++++++++++++-- crates/application/src/social/commands.rs | 4 ++-- crates/application/src/social/tests/accept.rs | 4 ++-- crates/application/src/social/tests/follow.rs | 8 +++---- .../src/social/tests/get_followers.rs | 4 ++-- .../src/social/tests/get_following.rs | 4 ++-- .../src/social/tests/get_pending.rs | 4 ++-- crates/application/src/social/tests/reject.rs | 4 ++-- .../src/social/tests/remove_follower.rs | 4 ++-- .../application/src/social/tests/unfollow.rs | 4 ++-- crates/domain/src/events.rs | 2 +- crates/domain/src/ports/noop.rs | 2 +- crates/domain/src/ports/social.rs | 4 ++-- crates/domain/src/testing/in_memory.rs | 18 ++++++++++---- crates/domain/src/tests/events.rs | 24 +++++++++++++++---- crates/domain/src/value_objects/social.rs | 6 +++++ crates/presentation/src/handlers/social.rs | 10 +++----- 18 files changed, 95 insertions(+), 45 deletions(-) diff --git a/crates/adapters/activitypub/src/social_adapter.rs b/crates/adapters/activitypub/src/social_adapter.rs index e1e1393..157fb27 100644 --- a/crates/adapters/activitypub/src/social_adapter.rs +++ b/crates/adapters/activitypub/src/social_adapter.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use domain::{ errors::DomainError, ports::{SocialCommand, SocialQuery, UserRepository}, - value_objects::{SocialActor, SocialIdentity, UserId}, + value_objects::{FollowTarget, SocialActor, SocialIdentity, UserId}, }; use k_ap::RemoteActor; @@ -87,15 +87,18 @@ fn ap_err(e: anyhow::Error) -> DomainError { #[async_trait] impl SocialCommand for CompositeSocialAdapter { - async fn follow(&self, follower: &UserId, target: &SocialIdentity) -> Result<(), DomainError> { - if let SocialIdentity::Local(target_id) = target + async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError> { + if let FollowTarget::Identity(SocialIdentity::Local(target_id)) = target && follower == target_id { return Err(DomainError::ValidationError( "Cannot follow yourself".into(), )); } - let handle = self.resolve_handle(target).await?; + let handle = match target { + FollowTarget::Handle(h) => h.clone(), + FollowTarget::Identity(id) => self.resolve_handle(id).await?, + }; self.ap_service .follow(follower.value(), &handle) .await diff --git a/crates/adapters/event-payload/src/lib.rs b/crates/adapters/event-payload/src/lib.rs index b1fd707..bc44702 100644 --- a/crates/adapters/event-payload/src/lib.rs +++ b/crates/adapters/event-payload/src/lib.rs @@ -196,6 +196,13 @@ fn identity_to_payload(id: &SocialIdentity) -> (String, String) { } } +fn follow_target_to_payload(target: &domain::value_objects::FollowTarget) -> (String, String) { + match target { + domain::value_objects::FollowTarget::Identity(id) => identity_to_payload(id), + domain::value_objects::FollowTarget::Handle(h) => ("handle".into(), h.clone()), + } +} + fn payload_to_identity(kind: &str, id: String) -> Result { match kind { "local" => Ok(SocialIdentity::Local(UserId::from_uuid(parse_uuid( @@ -208,6 +215,18 @@ fn payload_to_identity(kind: &str, id: String) -> Result Result { + match kind { + "handle" => Ok(domain::value_objects::FollowTarget::Handle(id)), + other => Ok(domain::value_objects::FollowTarget::Identity( + payload_to_identity(other, id)?, + )), + } +} + fn parse_ts(ts: i64) -> Result { chrono::DateTime::from_timestamp(ts, 0) .map(|dt| dt.naive_utc()) @@ -294,7 +313,7 @@ impl From<&DomainEvent> for EventPayload { } } DomainEvent::FollowRequested { follower, target } => { - let (kind, id) = identity_to_payload(target); + let (kind, id) = follow_target_to_payload(target); EventPayload::FollowRequested { follower_id: follower.value().to_string(), target_kind: kind, @@ -530,7 +549,7 @@ impl TryFrom for DomainEvent { target_id, } => Ok(DomainEvent::FollowRequested { follower: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?), - target: payload_to_identity(&target_kind, target_id)?, + target: payload_to_follow_target(&target_kind, target_id)?, }), EventPayload::FollowAccepted { owner_id, diff --git a/crates/application/src/social/commands.rs b/crates/application/src/social/commands.rs index 7c9ca74..6ea662d 100644 --- a/crates/application/src/social/commands.rs +++ b/crates/application/src/social/commands.rs @@ -1,9 +1,9 @@ -use domain::value_objects::SocialIdentity; +use domain::value_objects::{FollowTarget, SocialIdentity}; use uuid::Uuid; pub struct FollowCommand { pub follower_id: Uuid, - pub target: SocialIdentity, + pub target: FollowTarget, } pub struct UnfollowCommand { diff --git a/crates/application/src/social/tests/accept.rs b/crates/application/src/social/tests/accept.rs index f0ece65..1281879 100644 --- a/crates/application/src/social/tests/accept.rs +++ b/crates/application/src/social/tests/accept.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use domain::{ events::DomainEvent, testing::{InMemorySocialRepository, NoopEventPublisher}, - value_objects::{SocialIdentity, UserId}, + value_objects::{FollowTarget, SocialIdentity, UserId}, }; use uuid::Uuid; @@ -40,7 +40,7 @@ async fn accept_follow_emits_follow_accepted_event() { &deps, FollowCommand { follower_id, - target: SocialIdentity::Local(UserId::from_uuid(owner_id)), + target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(owner_id))), }, ) .await diff --git a/crates/application/src/social/tests/follow.rs b/crates/application/src/social/tests/follow.rs index 97e8dc5..67fefd8 100644 --- a/crates/application/src/social/tests/follow.rs +++ b/crates/application/src/social/tests/follow.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use domain::{ events::DomainEvent, testing::{InMemorySocialRepository, NoopEventPublisher}, - value_objects::{SocialIdentity, UserId}, + value_objects::{FollowTarget, SocialIdentity, UserId}, }; use uuid::Uuid; @@ -32,7 +32,7 @@ async fn follow_emits_follow_requested_event() { &deps, FollowCommand { follower_id: Uuid::new_v4(), - target: SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4())), + target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4()))), }, ) .await @@ -55,7 +55,7 @@ async fn cannot_follow_yourself() { &deps, FollowCommand { follower_id: user_id, - target: SocialIdentity::Local(UserId::from_uuid(user_id)), + target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(user_id))), }, ) .await; @@ -67,7 +67,7 @@ async fn cannot_follow_yourself() { async fn cannot_follow_same_target_twice() { let (_social, _events, deps) = make_deps(); let follower_id = Uuid::new_v4(); - let target = SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4())); + let target = FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4()))); follow::execute( &deps, diff --git a/crates/application/src/social/tests/get_followers.rs b/crates/application/src/social/tests/get_followers.rs index d9c5d39..3ad1fed 100644 --- a/crates/application/src/social/tests/get_followers.rs +++ b/crates/application/src/social/tests/get_followers.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use domain::{ testing::{InMemorySocialRepository, NoopEventPublisher}, - value_objects::{SocialIdentity, UserId}, + value_objects::{FollowTarget, SocialIdentity, UserId}, }; use uuid::Uuid; @@ -34,7 +34,7 @@ async fn returns_accepted_followers() { &cmd_deps, FollowCommand { follower_id, - target: SocialIdentity::Local(UserId::from_uuid(owner_id)), + target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(owner_id))), }, ) .await diff --git a/crates/application/src/social/tests/get_following.rs b/crates/application/src/social/tests/get_following.rs index 673cb80..1de05c1 100644 --- a/crates/application/src/social/tests/get_following.rs +++ b/crates/application/src/social/tests/get_following.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use domain::{ testing::{InMemorySocialRepository, NoopEventPublisher}, - value_objects::{SocialIdentity, UserId}, + value_objects::{FollowTarget, SocialIdentity, UserId}, }; use uuid::Uuid; @@ -34,7 +34,7 @@ async fn returns_accepted_follows() { &cmd_deps, FollowCommand { follower_id, - target: SocialIdentity::Local(UserId::from_uuid(target_id)), + target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(target_id))), }, ) .await diff --git a/crates/application/src/social/tests/get_pending.rs b/crates/application/src/social/tests/get_pending.rs index 4db770d..2358169 100644 --- a/crates/application/src/social/tests/get_pending.rs +++ b/crates/application/src/social/tests/get_pending.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use domain::{ testing::{InMemorySocialRepository, NoopEventPublisher}, - value_objects::{SocialIdentity, UserId}, + value_objects::{FollowTarget, SocialIdentity, UserId}, }; use uuid::Uuid; @@ -33,7 +33,7 @@ async fn returns_only_pending_followers() { &cmd_deps, FollowCommand { follower_id, - target: SocialIdentity::Local(UserId::from_uuid(owner_id)), + target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(owner_id))), }, ) .await diff --git a/crates/application/src/social/tests/reject.rs b/crates/application/src/social/tests/reject.rs index b33b886..6c4d261 100644 --- a/crates/application/src/social/tests/reject.rs +++ b/crates/application/src/social/tests/reject.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use domain::{ testing::{InMemorySocialRepository, NoopEventPublisher}, - value_objects::{SocialIdentity, UserId}, + value_objects::{FollowTarget, SocialIdentity, UserId}, }; use uuid::Uuid; @@ -37,7 +37,7 @@ async fn reject_follow_completes_without_error() { &deps, FollowCommand { follower_id, - target: SocialIdentity::Local(UserId::from_uuid(owner_id)), + target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(owner_id))), }, ) .await diff --git a/crates/application/src/social/tests/remove_follower.rs b/crates/application/src/social/tests/remove_follower.rs index 60d48f1..97aa823 100644 --- a/crates/application/src/social/tests/remove_follower.rs +++ b/crates/application/src/social/tests/remove_follower.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use domain::{ events::DomainEvent, testing::{InMemorySocialRepository, NoopEventPublisher}, - value_objects::{SocialIdentity, UserId}, + value_objects::{FollowTarget, SocialIdentity, UserId}, }; use uuid::Uuid; @@ -39,7 +39,7 @@ async fn remove_follower_emits_follower_removed_event() { &deps, FollowCommand { follower_id, - target: SocialIdentity::Local(UserId::from_uuid(owner_id)), + target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(owner_id))), }, ) .await diff --git a/crates/application/src/social/tests/unfollow.rs b/crates/application/src/social/tests/unfollow.rs index 9cd44f3..2f7f26a 100644 --- a/crates/application/src/social/tests/unfollow.rs +++ b/crates/application/src/social/tests/unfollow.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use domain::{ events::DomainEvent, testing::{InMemorySocialRepository, NoopEventPublisher}, - value_objects::{SocialIdentity, UserId}, + value_objects::{FollowTarget, SocialIdentity, UserId}, }; use uuid::Uuid; @@ -38,7 +38,7 @@ async fn unfollow_emits_unfollowed_event() { &deps, FollowCommand { follower_id, - target: target.clone(), + target: FollowTarget::Identity(target.clone()), }, ) .await diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 103f764..e828460 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -65,7 +65,7 @@ pub enum DomainEvent { }, FollowRequested { follower: UserId, - target: crate::value_objects::SocialIdentity, + target: crate::value_objects::FollowTarget, }, FollowAccepted { owner: UserId, diff --git a/crates/domain/src/ports/noop.rs b/crates/domain/src/ports/noop.rs index a18a51e..ca0f202 100644 --- a/crates/domain/src/ports/noop.rs +++ b/crates/domain/src/ports/noop.rs @@ -41,7 +41,7 @@ pub struct NoopSocialCommand; #[async_trait] impl super::SocialCommand for NoopSocialCommand { - async fn follow(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> { + async fn follow(&self, _: &UserId, _: &crate::value_objects::FollowTarget) -> Result<(), DomainError> { Ok(()) } async fn unfollow(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> { diff --git a/crates/domain/src/ports/social.rs b/crates/domain/src/ports/social.rs index 2edfd2d..e5ee595 100644 --- a/crates/domain/src/ports/social.rs +++ b/crates/domain/src/ports/social.rs @@ -7,14 +7,14 @@ use crate::{ DiaryEntry, FederationFlags, RemoteActorInfo, RemoteGoalEntry, RemoteWatchlistEntry, WatchlistWithMovie, }, - value_objects::{MovieId, SocialActor, SocialIdentity, UserId}, + value_objects::{FollowTarget, MovieId, SocialActor, SocialIdentity, UserId}, }; // ── Unified social ports (ADR-0002) ───────────────────────────────────────── #[async_trait] pub trait SocialCommand: Send + Sync { - async fn follow(&self, follower: &UserId, target: &SocialIdentity) -> Result<(), DomainError>; + async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError>; async fn unfollow(&self, follower: &UserId, target: &SocialIdentity) -> Result<(), DomainError>; diff --git a/crates/domain/src/testing/in_memory.rs b/crates/domain/src/testing/in_memory.rs index 51097c9..1933771 100644 --- a/crates/domain/src/testing/in_memory.rs +++ b/crates/domain/src/testing/in_memory.rs @@ -893,8 +893,18 @@ impl InMemorySocialRepository { #[async_trait] impl SocialCommand for InMemorySocialRepository { - async fn follow(&self, follower: &UserId, target: &SocialIdentity) -> Result<(), DomainError> { - if let SocialIdentity::Local(target_id) = target { + async fn follow( + &self, + follower: &UserId, + target: &crate::value_objects::FollowTarget, + ) -> Result<(), DomainError> { + let identity = match target { + crate::value_objects::FollowTarget::Identity(id) => id.clone(), + crate::value_objects::FollowTarget::Handle(h) => SocialIdentity::Remote { + actor_url: h.clone(), + }, + }; + if let SocialIdentity::Local(target_id) = &identity { if follower == target_id { return Err(DomainError::ValidationError( "Cannot follow yourself".into(), @@ -904,11 +914,11 @@ impl SocialCommand for InMemorySocialRepository { let mut store = self.follows.lock().unwrap(); let already = store .iter() - .any(|(f, t, _)| *f == follower.value() && t == target); + .any(|(f, t, _)| *f == follower.value() && *t == identity); if already { return Err(DomainError::ValidationError("Already following".into())); } - store.push((follower.value(), target.clone(), FollowState::Pending)); + store.push((follower.value(), identity, FollowState::Pending)); Ok(()) } diff --git a/crates/domain/src/tests/events.rs b/crates/domain/src/tests/events.rs index a684aca..57b618e 100644 --- a/crates/domain/src/tests/events.rs +++ b/crates/domain/src/tests/events.rs @@ -1,5 +1,5 @@ use super::*; -use crate::value_objects::{SocialIdentity, UserId}; +use crate::value_objects::{FollowTarget, SocialIdentity, UserId}; #[test] fn follow_accepted_matches() { @@ -22,17 +22,33 @@ fn follow_accepted_matches() { } #[test] -fn follow_requested_local() { +fn follow_requested_with_identity() { let follower = UserId::from_uuid(uuid::Uuid::new_v4()); let target = UserId::from_uuid(uuid::Uuid::new_v4()); let event = DomainEvent::FollowRequested { follower: follower.clone(), - target: SocialIdentity::Local(target.clone()), + target: FollowTarget::Identity(SocialIdentity::Local(target.clone())), }; assert!(matches!( event, DomainEvent::FollowRequested { - target: SocialIdentity::Local(_), + target: FollowTarget::Identity(SocialIdentity::Local(_)), + .. + } + )); +} + +#[test] +fn follow_requested_with_handle() { + let follower = UserId::from_uuid(uuid::Uuid::new_v4()); + let event = DomainEvent::FollowRequested { + follower: follower.clone(), + target: FollowTarget::Handle("@alice@remote.example".into()), + }; + assert!(matches!( + event, + DomainEvent::FollowRequested { + target: FollowTarget::Handle(_), .. } )); diff --git a/crates/domain/src/value_objects/social.rs b/crates/domain/src/value_objects/social.rs index a9088e0..8c874d1 100644 --- a/crates/domain/src/value_objects/social.rs +++ b/crates/domain/src/value_objects/social.rs @@ -16,6 +16,12 @@ impl SocialIdentity { } } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FollowTarget { + Identity(SocialIdentity), + Handle(String), +} + #[derive(Clone, Debug)] pub struct SocialActor { pub identity: SocialIdentity, diff --git a/crates/presentation/src/handlers/social.rs b/crates/presentation/src/handlers/social.rs index 7c1b496..f730ae5 100644 --- a/crates/presentation/src/handlers/social.rs +++ b/crates/presentation/src/handlers/social.rs @@ -22,7 +22,7 @@ use api_types::{ BlockedDomainResponse, FollowRequest, RemoteActorDto, }; use application::social::deps::{SocialCommandDeps, SocialQueryDeps}; -use domain::value_objects::{SocialActor, SocialIdentity}; +use domain::value_objects::{FollowTarget, SocialActor, SocialIdentity}; use template_askama::{ BlockedActorsTemplate, BlockedDomainsTemplate, FollowersTemplate, FollowingTemplate, RemoteActorData, @@ -359,9 +359,7 @@ pub async fn follow( &deps, application::social::commands::FollowCommand { follower_id: user.0.value(), - target: SocialIdentity::Remote { - actor_url: body.handle, - }, + target: FollowTarget::Handle(body.handle), }, ) .await?; @@ -554,9 +552,7 @@ pub async fn follow_remote_user( &deps, application::social::commands::FollowCommand { follower_id: user_id.value(), - target: SocialIdentity::Remote { - actor_url: form.handle, - }, + target: FollowTarget::Handle(form.handle), }, ) .await