fix: FollowTarget enum — actor_url no longer lies about holding a handle

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.
This commit is contained in:
2026-07-10 16:18:57 +02:00
parent 2484f1e603
commit d60c47199c
18 changed files with 95 additions and 45 deletions

View File

@@ -4,7 +4,7 @@ use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
ports::{SocialCommand, SocialQuery, UserRepository}, ports::{SocialCommand, SocialQuery, UserRepository},
value_objects::{SocialActor, SocialIdentity, UserId}, value_objects::{FollowTarget, SocialActor, SocialIdentity, UserId},
}; };
use k_ap::RemoteActor; use k_ap::RemoteActor;
@@ -87,15 +87,18 @@ fn ap_err(e: anyhow::Error) -> DomainError {
#[async_trait] #[async_trait]
impl SocialCommand for CompositeSocialAdapter { impl SocialCommand for CompositeSocialAdapter {
async fn follow(&self, follower: &UserId, target: &SocialIdentity) -> Result<(), DomainError> { async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError> {
if let SocialIdentity::Local(target_id) = target if let FollowTarget::Identity(SocialIdentity::Local(target_id)) = target
&& follower == target_id && follower == target_id
{ {
return Err(DomainError::ValidationError( return Err(DomainError::ValidationError(
"Cannot follow yourself".into(), "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 self.ap_service
.follow(follower.value(), &handle) .follow(follower.value(), &handle)
.await .await

View File

@@ -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<SocialIdentity, DomainError> { fn payload_to_identity(kind: &str, id: String) -> Result<SocialIdentity, DomainError> {
match kind { match kind {
"local" => Ok(SocialIdentity::Local(UserId::from_uuid(parse_uuid( "local" => Ok(SocialIdentity::Local(UserId::from_uuid(parse_uuid(
@@ -208,6 +215,18 @@ fn payload_to_identity(kind: &str, id: String) -> Result<SocialIdentity, DomainE
} }
} }
fn payload_to_follow_target(
kind: &str,
id: String,
) -> Result<domain::value_objects::FollowTarget, DomainError> {
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<NaiveDateTime, DomainError> { fn parse_ts(ts: i64) -> Result<NaiveDateTime, DomainError> {
chrono::DateTime::from_timestamp(ts, 0) chrono::DateTime::from_timestamp(ts, 0)
.map(|dt| dt.naive_utc()) .map(|dt| dt.naive_utc())
@@ -294,7 +313,7 @@ impl From<&DomainEvent> for EventPayload {
} }
} }
DomainEvent::FollowRequested { follower, target } => { DomainEvent::FollowRequested { follower, target } => {
let (kind, id) = identity_to_payload(target); let (kind, id) = follow_target_to_payload(target);
EventPayload::FollowRequested { EventPayload::FollowRequested {
follower_id: follower.value().to_string(), follower_id: follower.value().to_string(),
target_kind: kind, target_kind: kind,
@@ -530,7 +549,7 @@ impl TryFrom<EventPayload> for DomainEvent {
target_id, target_id,
} => Ok(DomainEvent::FollowRequested { } => Ok(DomainEvent::FollowRequested {
follower: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?), 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 { EventPayload::FollowAccepted {
owner_id, owner_id,

View File

@@ -1,9 +1,9 @@
use domain::value_objects::SocialIdentity; use domain::value_objects::{FollowTarget, SocialIdentity};
use uuid::Uuid; use uuid::Uuid;
pub struct FollowCommand { pub struct FollowCommand {
pub follower_id: Uuid, pub follower_id: Uuid,
pub target: SocialIdentity, pub target: FollowTarget,
} }
pub struct UnfollowCommand { pub struct UnfollowCommand {

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use domain::{ use domain::{
events::DomainEvent, events::DomainEvent,
testing::{InMemorySocialRepository, NoopEventPublisher}, testing::{InMemorySocialRepository, NoopEventPublisher},
value_objects::{SocialIdentity, UserId}, value_objects::{FollowTarget, SocialIdentity, UserId},
}; };
use uuid::Uuid; use uuid::Uuid;
@@ -40,7 +40,7 @@ async fn accept_follow_emits_follow_accepted_event() {
&deps, &deps,
FollowCommand { FollowCommand {
follower_id, follower_id,
target: SocialIdentity::Local(UserId::from_uuid(owner_id)), target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(owner_id))),
}, },
) )
.await .await

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use domain::{ use domain::{
events::DomainEvent, events::DomainEvent,
testing::{InMemorySocialRepository, NoopEventPublisher}, testing::{InMemorySocialRepository, NoopEventPublisher},
value_objects::{SocialIdentity, UserId}, value_objects::{FollowTarget, SocialIdentity, UserId},
}; };
use uuid::Uuid; use uuid::Uuid;
@@ -32,7 +32,7 @@ async fn follow_emits_follow_requested_event() {
&deps, &deps,
FollowCommand { FollowCommand {
follower_id: Uuid::new_v4(), 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 .await
@@ -55,7 +55,7 @@ async fn cannot_follow_yourself() {
&deps, &deps,
FollowCommand { FollowCommand {
follower_id: user_id, follower_id: user_id,
target: SocialIdentity::Local(UserId::from_uuid(user_id)), target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(user_id))),
}, },
) )
.await; .await;
@@ -67,7 +67,7 @@ async fn cannot_follow_yourself() {
async fn cannot_follow_same_target_twice() { async fn cannot_follow_same_target_twice() {
let (_social, _events, deps) = make_deps(); let (_social, _events, deps) = make_deps();
let follower_id = Uuid::new_v4(); 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( follow::execute(
&deps, &deps,

View File

@@ -2,7 +2,7 @@ use std::sync::Arc;
use domain::{ use domain::{
testing::{InMemorySocialRepository, NoopEventPublisher}, testing::{InMemorySocialRepository, NoopEventPublisher},
value_objects::{SocialIdentity, UserId}, value_objects::{FollowTarget, SocialIdentity, UserId},
}; };
use uuid::Uuid; use uuid::Uuid;
@@ -34,7 +34,7 @@ async fn returns_accepted_followers() {
&cmd_deps, &cmd_deps,
FollowCommand { FollowCommand {
follower_id, follower_id,
target: SocialIdentity::Local(UserId::from_uuid(owner_id)), target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(owner_id))),
}, },
) )
.await .await

View File

@@ -2,7 +2,7 @@ use std::sync::Arc;
use domain::{ use domain::{
testing::{InMemorySocialRepository, NoopEventPublisher}, testing::{InMemorySocialRepository, NoopEventPublisher},
value_objects::{SocialIdentity, UserId}, value_objects::{FollowTarget, SocialIdentity, UserId},
}; };
use uuid::Uuid; use uuid::Uuid;
@@ -34,7 +34,7 @@ async fn returns_accepted_follows() {
&cmd_deps, &cmd_deps,
FollowCommand { FollowCommand {
follower_id, follower_id,
target: SocialIdentity::Local(UserId::from_uuid(target_id)), target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(target_id))),
}, },
) )
.await .await

View File

@@ -2,7 +2,7 @@ use std::sync::Arc;
use domain::{ use domain::{
testing::{InMemorySocialRepository, NoopEventPublisher}, testing::{InMemorySocialRepository, NoopEventPublisher},
value_objects::{SocialIdentity, UserId}, value_objects::{FollowTarget, SocialIdentity, UserId},
}; };
use uuid::Uuid; use uuid::Uuid;
@@ -33,7 +33,7 @@ async fn returns_only_pending_followers() {
&cmd_deps, &cmd_deps,
FollowCommand { FollowCommand {
follower_id, follower_id,
target: SocialIdentity::Local(UserId::from_uuid(owner_id)), target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(owner_id))),
}, },
) )
.await .await

View File

@@ -2,7 +2,7 @@ use std::sync::Arc;
use domain::{ use domain::{
testing::{InMemorySocialRepository, NoopEventPublisher}, testing::{InMemorySocialRepository, NoopEventPublisher},
value_objects::{SocialIdentity, UserId}, value_objects::{FollowTarget, SocialIdentity, UserId},
}; };
use uuid::Uuid; use uuid::Uuid;
@@ -37,7 +37,7 @@ async fn reject_follow_completes_without_error() {
&deps, &deps,
FollowCommand { FollowCommand {
follower_id, follower_id,
target: SocialIdentity::Local(UserId::from_uuid(owner_id)), target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(owner_id))),
}, },
) )
.await .await

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use domain::{ use domain::{
events::DomainEvent, events::DomainEvent,
testing::{InMemorySocialRepository, NoopEventPublisher}, testing::{InMemorySocialRepository, NoopEventPublisher},
value_objects::{SocialIdentity, UserId}, value_objects::{FollowTarget, SocialIdentity, UserId},
}; };
use uuid::Uuid; use uuid::Uuid;
@@ -39,7 +39,7 @@ async fn remove_follower_emits_follower_removed_event() {
&deps, &deps,
FollowCommand { FollowCommand {
follower_id, follower_id,
target: SocialIdentity::Local(UserId::from_uuid(owner_id)), target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(owner_id))),
}, },
) )
.await .await

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use domain::{ use domain::{
events::DomainEvent, events::DomainEvent,
testing::{InMemorySocialRepository, NoopEventPublisher}, testing::{InMemorySocialRepository, NoopEventPublisher},
value_objects::{SocialIdentity, UserId}, value_objects::{FollowTarget, SocialIdentity, UserId},
}; };
use uuid::Uuid; use uuid::Uuid;
@@ -38,7 +38,7 @@ async fn unfollow_emits_unfollowed_event() {
&deps, &deps,
FollowCommand { FollowCommand {
follower_id, follower_id,
target: target.clone(), target: FollowTarget::Identity(target.clone()),
}, },
) )
.await .await

View File

@@ -65,7 +65,7 @@ pub enum DomainEvent {
}, },
FollowRequested { FollowRequested {
follower: UserId, follower: UserId,
target: crate::value_objects::SocialIdentity, target: crate::value_objects::FollowTarget,
}, },
FollowAccepted { FollowAccepted {
owner: UserId, owner: UserId,

View File

@@ -41,7 +41,7 @@ pub struct NoopSocialCommand;
#[async_trait] #[async_trait]
impl super::SocialCommand for NoopSocialCommand { 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(()) Ok(())
} }
async fn unfollow(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> { async fn unfollow(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> {

View File

@@ -7,14 +7,14 @@ use crate::{
DiaryEntry, FederationFlags, RemoteActorInfo, RemoteGoalEntry, RemoteWatchlistEntry, DiaryEntry, FederationFlags, RemoteActorInfo, RemoteGoalEntry, RemoteWatchlistEntry,
WatchlistWithMovie, WatchlistWithMovie,
}, },
value_objects::{MovieId, SocialActor, SocialIdentity, UserId}, value_objects::{FollowTarget, MovieId, SocialActor, SocialIdentity, UserId},
}; };
// ── Unified social ports (ADR-0002) ───────────────────────────────────────── // ── Unified social ports (ADR-0002) ─────────────────────────────────────────
#[async_trait] #[async_trait]
pub trait SocialCommand: Send + Sync { 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) async fn unfollow(&self, follower: &UserId, target: &SocialIdentity)
-> Result<(), DomainError>; -> Result<(), DomainError>;

View File

@@ -893,8 +893,18 @@ impl InMemorySocialRepository {
#[async_trait] #[async_trait]
impl SocialCommand for InMemorySocialRepository { impl SocialCommand for InMemorySocialRepository {
async fn follow(&self, follower: &UserId, target: &SocialIdentity) -> Result<(), DomainError> { async fn follow(
if let SocialIdentity::Local(target_id) = target { &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 { if follower == target_id {
return Err(DomainError::ValidationError( return Err(DomainError::ValidationError(
"Cannot follow yourself".into(), "Cannot follow yourself".into(),
@@ -904,11 +914,11 @@ impl SocialCommand for InMemorySocialRepository {
let mut store = self.follows.lock().unwrap(); let mut store = self.follows.lock().unwrap();
let already = store let already = store
.iter() .iter()
.any(|(f, t, _)| *f == follower.value() && t == target); .any(|(f, t, _)| *f == follower.value() && *t == identity);
if already { if already {
return Err(DomainError::ValidationError("Already following".into())); return Err(DomainError::ValidationError("Already following".into()));
} }
store.push((follower.value(), target.clone(), FollowState::Pending)); store.push((follower.value(), identity, FollowState::Pending));
Ok(()) Ok(())
} }

View File

@@ -1,5 +1,5 @@
use super::*; use super::*;
use crate::value_objects::{SocialIdentity, UserId}; use crate::value_objects::{FollowTarget, SocialIdentity, UserId};
#[test] #[test]
fn follow_accepted_matches() { fn follow_accepted_matches() {
@@ -22,17 +22,33 @@ fn follow_accepted_matches() {
} }
#[test] #[test]
fn follow_requested_local() { fn follow_requested_with_identity() {
let follower = UserId::from_uuid(uuid::Uuid::new_v4()); let follower = UserId::from_uuid(uuid::Uuid::new_v4());
let target = UserId::from_uuid(uuid::Uuid::new_v4()); let target = UserId::from_uuid(uuid::Uuid::new_v4());
let event = DomainEvent::FollowRequested { let event = DomainEvent::FollowRequested {
follower: follower.clone(), follower: follower.clone(),
target: SocialIdentity::Local(target.clone()), target: FollowTarget::Identity(SocialIdentity::Local(target.clone())),
}; };
assert!(matches!( assert!(matches!(
event, event,
DomainEvent::FollowRequested { 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(_),
.. ..
} }
)); ));

View File

@@ -16,6 +16,12 @@ impl SocialIdentity {
} }
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FollowTarget {
Identity(SocialIdentity),
Handle(String),
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct SocialActor { pub struct SocialActor {
pub identity: SocialIdentity, pub identity: SocialIdentity,

View File

@@ -22,7 +22,7 @@ use api_types::{
BlockedDomainResponse, FollowRequest, RemoteActorDto, BlockedDomainResponse, FollowRequest, RemoteActorDto,
}; };
use application::social::deps::{SocialCommandDeps, SocialQueryDeps}; use application::social::deps::{SocialCommandDeps, SocialQueryDeps};
use domain::value_objects::{SocialActor, SocialIdentity}; use domain::value_objects::{FollowTarget, SocialActor, SocialIdentity};
use template_askama::{ use template_askama::{
BlockedActorsTemplate, BlockedDomainsTemplate, FollowersTemplate, FollowingTemplate, BlockedActorsTemplate, BlockedDomainsTemplate, FollowersTemplate, FollowingTemplate,
RemoteActorData, RemoteActorData,
@@ -359,9 +359,7 @@ pub async fn follow(
&deps, &deps,
application::social::commands::FollowCommand { application::social::commands::FollowCommand {
follower_id: user.0.value(), follower_id: user.0.value(),
target: SocialIdentity::Remote { target: FollowTarget::Handle(body.handle),
actor_url: body.handle,
},
}, },
) )
.await?; .await?;
@@ -554,9 +552,7 @@ pub async fn follow_remote_user(
&deps, &deps,
application::social::commands::FollowCommand { application::social::commands::FollowCommand {
follower_id: user_id.value(), follower_id: user_id.value(),
target: SocialIdentity::Remote { target: FollowTarget::Handle(form.handle),
actor_url: form.handle,
},
}, },
) )
.await .await