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:
@@ -101,7 +101,6 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
|||||||
movie_repo: std::sync::Arc::clone(&movie_repo),
|
movie_repo: std::sync::Arc::clone(&movie_repo),
|
||||||
diary_repo,
|
diary_repo,
|
||||||
review_store,
|
review_store,
|
||||||
event_publisher: std::sync::Arc::clone(&event_publisher),
|
|
||||||
instance: instance.clone(),
|
instance: instance.clone(),
|
||||||
});
|
});
|
||||||
let watchlist_handler = std::sync::Arc::new(watchlist_handler::WatchlistObjectHandler {
|
let watchlist_handler = std::sync::Arc::new(watchlist_handler::WatchlistObjectHandler {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
use domain::models::Review;
|
use domain::{events::DomainEvent, models::Review};
|
||||||
|
|
||||||
pub struct RemoteReviewUpdate<'a> {
|
pub struct RemoteReviewUpdate<'a> {
|
||||||
pub ap_id: &'a str,
|
pub ap_id: &'a str,
|
||||||
@@ -16,6 +16,7 @@ pub struct RemoteReviewUpdate<'a> {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait RemoteReviewRepository: Send + Sync {
|
pub trait RemoteReviewRepository: Send + Sync {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn save_remote_review(
|
async fn save_remote_review(
|
||||||
&self,
|
&self,
|
||||||
review: &Review,
|
review: &Review,
|
||||||
@@ -24,6 +25,7 @@ pub trait RemoteReviewRepository: Send + Sync {
|
|||||||
release_year: u16,
|
release_year: u16,
|
||||||
external_metadata_id: Option<&str>,
|
external_metadata_id: Option<&str>,
|
||||||
poster_url: Option<&str>,
|
poster_url: Option<&str>,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<()>;
|
) -> Result<()>;
|
||||||
|
|
||||||
async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()>;
|
async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()>;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use async_trait::async_trait;
|
|||||||
use domain::{
|
use domain::{
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::ReviewSource,
|
models::ReviewSource,
|
||||||
ports::{DiaryQuery, EventPublisher, LocalApContentQuery, MovieQuery},
|
ports::{DiaryQuery, LocalApContentQuery, MovieQuery},
|
||||||
value_objects::{
|
value_objects::{
|
||||||
Comment, ExternalMetadataId, InstanceIdentity, MovieId, Rating, ReviewId, UserId,
|
Comment, ExternalMetadataId, InstanceIdentity, MovieId, Rating, ReviewId, UserId,
|
||||||
},
|
},
|
||||||
@@ -21,7 +21,6 @@ pub struct ReviewObjectHandler {
|
|||||||
pub movie_repo: Arc<dyn MovieQuery>,
|
pub movie_repo: Arc<dyn MovieQuery>,
|
||||||
pub diary_repo: Arc<dyn DiaryQuery>,
|
pub diary_repo: Arc<dyn DiaryQuery>,
|
||||||
pub review_store: Arc<dyn RemoteReviewRepository>,
|
pub review_store: Arc<dyn RemoteReviewRepository>,
|
||||||
pub event_publisher: Arc<dyn EventPublisher>,
|
|
||||||
pub instance: InstanceIdentity,
|
pub instance: InstanceIdentity,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +159,20 @@ impl ApObjectHandler for ReviewObjectHandler {
|
|||||||
spoilers: obj.contains_spoilers,
|
spoilers: obj.contains_spoilers,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The enrichment request rides the review write (one transaction).
|
||||||
|
let events: Vec<DomainEvent> = obj
|
||||||
|
.external_metadata_id
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|ext_id| ExternalMetadataId::new(ext_id.clone()).ok())
|
||||||
|
.map(
|
||||||
|
|external_metadata_id| DomainEvent::MovieEnrichmentRequested {
|
||||||
|
movie_id: movie_id.clone(),
|
||||||
|
external_metadata_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
self.review_store
|
self.review_store
|
||||||
.save_remote_review(
|
.save_remote_review(
|
||||||
&review,
|
&review,
|
||||||
@@ -168,21 +181,10 @@ impl ApObjectHandler for ReviewObjectHandler {
|
|||||||
obj.release_year,
|
obj.release_year,
|
||||||
obj.external_metadata_id.as_deref(),
|
obj.external_metadata_id.as_deref(),
|
||||||
obj.poster_url.as_deref(),
|
obj.poster_url.as_deref(),
|
||||||
|
&events,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if let Some(ref ext_id_str) = obj.external_metadata_id
|
|
||||||
&& let Ok(external_metadata_id) = ExternalMetadataId::new(ext_id_str.clone())
|
|
||||||
{
|
|
||||||
let _ = self
|
|
||||||
.event_publisher
|
|
||||||
.publish(&DomainEvent::MovieEnrichmentRequested {
|
|
||||||
movie_id: movie_id.clone(),
|
|
||||||
external_metadata_id,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ use std::sync::Arc;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::{BlockQuery, FollowGraphQuery, LocalSocial, SocialCommand, UserRepository},
|
events::DomainEvent,
|
||||||
|
ports::{
|
||||||
|
BlockQuery, EventPublisher, FollowGraphQuery, LocalSocial, SocialCommand, UserRepository,
|
||||||
|
},
|
||||||
value_objects::{
|
value_objects::{
|
||||||
FollowRelation, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity, UserId,
|
FollowRelation, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity, UserId,
|
||||||
},
|
},
|
||||||
@@ -16,6 +19,7 @@ pub struct CompositeSocialAdapter {
|
|||||||
ap_service: Arc<ActivityPubService>,
|
ap_service: Arc<ActivityPubService>,
|
||||||
user_repo: Arc<dyn UserRepository>,
|
user_repo: Arc<dyn UserRepository>,
|
||||||
instance: InstanceIdentity,
|
instance: InstanceIdentity,
|
||||||
|
event_publisher: Arc<dyn EventPublisher>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CompositeSocialAdapter {
|
impl CompositeSocialAdapter {
|
||||||
@@ -24,12 +28,23 @@ impl CompositeSocialAdapter {
|
|||||||
ap_service: Arc<ActivityPubService>,
|
ap_service: Arc<ActivityPubService>,
|
||||||
user_repo: Arc<dyn UserRepository>,
|
user_repo: Arc<dyn UserRepository>,
|
||||||
instance: InstanceIdentity,
|
instance: InstanceIdentity,
|
||||||
|
event_publisher: Arc<dyn EventPublisher>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
local,
|
local,
|
||||||
ap_service,
|
ap_service,
|
||||||
user_repo,
|
user_repo,
|
||||||
instance,
|
instance,
|
||||||
|
event_publisher,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort publish for writes k_ap owns (no shared transaction).
|
||||||
|
async fn announce(&self, events: &[DomainEvent]) {
|
||||||
|
for event in events {
|
||||||
|
if let Err(e) = self.event_publisher.publish(event).await {
|
||||||
|
tracing::warn!("failed to publish {event:?} after k_ap write: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,11 +55,19 @@ 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: &FollowTarget) -> Result<(), DomainError> {
|
async fn follow(
|
||||||
|
&self,
|
||||||
|
follower: &UserId,
|
||||||
|
target: &FollowTarget,
|
||||||
|
events: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
let identity = self.local.resolve_target(target).await?;
|
let identity = self.local.resolve_target(target).await?;
|
||||||
|
|
||||||
if let SocialIdentity::Local(_) = identity {
|
if let SocialIdentity::Local(_) = identity {
|
||||||
return self.local.follow_resolved(follower, &identity).await;
|
return self
|
||||||
|
.local
|
||||||
|
.follow_resolved(follower, &identity, events)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let handle = match target {
|
let handle = match target {
|
||||||
@@ -61,24 +84,33 @@ impl SocialCommand for CompositeSocialAdapter {
|
|||||||
SocialIdentity::Remote { actor_url } => actor_url.clone(),
|
SocialIdentity::Remote { actor_url } => actor_url.clone(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
// Not atomic: k_ap owns the transaction, so the events are published
|
||||||
|
// best-effort after its write succeeds.
|
||||||
self.ap_service
|
self.ap_service
|
||||||
.follow(follower.value(), &handle)
|
.follow(follower.value(), &handle)
|
||||||
.await
|
.await
|
||||||
.map_err(ap_err)
|
.map_err(ap_err)?;
|
||||||
|
self.announce(events).await;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn unfollow(
|
async fn unfollow(
|
||||||
&self,
|
&self,
|
||||||
follower: &UserId,
|
follower: &UserId,
|
||||||
target: &SocialIdentity,
|
target: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
match target {
|
match target {
|
||||||
SocialIdentity::Local(_) => self.local.unfollow(follower, target).await,
|
SocialIdentity::Local(_) => self.local.unfollow(follower, target, events).await,
|
||||||
SocialIdentity::Remote { .. } => self
|
// Not atomic: k_ap owns the transaction.
|
||||||
.ap_service
|
SocialIdentity::Remote { .. } => {
|
||||||
.unfollow(follower.value(), &self.instance.actor_url_of(target))
|
self.ap_service
|
||||||
.await
|
.unfollow(follower.value(), &self.instance.actor_url_of(target))
|
||||||
.map_err(ap_err),
|
.await
|
||||||
|
.map_err(ap_err)?;
|
||||||
|
self.announce(events).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,14 +118,19 @@ impl SocialCommand for CompositeSocialAdapter {
|
|||||||
&self,
|
&self,
|
||||||
owner: &UserId,
|
owner: &UserId,
|
||||||
requester: &SocialIdentity,
|
requester: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
match requester {
|
match requester {
|
||||||
SocialIdentity::Local(_) => self.local.accept_follow(owner, requester).await,
|
SocialIdentity::Local(_) => self.local.accept_follow(owner, requester, events).await,
|
||||||
SocialIdentity::Remote { .. } => self
|
// Not atomic: k_ap owns the transaction.
|
||||||
.ap_service
|
SocialIdentity::Remote { .. } => {
|
||||||
.accept_follower(owner.value(), &self.instance.actor_url_of(requester))
|
self.ap_service
|
||||||
.await
|
.accept_follower(owner.value(), &self.instance.actor_url_of(requester))
|
||||||
.map_err(ap_err),
|
.await
|
||||||
|
.map_err(ap_err)?;
|
||||||
|
self.announce(events).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,14 +138,19 @@ impl SocialCommand for CompositeSocialAdapter {
|
|||||||
&self,
|
&self,
|
||||||
owner: &UserId,
|
owner: &UserId,
|
||||||
requester: &SocialIdentity,
|
requester: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
match requester {
|
match requester {
|
||||||
SocialIdentity::Local(_) => self.local.reject_follow(owner, requester).await,
|
SocialIdentity::Local(_) => self.local.reject_follow(owner, requester, events).await,
|
||||||
SocialIdentity::Remote { .. } => self
|
// Not atomic: k_ap owns the transaction.
|
||||||
.ap_service
|
SocialIdentity::Remote { .. } => {
|
||||||
.reject_follower(owner.value(), &self.instance.actor_url_of(requester))
|
self.ap_service
|
||||||
.await
|
.reject_follower(owner.value(), &self.instance.actor_url_of(requester))
|
||||||
.map_err(ap_err),
|
.await
|
||||||
|
.map_err(ap_err)?;
|
||||||
|
self.announce(events).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,31 +158,52 @@ impl SocialCommand for CompositeSocialAdapter {
|
|||||||
&self,
|
&self,
|
||||||
owner: &UserId,
|
owner: &UserId,
|
||||||
follower: &SocialIdentity,
|
follower: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
match follower {
|
match follower {
|
||||||
SocialIdentity::Local(_) => self.local.remove_follower(owner, follower).await,
|
SocialIdentity::Local(_) => self.local.remove_follower(owner, follower, events).await,
|
||||||
SocialIdentity::Remote { .. } => self
|
// Not atomic: k_ap owns the transaction.
|
||||||
.ap_service
|
SocialIdentity::Remote { .. } => {
|
||||||
.remove_follower(owner.value(), &self.instance.actor_url_of(follower))
|
self.ap_service
|
||||||
.await
|
.remove_follower(owner.value(), &self.instance.actor_url_of(follower))
|
||||||
.map_err(ap_err),
|
.await
|
||||||
|
.map_err(ap_err)?;
|
||||||
|
self.announce(events).await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
|
/// Not atomic: k_ap owns the transaction.
|
||||||
|
async fn block(
|
||||||
|
&self,
|
||||||
|
blocker: &UserId,
|
||||||
|
target: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
let actor_url = self.instance.actor_url_of(target);
|
let actor_url = self.instance.actor_url_of(target);
|
||||||
self.ap_service
|
self.ap_service
|
||||||
.block_actor(blocker.value(), &actor_url)
|
.block_actor(blocker.value(), &actor_url)
|
||||||
.await
|
.await
|
||||||
.map_err(ap_err)
|
.map_err(ap_err)?;
|
||||||
|
self.announce(events).await;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn unblock(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
|
/// Not atomic: k_ap owns the transaction.
|
||||||
|
async fn unblock(
|
||||||
|
&self,
|
||||||
|
blocker: &UserId,
|
||||||
|
target: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
let actor_url = self.instance.actor_url_of(target);
|
let actor_url = self.instance.actor_url_of(target);
|
||||||
self.ap_service
|
self.ap_service
|
||||||
.unblock_actor(blocker.value(), &actor_url)
|
.unblock_actor(blocker.value(), &actor_url)
|
||||||
.await
|
.await
|
||||||
.map_err(ap_err)
|
.map_err(ap_err)?;
|
||||||
|
self.announce(events).await;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ async-trait = { workspace = true }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
domain = { workspace = true, features = ["test-helpers"] }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
use activitypub::{RemoteReviewRepository, RemoteReviewUpdate};
|
use activitypub::{RemoteReviewRepository, RemoteReviewUpdate};
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::models::{Review, ReviewSource};
|
use domain::{
|
||||||
|
events::DomainEvent,
|
||||||
|
models::{Review, ReviewSource},
|
||||||
|
};
|
||||||
|
|
||||||
use super::SqliteFederationRepository;
|
use super::SqliteFederationRepository;
|
||||||
use sqlite::persistence::datetime_to_str;
|
use sqlite::persistence::{datetime_to_str, enqueue_events};
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl RemoteReviewRepository for SqliteFederationRepository {
|
impl RemoteReviewRepository for SqliteFederationRepository {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn save_remote_review(
|
async fn save_remote_review(
|
||||||
&self,
|
&self,
|
||||||
review: &Review,
|
review: &Review,
|
||||||
@@ -16,6 +20,7 @@ impl RemoteReviewRepository for SqliteFederationRepository {
|
|||||||
release_year: u16,
|
release_year: u16,
|
||||||
external_metadata_id: Option<&str>,
|
external_metadata_id: Option<&str>,
|
||||||
poster_url: Option<&str>,
|
poster_url: Option<&str>,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let actor_url = match review.source() {
|
let actor_url = match review.source() {
|
||||||
ReviewSource::Remote { actor_url } => actor_url.clone(),
|
ReviewSource::Remote { actor_url } => actor_url.clone(),
|
||||||
@@ -24,6 +29,7 @@ impl RemoteReviewRepository for SqliteFederationRepository {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let movie_id = review.movie_id().value().to_string();
|
let movie_id = review.movie_id().value().to_string();
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
"INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)
|
"INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)
|
||||||
VALUES (?, ?, ?, ?, NULL, ?)
|
VALUES (?, ?, ?, ?, NULL, ?)
|
||||||
@@ -36,7 +42,7 @@ impl RemoteReviewRepository for SqliteFederationRepository {
|
|||||||
.bind(movie_title)
|
.bind(movie_title)
|
||||||
.bind(release_year.max(1888) as i64)
|
.bind(release_year.max(1888) as i64)
|
||||||
.bind(poster_url)
|
.bind(poster_url)
|
||||||
.execute(&self.pool)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let id = review.id().value().to_string();
|
let id = review.id().value().to_string();
|
||||||
@@ -59,8 +65,10 @@ impl RemoteReviewRepository for SqliteFederationRepository {
|
|||||||
.bind(&actor_url)
|
.bind(&actor_url)
|
||||||
.bind(ap_id)
|
.bind(ap_id)
|
||||||
.bind(review.spoilers())
|
.bind(review.spoilers())
|
||||||
.execute(&self.pool)
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
enqueue_events(&mut tx, events).await?;
|
||||||
|
tx.commit().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ mod actor_block_test;
|
|||||||
mod announce_test;
|
mod announce_test;
|
||||||
#[path = "federation/domain_block_test.rs"]
|
#[path = "federation/domain_block_test.rs"]
|
||||||
mod domain_block_test;
|
mod domain_block_test;
|
||||||
|
#[path = "federation/remote_review_test.rs"]
|
||||||
|
mod remote_review_test;
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
//! A remote review and the enrichment request it triggers commit together.
|
||||||
|
|
||||||
|
use activitypub::RemoteReviewRepository;
|
||||||
|
use domain::{
|
||||||
|
events::DomainEvent,
|
||||||
|
models::{PersistedReview, Review, ReviewSource},
|
||||||
|
value_objects::{Comment, ExternalMetadataId, MovieId, Rating, ReviewId, UserId},
|
||||||
|
};
|
||||||
|
use sqlite_federation::SqliteFederationRepository;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
const ACTOR: &str = "https://remote.example/users/ann";
|
||||||
|
const AP_ID: &str = "https://remote.example/reviews/1";
|
||||||
|
|
||||||
|
async fn migrated_pool() -> SqlitePool {
|
||||||
|
let pool = sqlx::sqlite::SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlite::migrate(&pool).await.unwrap();
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count(pool: &SqlitePool, table: &str) -> i64 {
|
||||||
|
sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}"))
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote_review(movie_id: &MovieId) -> Review {
|
||||||
|
let now = chrono::Utc::now().naive_utc();
|
||||||
|
Review::from_persistence(PersistedReview {
|
||||||
|
id: ReviewId::generate(),
|
||||||
|
movie_id: movie_id.clone(),
|
||||||
|
user_id: UserId::generate(),
|
||||||
|
rating: Rating::new(4).unwrap(),
|
||||||
|
comment: Some(Comment::new("good".into()).unwrap()),
|
||||||
|
watched_at: now,
|
||||||
|
created_at: now,
|
||||||
|
source: ReviewSource::Remote {
|
||||||
|
actor_url: ACTOR.into(),
|
||||||
|
},
|
||||||
|
watch_medium: None,
|
||||||
|
spoilers: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enrichment_requested(movie_id: &MovieId) -> DomainEvent {
|
||||||
|
DomainEvent::MovieEnrichmentRequested {
|
||||||
|
movie_id: movie_id.clone(),
|
||||||
|
external_metadata_id: ExternalMetadataId::new("tmdb:603".into()).unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(pool: &SqlitePool, review: &Review, events: &[DomainEvent]) -> anyhow::Result<()> {
|
||||||
|
SqliteFederationRepository::new(pool.clone())
|
||||||
|
.save_remote_review(
|
||||||
|
review,
|
||||||
|
AP_ID,
|
||||||
|
"The Matrix",
|
||||||
|
1999,
|
||||||
|
Some("tmdb:603"),
|
||||||
|
None,
|
||||||
|
events,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn saving_a_remote_review_writes_it_and_its_event_in_one_commit() {
|
||||||
|
let pool = migrated_pool().await;
|
||||||
|
let movie_id = MovieId::generate();
|
||||||
|
let review = remote_review(&movie_id);
|
||||||
|
|
||||||
|
save(&pool, &review, &[enrichment_requested(&movie_id)])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(count(&pool, "reviews").await, 1);
|
||||||
|
assert_eq!(count(&pool, "movies").await, 1);
|
||||||
|
assert_eq!(count(&pool, "event_queue").await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failing_enqueue_rolls_back_the_remote_review_and_its_movie() {
|
||||||
|
let pool = migrated_pool().await;
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TRIGGER fail_event_insert BEFORE INSERT ON event_queue \
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'enqueue failed'); END",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let movie_id = MovieId::generate();
|
||||||
|
let review = remote_review(&movie_id);
|
||||||
|
|
||||||
|
let result = save(&pool, &review, &[enrichment_requested(&movie_id)]).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(count(&pool, "reviews").await, 0);
|
||||||
|
assert_eq!(count(&pool, "movies").await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_remote_review_without_events_enqueues_nothing() {
|
||||||
|
let pool = migrated_pool().await;
|
||||||
|
let movie_id = MovieId::generate();
|
||||||
|
|
||||||
|
save(&pool, &remote_review(&movie_id), &[]).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(count(&pool, "reviews").await, 1);
|
||||||
|
assert_eq!(count(&pool, "event_queue").await, 0);
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ pub use repositories::{
|
|||||||
/// Row helpers shared with the `sqlite-federation` crate (which cannot live
|
/// Row helpers shared with the `sqlite-federation` crate (which cannot live
|
||||||
/// here because it depends on `activitypub`/`k-ap`).
|
/// here because it depends on `activitypub`/`k-ap`).
|
||||||
pub mod persistence {
|
pub mod persistence {
|
||||||
pub use crate::shared::datetime_to_str;
|
pub use crate::shared::{datetime_to_str, enqueue_events};
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_profile_fields_repo(
|
pub fn create_profile_fields_repo(
|
||||||
|
|||||||
@@ -1,130 +1,126 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use domain::{errors::DomainError, value_objects::FollowStatus};
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
|
ports::{FollowCommand, FollowWrite},
|
||||||
|
};
|
||||||
|
|
||||||
use crate::shared::datetime_to_str;
|
use crate::shared::{datetime_to_str, enqueue_events};
|
||||||
|
|
||||||
use super::infra_err;
|
use super::infra_err;
|
||||||
use super::rows::follow_status_to_str;
|
use super::rows::follow_status_to_str;
|
||||||
use crate::repositories::social::SqliteSocialRepository;
|
use crate::repositories::social::SqliteSocialRepository;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl domain::ports::FollowCommand for SqliteSocialRepository {
|
impl FollowCommand for SqliteSocialRepository {
|
||||||
async fn add_follow(
|
async fn apply(
|
||||||
&self,
|
&self,
|
||||||
follower_id: uuid::Uuid,
|
writes: &[FollowWrite],
|
||||||
target_actor_url: &str,
|
events: &[DomainEvent],
|
||||||
status: FollowStatus,
|
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let uid = follower_id.to_string();
|
let mut tx = self.pool.begin().await.map_err(infra_err)?;
|
||||||
let status_str = follow_status_to_str(&status);
|
for write in writes {
|
||||||
let now = datetime_to_str(&Utc::now().naive_utc());
|
apply_write(&mut tx, write).await?;
|
||||||
sqlx::query(
|
}
|
||||||
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at, status)
|
enqueue_events(&mut tx, events).await?;
|
||||||
VALUES (?1, ?2, '', ?3, ?4)
|
tx.commit().await.map_err(infra_err)
|
||||||
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status",
|
|
||||||
)
|
|
||||||
.bind(&uid)
|
|
||||||
.bind(target_actor_url)
|
|
||||||
.bind(&now)
|
|
||||||
.bind(status_str)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(infra_err)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_follow_status(
|
|
||||||
&self,
|
|
||||||
follower_id: uuid::Uuid,
|
|
||||||
target_actor_url: &str,
|
|
||||||
status: FollowStatus,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
let uid = follower_id.to_string();
|
|
||||||
let status_str = follow_status_to_str(&status);
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE ap_following SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3",
|
|
||||||
)
|
|
||||||
.bind(status_str)
|
|
||||||
.bind(&uid)
|
|
||||||
.bind(target_actor_url)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(infra_err)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn remove_follow(
|
|
||||||
&self,
|
|
||||||
follower_id: uuid::Uuid,
|
|
||||||
target_actor_url: &str,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
let uid = follower_id.to_string();
|
|
||||||
sqlx::query("DELETE FROM ap_following WHERE local_user_id = ?1 AND remote_actor_url = ?2")
|
|
||||||
.bind(&uid)
|
|
||||||
.bind(target_actor_url)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(infra_err)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn add_follower(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
follower_actor_url: &str,
|
|
||||||
status: FollowStatus,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
let uid = local_user_id.to_string();
|
|
||||||
let status_str = follow_status_to_str(&status);
|
|
||||||
let now = datetime_to_str(&Utc::now().naive_utc());
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, '')
|
|
||||||
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status",
|
|
||||||
)
|
|
||||||
.bind(&uid)
|
|
||||||
.bind(follower_actor_url)
|
|
||||||
.bind(status_str)
|
|
||||||
.bind(&now)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(infra_err)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_follower_status(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
follower_actor_url: &str,
|
|
||||||
status: FollowStatus,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
let uid = local_user_id.to_string();
|
|
||||||
let status_str = follow_status_to_str(&status);
|
|
||||||
sqlx::query(
|
|
||||||
"UPDATE ap_followers SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3",
|
|
||||||
)
|
|
||||||
.bind(status_str)
|
|
||||||
.bind(&uid)
|
|
||||||
.bind(follower_actor_url)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(infra_err)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn remove_follower_record(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
follower_actor_url: &str,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
let uid = local_user_id.to_string();
|
|
||||||
sqlx::query("DELETE FROM ap_followers WHERE local_user_id = ?1 AND remote_actor_url = ?2")
|
|
||||||
.bind(&uid)
|
|
||||||
.bind(follower_actor_url)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(infra_err)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn apply_write(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
|
write: &FollowWrite,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
let now = datetime_to_str(&Utc::now().naive_utc());
|
||||||
|
match write {
|
||||||
|
FollowWrite::AddFollow {
|
||||||
|
follower_id,
|
||||||
|
target_actor_url,
|
||||||
|
status,
|
||||||
|
} => {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at, status)
|
||||||
|
VALUES (?1, ?2, '', ?3, ?4)
|
||||||
|
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status",
|
||||||
|
)
|
||||||
|
.bind(follower_id.to_string())
|
||||||
|
.bind(target_actor_url)
|
||||||
|
.bind(&now)
|
||||||
|
.bind(follow_status_to_str(status))
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
FollowWrite::UpdateFollowStatus {
|
||||||
|
follower_id,
|
||||||
|
target_actor_url,
|
||||||
|
status,
|
||||||
|
} => {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE ap_following SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3",
|
||||||
|
)
|
||||||
|
.bind(follow_status_to_str(status))
|
||||||
|
.bind(follower_id.to_string())
|
||||||
|
.bind(target_actor_url)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
FollowWrite::RemoveFollow {
|
||||||
|
follower_id,
|
||||||
|
target_actor_url,
|
||||||
|
} => {
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM ap_following WHERE local_user_id = ?1 AND remote_actor_url = ?2",
|
||||||
|
)
|
||||||
|
.bind(follower_id.to_string())
|
||||||
|
.bind(target_actor_url)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
FollowWrite::AddFollower {
|
||||||
|
local_user_id,
|
||||||
|
follower_actor_url,
|
||||||
|
status,
|
||||||
|
} => {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, '')
|
||||||
|
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status",
|
||||||
|
)
|
||||||
|
.bind(local_user_id.to_string())
|
||||||
|
.bind(follower_actor_url)
|
||||||
|
.bind(follow_status_to_str(status))
|
||||||
|
.bind(&now)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
FollowWrite::UpdateFollowerStatus {
|
||||||
|
local_user_id,
|
||||||
|
follower_actor_url,
|
||||||
|
status,
|
||||||
|
} => {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE ap_followers SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3",
|
||||||
|
)
|
||||||
|
.bind(follow_status_to_str(status))
|
||||||
|
.bind(local_user_id.to_string())
|
||||||
|
.bind(follower_actor_url)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
FollowWrite::RemoveFollowerRecord {
|
||||||
|
local_user_id,
|
||||||
|
follower_actor_url,
|
||||||
|
} => {
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM ap_followers WHERE local_user_id = ?1 AND remote_actor_url = ?2",
|
||||||
|
)
|
||||||
|
.bind(local_user_id.to_string())
|
||||||
|
.bind(follower_actor_url)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map_err(infra_err)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use domain::{
|
|||||||
/// THE outbox write path: inserts one `event_queue` row per event, each with a
|
/// THE outbox write path: inserts one `event_queue` row per event, each with a
|
||||||
/// fresh `EventId`, inside the caller's transaction so a change and the events
|
/// fresh `EventId`, inside the caller's transaction so a change and the events
|
||||||
/// announcing it commit or roll back together.
|
/// announcing it commit or roll back together.
|
||||||
pub(crate) async fn enqueue_events(
|
pub async fn enqueue_events(
|
||||||
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||||
events: &[domain::events::DomainEvent],
|
events: &[domain::events::DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ use domain::models::{
|
|||||||
WatchEvent, WatchEventSource, WatchlistEntry, WebhookToken,
|
WatchEvent, WatchEventSource, WatchlistEntry, WebhookToken,
|
||||||
};
|
};
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
FollowCommand, UserProfileFieldsRepository, WatchEventCommand, WebhookTokenRepository,
|
FollowCommand, FollowWrite, UserProfileFieldsRepository, WatchEventCommand,
|
||||||
|
WebhookTokenRepository,
|
||||||
};
|
};
|
||||||
use domain::testing::{
|
use domain::testing::{
|
||||||
MOVIE_CASCADE_TABLES, USER_CASCADE_TABLES, USER_CASCADE_TABLES_OUTSIDE_STORE, test_goal,
|
MOVIE_CASCADE_TABLES, USER_CASCADE_TABLES, USER_CASCADE_TABLES_OUTSIDE_STORE, test_goal,
|
||||||
@@ -184,11 +185,21 @@ async fn seed_user_with_dependents(
|
|||||||
domain::value_objects::InstanceIdentity::new("https://md.example"),
|
domain::value_objects::InstanceIdentity::new("https://md.example"),
|
||||||
);
|
);
|
||||||
social
|
social
|
||||||
.add_follow(user_id.value(), REMOTE_ACTOR, FollowStatus::Accepted)
|
.apply(
|
||||||
.await
|
&[
|
||||||
.unwrap();
|
FollowWrite::AddFollow {
|
||||||
social
|
follower_id: user_id.value(),
|
||||||
.add_follower(user_id.value(), REMOTE_ACTOR, FollowStatus::Accepted)
|
target_actor_url: REMOTE_ACTOR.into(),
|
||||||
|
status: FollowStatus::Accepted,
|
||||||
|
},
|
||||||
|
FollowWrite::AddFollower {
|
||||||
|
local_user_id: user_id.value(),
|
||||||
|
follower_actor_url: REMOTE_ACTOR.into(),
|
||||||
|
status: FollowStatus::Accepted,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// No repository in this crate owns these two tables (federation crate).
|
// No repository in this crate owns these two tables (federation crate).
|
||||||
|
|||||||
@@ -712,3 +712,85 @@ async fn a_failing_enqueue_rolls_back_the_watch_event_save() {
|
|||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert_eq!(count(&pool, "watch_events").await, 0);
|
assert_eq!(count(&pool, "watch_events").await, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn follow_writes(user_id: &UserId) -> Vec<domain::ports::FollowWrite> {
|
||||||
|
use domain::{ports::FollowWrite, value_objects::FollowStatus};
|
||||||
|
vec![
|
||||||
|
FollowWrite::AddFollower {
|
||||||
|
local_user_id: user_id.value(),
|
||||||
|
follower_actor_url: "https://md.example/users/other".into(),
|
||||||
|
status: FollowStatus::Pending,
|
||||||
|
},
|
||||||
|
FollowWrite::AddFollow {
|
||||||
|
follower_id: user_id.value(),
|
||||||
|
target_actor_url: "https://md.example/users/other".into(),
|
||||||
|
status: FollowStatus::Pending,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn follow_requested(user_id: &UserId) -> DomainEvent {
|
||||||
|
DomainEvent::FollowRequested {
|
||||||
|
follower: user_id.clone(),
|
||||||
|
target: domain::value_objects::FollowTarget::Handle("@other@md.example".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn social_repo(pool: &SqlitePool) -> sqlite::SqliteSocialRepository {
|
||||||
|
sqlite::SqliteSocialRepository::new(
|
||||||
|
pool.clone(),
|
||||||
|
domain::value_objects::InstanceIdentity::new("https://md.example"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_local_follow_writes_both_rows_and_its_event_in_one_commit() {
|
||||||
|
use domain::ports::FollowCommand;
|
||||||
|
let (pool, user) = pool_with_user().await;
|
||||||
|
|
||||||
|
social_repo(&pool)
|
||||||
|
.apply(&follow_writes(user.id()), &[follow_requested(user.id())])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(count(&pool, "ap_followers").await, 1);
|
||||||
|
assert_eq!(count(&pool, "ap_following").await, 1);
|
||||||
|
assert_eq!(count(&pool, "event_queue").await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failing_enqueue_rolls_back_both_follow_rows() {
|
||||||
|
use domain::ports::FollowCommand;
|
||||||
|
let (pool, user) = pool_with_user().await;
|
||||||
|
fail_event_inserts(&pool).await;
|
||||||
|
|
||||||
|
let result = social_repo(&pool)
|
||||||
|
.apply(&follow_writes(user.id()), &[follow_requested(user.id())])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(count(&pool, "ap_followers").await, 0);
|
||||||
|
assert_eq!(count(&pool, "ap_following").await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failing_follow_write_rolls_back_the_rows_already_applied() {
|
||||||
|
use domain::ports::{FollowCommand, FollowWrite};
|
||||||
|
let (pool, user) = pool_with_user().await;
|
||||||
|
let mut writes = follow_writes(user.id());
|
||||||
|
// An unknown local user violates the ap_following foreign key after the
|
||||||
|
// follower row was already written.
|
||||||
|
writes.push(FollowWrite::AddFollow {
|
||||||
|
follower_id: uuid::Uuid::new_v4(),
|
||||||
|
target_actor_url: "https://md.example/users/other".into(),
|
||||||
|
status: domain::value_objects::FollowStatus::Pending,
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = social_repo(&pool)
|
||||||
|
.apply(&writes, &[follow_requested(user.id())])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(count(&pool, "ap_followers").await, 0);
|
||||||
|
assert_eq!(count(&pool, "event_queue").await, 0);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::ports::{BlockQuery, EventPublisher, FollowGraphQuery, SocialCommand};
|
use domain::ports::{BlockQuery, FollowGraphQuery, SocialCommand};
|
||||||
|
|
||||||
pub struct SocialCommandDeps {
|
pub struct SocialCommandDeps {
|
||||||
pub social_command: Arc<dyn SocialCommand>,
|
pub social_command: Arc<dyn SocialCommand>,
|
||||||
pub event_publisher: Arc<dyn EventPublisher>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SocialQueryDeps {
|
pub struct SocialQueryDeps {
|
||||||
|
|||||||
@@ -2,61 +2,78 @@ use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId};
|
|||||||
|
|
||||||
use super::{commands::SocialCmd, deps::SocialCommandDeps};
|
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> {
|
pub async fn execute_command(deps: &SocialCommandDeps, cmd: SocialCmd) -> Result<(), DomainError> {
|
||||||
let event = match cmd {
|
let command = &deps.social_command;
|
||||||
|
match cmd {
|
||||||
SocialCmd::Follow {
|
SocialCmd::Follow {
|
||||||
follower_id,
|
follower_id,
|
||||||
target,
|
target,
|
||||||
} => {
|
} => {
|
||||||
let follower = UserId::from_uuid(follower_id);
|
let follower = UserId::from_uuid(follower_id);
|
||||||
deps.social_command.follow(&follower, &target).await?;
|
let events = [DomainEvent::FollowRequested {
|
||||||
DomainEvent::FollowRequested { follower, target }
|
follower: follower.clone(),
|
||||||
|
target: target.clone(),
|
||||||
|
}];
|
||||||
|
command.follow(&follower, &target, &events).await
|
||||||
}
|
}
|
||||||
SocialCmd::Unfollow {
|
SocialCmd::Unfollow {
|
||||||
follower_id,
|
follower_id,
|
||||||
target,
|
target,
|
||||||
} => {
|
} => {
|
||||||
let follower = UserId::from_uuid(follower_id);
|
let follower = UserId::from_uuid(follower_id);
|
||||||
deps.social_command.unfollow(&follower, &target).await?;
|
let events = [DomainEvent::Unfollowed {
|
||||||
DomainEvent::Unfollowed { follower, target }
|
follower: follower.clone(),
|
||||||
|
target: target.clone(),
|
||||||
|
}];
|
||||||
|
command.unfollow(&follower, &target, &events).await
|
||||||
}
|
}
|
||||||
SocialCmd::AcceptFollow {
|
SocialCmd::AcceptFollow {
|
||||||
owner_id,
|
owner_id,
|
||||||
requester,
|
requester,
|
||||||
} => {
|
} => {
|
||||||
let owner = UserId::from_uuid(owner_id);
|
let owner = UserId::from_uuid(owner_id);
|
||||||
deps.social_command
|
let events = [DomainEvent::FollowAccepted {
|
||||||
.accept_follow(&owner, &requester)
|
owner: owner.clone(),
|
||||||
.await?;
|
requester: requester.clone(),
|
||||||
DomainEvent::FollowAccepted { owner, requester }
|
}];
|
||||||
|
command.accept_follow(&owner, &requester, &events).await
|
||||||
}
|
}
|
||||||
SocialCmd::RejectFollow {
|
SocialCmd::RejectFollow {
|
||||||
owner_id,
|
owner_id,
|
||||||
requester,
|
requester,
|
||||||
} => {
|
} => {
|
||||||
let owner = UserId::from_uuid(owner_id);
|
let owner = UserId::from_uuid(owner_id);
|
||||||
deps.social_command
|
let events = [DomainEvent::FollowRejected {
|
||||||
.reject_follow(&owner, &requester)
|
owner: owner.clone(),
|
||||||
.await?;
|
requester: requester.clone(),
|
||||||
DomainEvent::FollowRejected { owner, requester }
|
}];
|
||||||
|
command.reject_follow(&owner, &requester, &events).await
|
||||||
}
|
}
|
||||||
SocialCmd::RemoveFollower { owner_id, follower } => {
|
SocialCmd::RemoveFollower { owner_id, follower } => {
|
||||||
let owner = UserId::from_uuid(owner_id);
|
let owner = UserId::from_uuid(owner_id);
|
||||||
deps.social_command
|
let events = [DomainEvent::FollowerRemoved {
|
||||||
.remove_follower(&owner, &follower)
|
owner: owner.clone(),
|
||||||
.await?;
|
follower: follower.clone(),
|
||||||
DomainEvent::FollowerRemoved { owner, follower }
|
}];
|
||||||
|
command.remove_follower(&owner, &follower, &events).await
|
||||||
}
|
}
|
||||||
SocialCmd::Block { blocker_id, target } => {
|
SocialCmd::Block { blocker_id, target } => {
|
||||||
let blocker = UserId::from_uuid(blocker_id);
|
let blocker = UserId::from_uuid(blocker_id);
|
||||||
deps.social_command.block(&blocker, &target).await?;
|
let events = [DomainEvent::ActorBlocked {
|
||||||
DomainEvent::ActorBlocked { blocker, target }
|
blocker: blocker.clone(),
|
||||||
|
target: target.clone(),
|
||||||
|
}];
|
||||||
|
command.block(&blocker, &target, &events).await
|
||||||
}
|
}
|
||||||
SocialCmd::Unblock { blocker_id, target } => {
|
SocialCmd::Unblock { blocker_id, target } => {
|
||||||
let blocker = UserId::from_uuid(blocker_id);
|
let blocker = UserId::from_uuid(blocker_id);
|
||||||
deps.social_command.unblock(&blocker, &target).await?;
|
let events = [DomainEvent::ActorUnblocked {
|
||||||
DomainEvent::ActorUnblocked { blocker, target }
|
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 async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
ports::{
|
ports::{
|
||||||
BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, FollowTargetResolver,
|
BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, FollowTargetResolver,
|
||||||
ResolvedFollow, SocialCommand, UserRepository,
|
FollowWrite, ResolvedFollow, SocialCommand, UserRepository,
|
||||||
},
|
},
|
||||||
value_objects::{
|
value_objects::{
|
||||||
FollowRelation, FollowStatus, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity,
|
FollowRelation, FollowStatus, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity,
|
||||||
@@ -72,27 +73,41 @@ fn remote_unsupported(actor: &str) -> DomainError {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl SocialCommand for LocalSocialService {
|
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?;
|
let identity = self.resolve_target_identity(target).await?;
|
||||||
self.follow_resolved(follower, &identity).await
|
self.follow_resolved(follower, &identity, events).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn unfollow(
|
async fn unfollow(
|
||||||
&self,
|
&self,
|
||||||
follower: &UserId,
|
follower: &UserId,
|
||||||
target: &SocialIdentity,
|
target: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let actor_url = self.instance.actor_url_of(target);
|
let actor_url = self.instance.actor_url_of(target);
|
||||||
match target {
|
match target {
|
||||||
SocialIdentity::Local(target_id) => {
|
SocialIdentity::Local(target_id) => {
|
||||||
let follower_url = self.instance.actor_url_for(follower);
|
let follower_url = self.instance.actor_url_for(follower);
|
||||||
self.follow_command
|
self.follow_command
|
||||||
.remove_follow(follower.value(), &actor_url)
|
.apply(
|
||||||
.await?;
|
&[
|
||||||
self.follow_command
|
FollowWrite::RemoveFollow {
|
||||||
.remove_follower_record(target_id.value(), &follower_url)
|
follower_id: follower.value(),
|
||||||
.await?;
|
target_actor_url: actor_url,
|
||||||
Ok(())
|
},
|
||||||
|
FollowWrite::RemoveFollowerRecord {
|
||||||
|
local_user_id: target_id.value(),
|
||||||
|
follower_actor_url: follower_url,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
events,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
|
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
|
||||||
}
|
}
|
||||||
@@ -102,18 +117,29 @@ impl SocialCommand for LocalSocialService {
|
|||||||
&self,
|
&self,
|
||||||
owner: &UserId,
|
owner: &UserId,
|
||||||
requester: &SocialIdentity,
|
requester: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let actor_url = self.instance.actor_url_of(requester);
|
let actor_url = self.instance.actor_url_of(requester);
|
||||||
match requester {
|
match requester {
|
||||||
SocialIdentity::Local(requester_id) => {
|
SocialIdentity::Local(requester_id) => {
|
||||||
let owner_url = self.instance.actor_url_for(owner);
|
let owner_url = self.instance.actor_url_for(owner);
|
||||||
self.follow_command
|
self.follow_command
|
||||||
.update_follower_status(owner.value(), &actor_url, FollowStatus::Accepted)
|
.apply(
|
||||||
.await?;
|
&[
|
||||||
self.follow_command
|
FollowWrite::UpdateFollowerStatus {
|
||||||
.update_follow_status(requester_id.value(), &owner_url, FollowStatus::Accepted)
|
local_user_id: owner.value(),
|
||||||
.await?;
|
follower_actor_url: actor_url,
|
||||||
Ok(())
|
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)),
|
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
|
||||||
}
|
}
|
||||||
@@ -123,18 +149,28 @@ impl SocialCommand for LocalSocialService {
|
|||||||
&self,
|
&self,
|
||||||
owner: &UserId,
|
owner: &UserId,
|
||||||
requester: &SocialIdentity,
|
requester: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let actor_url = self.instance.actor_url_of(requester);
|
let actor_url = self.instance.actor_url_of(requester);
|
||||||
match requester {
|
match requester {
|
||||||
SocialIdentity::Local(requester_id) => {
|
SocialIdentity::Local(requester_id) => {
|
||||||
let owner_url = self.instance.actor_url_for(owner);
|
let owner_url = self.instance.actor_url_for(owner);
|
||||||
self.follow_command
|
self.follow_command
|
||||||
.update_follower_status(owner.value(), &actor_url, FollowStatus::Rejected)
|
.apply(
|
||||||
.await?;
|
&[
|
||||||
self.follow_command
|
FollowWrite::UpdateFollowerStatus {
|
||||||
.remove_follow(requester_id.value(), &owner_url)
|
local_user_id: owner.value(),
|
||||||
.await?;
|
follower_actor_url: actor_url,
|
||||||
Ok(())
|
status: FollowStatus::Rejected,
|
||||||
|
},
|
||||||
|
FollowWrite::RemoveFollow {
|
||||||
|
follower_id: requester_id.value(),
|
||||||
|
target_actor_url: owner_url,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
events,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
|
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
|
||||||
}
|
}
|
||||||
@@ -144,24 +180,38 @@ impl SocialCommand for LocalSocialService {
|
|||||||
&self,
|
&self,
|
||||||
owner: &UserId,
|
owner: &UserId,
|
||||||
follower: &SocialIdentity,
|
follower: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let actor_url = self.instance.actor_url_of(follower);
|
let actor_url = self.instance.actor_url_of(follower);
|
||||||
match follower {
|
match follower {
|
||||||
SocialIdentity::Local(follower_id) => {
|
SocialIdentity::Local(follower_id) => {
|
||||||
let owner_url = self.instance.actor_url_for(owner);
|
let owner_url = self.instance.actor_url_for(owner);
|
||||||
self.follow_command
|
self.follow_command
|
||||||
.remove_follower_record(owner.value(), &actor_url)
|
.apply(
|
||||||
.await?;
|
&[
|
||||||
self.follow_command
|
FollowWrite::RemoveFollowerRecord {
|
||||||
.remove_follow(follower_id.value(), &owner_url)
|
local_user_id: owner.value(),
|
||||||
.await?;
|
follower_actor_url: actor_url,
|
||||||
Ok(())
|
},
|
||||||
|
FollowWrite::RemoveFollow {
|
||||||
|
follower_id: follower_id.value(),
|
||||||
|
target_actor_url: owner_url,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
events,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
|
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(
|
Err(DomainError::ValidationError(
|
||||||
"blocking requires the federation feature".into(),
|
"blocking requires the federation feature".into(),
|
||||||
))
|
))
|
||||||
@@ -171,6 +221,7 @@ impl SocialCommand for LocalSocialService {
|
|||||||
&self,
|
&self,
|
||||||
_blocker: &UserId,
|
_blocker: &UserId,
|
||||||
_target: &SocialIdentity,
|
_target: &SocialIdentity,
|
||||||
|
_events: &[DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
Err(DomainError::ValidationError(
|
Err(DomainError::ValidationError(
|
||||||
"blocking requires the federation feature".into(),
|
"blocking requires the federation feature".into(),
|
||||||
@@ -242,6 +293,7 @@ impl ResolvedFollow for LocalSocialService {
|
|||||||
&self,
|
&self,
|
||||||
follower: &UserId,
|
follower: &UserId,
|
||||||
target: &SocialIdentity,
|
target: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let SocialIdentity::Local(target_id) = target else {
|
let SocialIdentity::Local(target_id) = target else {
|
||||||
let actor_url = self.instance.actor_url_of(target);
|
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 follower_url = self.instance.actor_url_for(follower);
|
||||||
let target_url = self.instance.actor_url_for(target_id);
|
let target_url = self.instance.actor_url_for(target_id);
|
||||||
self.follow_command
|
self.follow_command
|
||||||
.add_follower(target_id.value(), &follower_url, FollowStatus::Pending)
|
.apply(
|
||||||
.await?;
|
&[
|
||||||
self.follow_command
|
FollowWrite::AddFollower {
|
||||||
.add_follow(follower.value(), &target_url, FollowStatus::Pending)
|
local_user_id: target_id.value(),
|
||||||
.await?;
|
follower_actor_url: follower_url,
|
||||||
Ok(())
|
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::{
|
use domain::{
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
testing::{InMemoryStore, NoopEventPublisher},
|
testing::{InMemoryStore, RefusingSocialCommand},
|
||||||
value_objects::{FollowStatus, FollowTarget, SocialIdentity, UserId},
|
value_objects::{FollowStatus, FollowTarget, SocialIdentity, UserId},
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -13,25 +13,19 @@ use application::social::{
|
|||||||
execute::execute_command,
|
execute::execute_command,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn make_cmd_deps() -> (
|
fn make_cmd_deps() -> (Arc<InMemoryStore>, SocialCommandDeps) {
|
||||||
Arc<InMemoryStore>,
|
|
||||||
Arc<NoopEventPublisher>,
|
|
||||||
SocialCommandDeps,
|
|
||||||
) {
|
|
||||||
let social = InMemoryStore::new();
|
let social = InMemoryStore::new();
|
||||||
let events = NoopEventPublisher::new();
|
|
||||||
let deps = SocialCommandDeps {
|
let deps = SocialCommandDeps {
|
||||||
social_command: Arc::clone(&social) as _,
|
social_command: Arc::clone(&social) as _,
|
||||||
event_publisher: Arc::clone(&events) as _,
|
|
||||||
};
|
};
|
||||||
(social, events, deps)
|
(social, deps)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Follow ──────────────────────────────────────────────────────────────────
|
// ── Follow ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn follow_emits_follow_requested_event() {
|
async fn follow_emits_follow_requested_event() {
|
||||||
let (_social, events, deps) = make_cmd_deps();
|
let (events, deps) = make_cmd_deps();
|
||||||
|
|
||||||
execute_command(
|
execute_command(
|
||||||
&deps,
|
&deps,
|
||||||
@@ -45,7 +39,7 @@ async fn follow_emits_follow_requested_event() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let published = events.published();
|
let published = events.recorded_events();
|
||||||
assert!(
|
assert!(
|
||||||
published
|
published
|
||||||
.iter()
|
.iter()
|
||||||
@@ -55,7 +49,7 @@ async fn follow_emits_follow_requested_event() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cannot_follow_yourself() {
|
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 user_id = Uuid::new_v4();
|
||||||
|
|
||||||
let result = execute_command(
|
let result = execute_command(
|
||||||
@@ -72,7 +66,7 @@ async fn cannot_follow_yourself() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn cannot_follow_same_target_twice() {
|
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 follower_id = Uuid::new_v4();
|
||||||
let target = FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(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]
|
#[tokio::test]
|
||||||
async fn unfollow_emits_unfollowed_event() {
|
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 follower_id = Uuid::new_v4();
|
||||||
let target = SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4()));
|
let target = SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4()));
|
||||||
|
|
||||||
@@ -126,7 +120,7 @@ async fn unfollow_emits_unfollowed_event() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let published = events.published();
|
let published = events.recorded_events();
|
||||||
assert!(
|
assert!(
|
||||||
published
|
published
|
||||||
.iter()
|
.iter()
|
||||||
@@ -138,7 +132,7 @@ async fn unfollow_emits_unfollowed_event() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn accept_follow_emits_follow_accepted_event() {
|
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 follower_id = Uuid::new_v4();
|
||||||
let owner_id = Uuid::new_v4();
|
let owner_id = Uuid::new_v4();
|
||||||
let requester = SocialIdentity::Local(UserId::from_uuid(follower_id));
|
let requester = SocialIdentity::Local(UserId::from_uuid(follower_id));
|
||||||
@@ -163,7 +157,7 @@ async fn accept_follow_emits_follow_accepted_event() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let published = events.published();
|
let published = events.recorded_events();
|
||||||
assert!(
|
assert!(
|
||||||
published
|
published
|
||||||
.iter()
|
.iter()
|
||||||
@@ -175,7 +169,7 @@ async fn accept_follow_emits_follow_accepted_event() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn reject_follow_emits_follow_rejected_event() {
|
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 follower_id = Uuid::new_v4();
|
||||||
let owner_id = Uuid::new_v4();
|
let owner_id = Uuid::new_v4();
|
||||||
|
|
||||||
@@ -199,7 +193,7 @@ async fn reject_follow_emits_follow_rejected_event() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let published = events.published();
|
let published = events.recorded_events();
|
||||||
assert!(
|
assert!(
|
||||||
published
|
published
|
||||||
.iter()
|
.iter()
|
||||||
@@ -211,7 +205,7 @@ async fn reject_follow_emits_follow_rejected_event() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn remove_follower_emits_follower_removed_event() {
|
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 follower_id = Uuid::new_v4();
|
||||||
let owner_id = Uuid::new_v4();
|
let owner_id = Uuid::new_v4();
|
||||||
|
|
||||||
@@ -245,7 +239,7 @@ async fn remove_follower_emits_follower_removed_event() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let published = events.published();
|
let published = events.recorded_events();
|
||||||
assert!(
|
assert!(
|
||||||
published
|
published
|
||||||
.iter()
|
.iter()
|
||||||
@@ -257,7 +251,7 @@ async fn remove_follower_emits_follower_removed_event() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn block_emits_actor_blocked_event() {
|
async fn block_emits_actor_blocked_event() {
|
||||||
let (_social, events, deps) = make_cmd_deps();
|
let (events, deps) = make_cmd_deps();
|
||||||
|
|
||||||
execute_command(
|
execute_command(
|
||||||
&deps,
|
&deps,
|
||||||
@@ -269,7 +263,7 @@ async fn block_emits_actor_blocked_event() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let published = events.published();
|
let published = events.recorded_events();
|
||||||
assert!(
|
assert!(
|
||||||
published
|
published
|
||||||
.iter()
|
.iter()
|
||||||
@@ -281,7 +275,7 @@ async fn block_emits_actor_blocked_event() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn unblock_emits_actor_unblocked_event() {
|
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 target = SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4()));
|
||||||
let blocker_id = Uuid::new_v4();
|
let blocker_id = Uuid::new_v4();
|
||||||
|
|
||||||
@@ -299,7 +293,7 @@ async fn unblock_emits_actor_unblocked_event() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let published = events.published();
|
let published = events.recorded_events();
|
||||||
assert!(
|
assert!(
|
||||||
published
|
published
|
||||||
.iter()
|
.iter()
|
||||||
@@ -311,7 +305,7 @@ async fn unblock_emits_actor_unblocked_event() {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn get_following_returns_accepted_targets() {
|
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 {
|
let query_deps = SocialQueryDeps {
|
||||||
follow_graph: Arc::clone(&social) as _,
|
follow_graph: Arc::clone(&social) as _,
|
||||||
block_query: Arc::clone(&social) as _,
|
block_query: Arc::clone(&social) as _,
|
||||||
@@ -360,10 +354,8 @@ async fn get_following_returns_accepted_targets() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn returns_accepted_followers() {
|
async fn returns_accepted_followers() {
|
||||||
let social = InMemoryStore::new();
|
let social = InMemoryStore::new();
|
||||||
let events = NoopEventPublisher::new();
|
|
||||||
let cmd_deps = SocialCommandDeps {
|
let cmd_deps = SocialCommandDeps {
|
||||||
social_command: Arc::clone(&social) as _,
|
social_command: Arc::clone(&social) as _,
|
||||||
event_publisher: Arc::clone(&events) as _,
|
|
||||||
};
|
};
|
||||||
let query_deps = SocialQueryDeps {
|
let query_deps = SocialQueryDeps {
|
||||||
follow_graph: Arc::clone(&social) as _,
|
follow_graph: Arc::clone(&social) as _,
|
||||||
@@ -404,10 +396,8 @@ async fn returns_accepted_followers() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn returns_only_pending_followers() {
|
async fn returns_only_pending_followers() {
|
||||||
let social = InMemoryStore::new();
|
let social = InMemoryStore::new();
|
||||||
let events = NoopEventPublisher::new();
|
|
||||||
let cmd_deps = SocialCommandDeps {
|
let cmd_deps = SocialCommandDeps {
|
||||||
social_command: Arc::clone(&social) as _,
|
social_command: Arc::clone(&social) as _,
|
||||||
event_publisher: Arc::clone(&events) as _,
|
|
||||||
};
|
};
|
||||||
let query_deps = SocialQueryDeps {
|
let query_deps = SocialQueryDeps {
|
||||||
follow_graph: Arc::clone(&social) as _,
|
follow_graph: Arc::clone(&social) as _,
|
||||||
@@ -439,7 +429,7 @@ async fn returns_only_pending_followers() {
|
|||||||
async fn get_relation_reports_none_for_strangers() {
|
async fn get_relation_reports_none_for_strangers() {
|
||||||
use domain::ports::FollowGraphQuery;
|
use domain::ports::FollowGraphQuery;
|
||||||
|
|
||||||
let (social, _events, _deps) = make_cmd_deps();
|
let (social, _deps) = make_cmd_deps();
|
||||||
let rel = FollowGraphQuery::get_relation(
|
let rel = FollowGraphQuery::get_relation(
|
||||||
&*social,
|
&*social,
|
||||||
&UserId::from_uuid(Uuid::new_v4()),
|
&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() {
|
async fn get_relation_reports_pending_before_acceptance() {
|
||||||
use domain::ports::FollowGraphQuery;
|
use domain::ports::FollowGraphQuery;
|
||||||
|
|
||||||
let (social, _events, deps) = make_cmd_deps();
|
let (social, deps) = make_cmd_deps();
|
||||||
let a = Uuid::new_v4();
|
let a = Uuid::new_v4();
|
||||||
let b_id = UserId::from_uuid(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() {
|
async fn get_relation_reports_accepted_in_both_directions_after_mutual_follow() {
|
||||||
use domain::ports::FollowGraphQuery;
|
use domain::ports::FollowGraphQuery;
|
||||||
|
|
||||||
let (social, _events, deps) = make_cmd_deps();
|
let (social, deps) = make_cmd_deps();
|
||||||
let a = Uuid::new_v4();
|
let a = Uuid::new_v4();
|
||||||
let b = Uuid::new_v4();
|
let b = Uuid::new_v4();
|
||||||
let a_id = UserId::from_uuid(a);
|
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() {
|
async fn unaccepted_follow_appears_in_pending_following_not_in_following() {
|
||||||
use domain::ports::FollowGraphQuery;
|
use domain::ports::FollowGraphQuery;
|
||||||
|
|
||||||
let (social, _events, deps) = make_cmd_deps();
|
let (social, deps) = make_cmd_deps();
|
||||||
let viewer = Uuid::new_v4();
|
let viewer = Uuid::new_v4();
|
||||||
let target = UserId::from_uuid(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]
|
#[tokio::test]
|
||||||
async fn get_pending_following_returns_unaccepted_targets_and_get_following_does_not() {
|
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 {
|
let query_deps = SocialQueryDeps {
|
||||||
follow_graph: Arc::clone(&social) as _,
|
follow_graph: Arc::clone(&social) as _,
|
||||||
block_query: 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]
|
#[tokio::test]
|
||||||
async fn count_pending_followers_drops_as_requests_are_accepted() {
|
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 {
|
let query_deps = SocialQueryDeps {
|
||||||
follow_graph: Arc::clone(&social) as _,
|
follow_graph: Arc::clone(&social) as _,
|
||||||
block_query: 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]
|
#[tokio::test]
|
||||||
async fn get_relation_use_case_reports_direction_asymmetrically() {
|
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 {
|
let query_deps = SocialQueryDeps {
|
||||||
follow_graph: Arc::clone(&social) as _,
|
follow_graph: Arc::clone(&social) as _,
|
||||||
block_query: 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");
|
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 async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
ports::{
|
ports::{
|
||||||
BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, SocialCommand, UserRepository,
|
BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, FollowWrite, SocialCommand,
|
||||||
|
UserRepository,
|
||||||
},
|
},
|
||||||
testing::InMemoryStore,
|
testing::InMemoryStore,
|
||||||
value_objects::{
|
value_objects::{
|
||||||
@@ -30,6 +32,8 @@ struct FollowFakeStore {
|
|||||||
follows: Mutex<Vec<(Uuid, String, FollowStatus)>>,
|
follows: Mutex<Vec<(Uuid, String, FollowStatus)>>,
|
||||||
// (local_user_id, follower_actor_url, status) — written by add_follower/update_follower_status/remove_follower_record
|
// (local_user_id, follower_actor_url, status) — written by add_follower/update_follower_status/remove_follower_record
|
||||||
followers: Mutex<Vec<(Uuid, String, FollowStatus)>>,
|
followers: Mutex<Vec<(Uuid, String, FollowStatus)>>,
|
||||||
|
recorded: Mutex<Vec<DomainEvent>>,
|
||||||
|
refuse_writes: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FollowFakeStore {
|
impl FollowFakeStore {
|
||||||
@@ -37,8 +41,23 @@ impl FollowFakeStore {
|
|||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
follows: Mutex::new(Vec::new()),
|
follows: Mutex::new(Vec::new()),
|
||||||
followers: 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 {
|
fn fake_actor(url: &str) -> SocialActor {
|
||||||
@@ -54,86 +73,63 @@ fn fake_actor(url: &str) -> SocialActor {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl FollowCommand for FollowFakeStore {
|
impl FollowCommand for FollowFakeStore {
|
||||||
async fn add_follow(
|
async fn apply(
|
||||||
&self,
|
&self,
|
||||||
follower_id: Uuid,
|
writes: &[FollowWrite],
|
||||||
target_actor_url: &str,
|
events: &[DomainEvent],
|
||||||
status: FollowStatus,
|
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
self.follows
|
if self.refuse_writes {
|
||||||
.lock()
|
return Err(DomainError::InfrastructureError("refused".into()));
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
Ok(())
|
let mut follows = self.follows.lock().unwrap();
|
||||||
}
|
let mut followers = self.followers.lock().unwrap();
|
||||||
|
for write in writes {
|
||||||
async fn remove_follow(
|
match write {
|
||||||
&self,
|
FollowWrite::AddFollow {
|
||||||
follower_id: Uuid,
|
follower_id,
|
||||||
target_actor_url: &str,
|
target_actor_url,
|
||||||
) -> Result<(), DomainError> {
|
status,
|
||||||
self.follows
|
} => follows.push((*follower_id, target_actor_url.clone(), *status)),
|
||||||
.lock()
|
FollowWrite::UpdateFollowStatus {
|
||||||
.unwrap()
|
follower_id,
|
||||||
.retain(|(f, t, _)| !(*f == follower_id && t == target_actor_url));
|
target_actor_url,
|
||||||
Ok(())
|
status,
|
||||||
}
|
} => {
|
||||||
|
if let Some(entry) = follows
|
||||||
async fn add_follower(
|
.iter_mut()
|
||||||
&self,
|
.find(|(f, t, _)| f == follower_id && t == target_actor_url)
|
||||||
local_user_id: Uuid,
|
{
|
||||||
follower_actor_url: &str,
|
entry.2 = *status;
|
||||||
status: FollowStatus,
|
}
|
||||||
) -> Result<(), DomainError> {
|
}
|
||||||
self.followers.lock().unwrap().push((
|
FollowWrite::RemoveFollow {
|
||||||
local_user_id,
|
follower_id,
|
||||||
follower_actor_url.to_string(),
|
target_actor_url,
|
||||||
status,
|
} => follows.retain(|(f, t, _)| !(f == follower_id && t == target_actor_url)),
|
||||||
));
|
FollowWrite::AddFollower {
|
||||||
Ok(())
|
local_user_id,
|
||||||
}
|
follower_actor_url,
|
||||||
|
status,
|
||||||
async fn update_follower_status(
|
} => followers.push((*local_user_id, follower_actor_url.clone(), *status)),
|
||||||
&self,
|
FollowWrite::UpdateFollowerStatus {
|
||||||
local_user_id: Uuid,
|
local_user_id,
|
||||||
follower_actor_url: &str,
|
follower_actor_url,
|
||||||
status: FollowStatus,
|
status,
|
||||||
) -> Result<(), DomainError> {
|
} => {
|
||||||
let mut store = self.followers.lock().unwrap();
|
if let Some(entry) = followers
|
||||||
if let Some(entry) = store
|
.iter_mut()
|
||||||
.iter_mut()
|
.find(|(u, f, _)| u == local_user_id && f == follower_actor_url)
|
||||||
.find(|(u, f, _)| *u == local_user_id && f == follower_actor_url)
|
{
|
||||||
{
|
entry.2 = *status;
|
||||||
entry.2 = status;
|
}
|
||||||
|
}
|
||||||
|
FollowWrite::RemoveFollowerRecord {
|
||||||
|
local_user_id,
|
||||||
|
follower_actor_url,
|
||||||
|
} => followers.retain(|(u, f, _)| !(u == local_user_id && f == follower_actor_url)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
self.recorded.lock().unwrap().extend_from_slice(events);
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,6 +276,7 @@ async fn follow_local_user_writes_both_sides() {
|
|||||||
svc.follow(
|
svc.follow(
|
||||||
&alice_id,
|
&alice_id,
|
||||||
&FollowTarget::Handle("@bob@md.example".to_string()),
|
&FollowTarget::Handle("@bob@md.example".to_string()),
|
||||||
|
&[],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -311,6 +308,7 @@ async fn follow_self_is_rejected() {
|
|||||||
.follow(
|
.follow(
|
||||||
&alice_id,
|
&alice_id,
|
||||||
&FollowTarget::Handle("@alice@md.example".to_string()),
|
&FollowTarget::Handle("@alice@md.example".to_string()),
|
||||||
|
&[],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -344,6 +342,7 @@ async fn follow_remote_target_errors_rather_than_silently_succeeding() {
|
|||||||
.follow(
|
.follow(
|
||||||
&alice_id,
|
&alice_id,
|
||||||
&FollowTarget::Handle("@carol@other.example".to_string()),
|
&FollowTarget::Handle("@carol@other.example".to_string()),
|
||||||
|
&[],
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -386,11 +385,12 @@ async fn reject_follow_updates_follower_status_but_removes_the_follow_row() {
|
|||||||
svc.follow(
|
svc.follow(
|
||||||
&requester_id,
|
&requester_id,
|
||||||
&FollowTarget::Handle("@owner@md.example".to_string()),
|
&FollowTarget::Handle("@owner@md.example".to_string()),
|
||||||
|
&[],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
svc.reject_follow(&owner_id, &SocialIdentity::Local(requester_id.clone()))
|
svc.reject_follow(&owner_id, &SocialIdentity::Local(requester_id.clone()), &[])
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -431,9 +431,60 @@ async fn block_errors_and_get_blocked_is_empty() {
|
|||||||
actor_url: "https://other.example/users/carol".to_string(),
|
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(_))));
|
assert!(matches!(result, Err(DomainError::ValidationError(_))));
|
||||||
|
|
||||||
let blocked = svc.get_blocked(&alice_id).await.unwrap();
|
let blocked = svc.get_blocked(&alice_id).await.unwrap();
|
||||||
assert_eq!(blocked.len(), 0);
|
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 owner_id = UserId::from_uuid(owner_uuid);
|
||||||
let target = FollowTarget::Identity(SocialIdentity::Local(owner_id.clone()));
|
let target = FollowTarget::Identity(SocialIdentity::Local(owner_id.clone()));
|
||||||
b.social_command
|
b.social_command
|
||||||
.follow(&UserId::from_uuid(follower1), &target)
|
.follow(&UserId::from_uuid(follower1), &target, &[])
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
b.social_command
|
b.social_command
|
||||||
.follow(&UserId::from_uuid(follower2), &target)
|
.follow(&UserId::from_uuid(follower2), &target, &[])
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -241,7 +241,6 @@ pub fn build_deps(
|
|||||||
social: SocialGroup {
|
social: SocialGroup {
|
||||||
command: SocialCommandDeps {
|
command: SocialCommandDeps {
|
||||||
social_command: repos.social_command.clone(),
|
social_command: repos.social_command.clone(),
|
||||||
event_publisher: services.event_publisher.clone(),
|
|
||||||
},
|
},
|
||||||
query: SocialQueryDeps {
|
query: SocialQueryDeps {
|
||||||
follow_graph: repos.follow_graph.clone(),
|
follow_graph: repos.follow_graph.clone(),
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ pub async fn wire_server_social(
|
|||||||
ap.service,
|
ap.service,
|
||||||
Arc::clone(&db.user),
|
Arc::clone(&db.user),
|
||||||
instance.clone(),
|
instance.clone(),
|
||||||
|
Arc::clone(event_publisher),
|
||||||
));
|
));
|
||||||
|
|
||||||
Ok(ServerSocial {
|
Ok(ServerSocial {
|
||||||
|
|||||||
@@ -145,3 +145,73 @@ async fn handler_names_are_unique() {
|
|||||||
// Image conversion ("image_conversion") is disabled in the test config.
|
// Image conversion ("image_conversion") is disabled in the test config.
|
||||||
assert_eq!(sorted, expected);
|
assert_eq!(sorted, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn queued(pool: &sqlx::SqlitePool, event_type: &str) -> i64 {
|
||||||
|
sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM event_queue WHERE event_type = ? AND event_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
.bind(event_type)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A local follow writes both follow rows and its event in one commit, in
|
||||||
|
/// both federation builds.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_local_follow_lands_with_its_event_in_the_outbox() {
|
||||||
|
let (router, pool) = support::server().await;
|
||||||
|
let alice = support::register(&router, "alice", "alice@example.com").await;
|
||||||
|
let _bob = support::register(&router, "bob", "bob@example.com").await;
|
||||||
|
|
||||||
|
let (status, _, body) = support::send(
|
||||||
|
&router,
|
||||||
|
support::json_request(
|
||||||
|
axum::http::Method::POST,
|
||||||
|
"/api/v1/social/follow",
|
||||||
|
Some(&alice.token),
|
||||||
|
serde_json::json!({ "handle": "bob@localhost:3000" }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, 200, "follow: {body}");
|
||||||
|
|
||||||
|
let following: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_following")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let followers: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_followers")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!((following, followers), (1, 1));
|
||||||
|
assert_eq!(queued(&pool, "FollowRequested").await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The k_ap path owns its transaction, so the adapter publishes the event
|
||||||
|
/// itself once the k_ap write has succeeded.
|
||||||
|
#[cfg(feature = "federation")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_remote_block_publishes_its_event_after_the_k_ap_write() {
|
||||||
|
let (router, pool) = support::server().await;
|
||||||
|
let alice = support::register(&router, "alice", "alice@example.com").await;
|
||||||
|
|
||||||
|
let (status, _, body) = support::send(
|
||||||
|
&router,
|
||||||
|
support::json_request(
|
||||||
|
axum::http::Method::POST,
|
||||||
|
"/api/v1/social/block",
|
||||||
|
Some(&alice.token),
|
||||||
|
serde_json::json!({ "actor_url": "https://remote.example/users/ann" }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, 204, "block: {body}");
|
||||||
|
|
||||||
|
let blocked: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blocked_actors")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(blocked, 1);
|
||||||
|
assert_eq!(queued(&pool, "ActorBlocked").await, 1);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,49 +2,52 @@ use async_trait::async_trait;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
value_objects::{FollowRelation, FollowStatus, SocialActor},
|
value_objects::{FollowRelation, FollowStatus, SocialActor},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// One row-level change to the local follow tables. A local follow operation
|
||||||
|
/// is a batch of these applied in a single transaction with its events.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum FollowWrite {
|
||||||
|
AddFollow {
|
||||||
|
follower_id: uuid::Uuid,
|
||||||
|
target_actor_url: String,
|
||||||
|
status: FollowStatus,
|
||||||
|
},
|
||||||
|
UpdateFollowStatus {
|
||||||
|
follower_id: uuid::Uuid,
|
||||||
|
target_actor_url: String,
|
||||||
|
status: FollowStatus,
|
||||||
|
},
|
||||||
|
RemoveFollow {
|
||||||
|
follower_id: uuid::Uuid,
|
||||||
|
target_actor_url: String,
|
||||||
|
},
|
||||||
|
AddFollower {
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
follower_actor_url: String,
|
||||||
|
status: FollowStatus,
|
||||||
|
},
|
||||||
|
UpdateFollowerStatus {
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
follower_actor_url: String,
|
||||||
|
status: FollowStatus,
|
||||||
|
},
|
||||||
|
RemoveFollowerRecord {
|
||||||
|
local_user_id: uuid::Uuid,
|
||||||
|
follower_actor_url: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait FollowCommand: Send + Sync {
|
pub trait FollowCommand: Send + Sync {
|
||||||
async fn add_follow(
|
/// Applies every write and records `events` in one transaction: all rows
|
||||||
|
/// and events land, or none do.
|
||||||
|
async fn apply(
|
||||||
&self,
|
&self,
|
||||||
follower_id: uuid::Uuid,
|
writes: &[FollowWrite],
|
||||||
target_actor_url: &str,
|
events: &[DomainEvent],
|
||||||
status: FollowStatus,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
|
|
||||||
async fn update_follow_status(
|
|
||||||
&self,
|
|
||||||
follower_id: uuid::Uuid,
|
|
||||||
target_actor_url: &str,
|
|
||||||
status: FollowStatus,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
|
|
||||||
async fn remove_follow(
|
|
||||||
&self,
|
|
||||||
follower_id: uuid::Uuid,
|
|
||||||
target_actor_url: &str,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
|
|
||||||
async fn add_follower(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
follower_actor_url: &str,
|
|
||||||
status: FollowStatus,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
|
|
||||||
async fn update_follower_status(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
follower_actor_url: &str,
|
|
||||||
status: FollowStatus,
|
|
||||||
) -> Result<(), DomainError>;
|
|
||||||
|
|
||||||
async fn remove_follower_record(
|
|
||||||
&self,
|
|
||||||
local_user_id: uuid::Uuid,
|
|
||||||
follower_actor_url: &str,
|
|
||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,25 +44,56 @@ impl super::SocialCommand for NoopSocialCommand {
|
|||||||
&self,
|
&self,
|
||||||
_: &UserId,
|
_: &UserId,
|
||||||
_: &crate::value_objects::FollowTarget,
|
_: &crate::value_objects::FollowTarget,
|
||||||
|
_: &[crate::events::DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn unfollow(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> {
|
async fn unfollow(
|
||||||
|
&self,
|
||||||
|
_: &UserId,
|
||||||
|
_: &SocialIdentity,
|
||||||
|
_: &[crate::events::DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn accept_follow(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> {
|
async fn accept_follow(
|
||||||
|
&self,
|
||||||
|
_: &UserId,
|
||||||
|
_: &SocialIdentity,
|
||||||
|
_: &[crate::events::DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn reject_follow(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> {
|
async fn reject_follow(
|
||||||
|
&self,
|
||||||
|
_: &UserId,
|
||||||
|
_: &SocialIdentity,
|
||||||
|
_: &[crate::events::DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn remove_follower(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> {
|
async fn remove_follower(
|
||||||
|
&self,
|
||||||
|
_: &UserId,
|
||||||
|
_: &SocialIdentity,
|
||||||
|
_: &[crate::events::DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn block(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> {
|
async fn block(
|
||||||
|
&self,
|
||||||
|
_: &UserId,
|
||||||
|
_: &SocialIdentity,
|
||||||
|
_: &[crate::events::DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn unblock(&self, _: &UserId, _: &SocialIdentity) -> Result<(), DomainError> {
|
async fn unblock(
|
||||||
|
&self,
|
||||||
|
_: &UserId,
|
||||||
|
_: &SocialIdentity,
|
||||||
|
_: &[crate::events::DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use chrono::NaiveDateTime;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
models::{
|
models::{
|
||||||
DiaryEntry, FederationFlags, RemoteActorInfo, RemoteGoalEntry, RemoteWatchlistEntry,
|
DiaryEntry, FederationFlags, RemoteActorInfo, RemoteGoalEntry, RemoteWatchlistEntry,
|
||||||
WatchlistWithMovie,
|
WatchlistWithMovie,
|
||||||
@@ -14,32 +15,54 @@ use crate::{
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait SocialCommand: Send + Sync {
|
pub trait SocialCommand: Send + Sync {
|
||||||
async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError>;
|
async fn follow(
|
||||||
|
&self,
|
||||||
|
follower: &UserId,
|
||||||
|
target: &FollowTarget,
|
||||||
|
events: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
async fn unfollow(&self, follower: &UserId, target: &SocialIdentity)
|
async fn unfollow(
|
||||||
-> Result<(), DomainError>;
|
&self,
|
||||||
|
follower: &UserId,
|
||||||
|
target: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
async fn accept_follow(
|
async fn accept_follow(
|
||||||
&self,
|
&self,
|
||||||
owner: &UserId,
|
owner: &UserId,
|
||||||
requester: &SocialIdentity,
|
requester: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
async fn reject_follow(
|
async fn reject_follow(
|
||||||
&self,
|
&self,
|
||||||
owner: &UserId,
|
owner: &UserId,
|
||||||
requester: &SocialIdentity,
|
requester: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
async fn remove_follower(
|
async fn remove_follower(
|
||||||
&self,
|
&self,
|
||||||
owner: &UserId,
|
owner: &UserId,
|
||||||
follower: &SocialIdentity,
|
follower: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError>;
|
async fn block(
|
||||||
|
&self,
|
||||||
|
blocker: &UserId,
|
||||||
|
target: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
async fn unblock(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError>;
|
async fn unblock(
|
||||||
|
&self,
|
||||||
|
blocker: &UserId,
|
||||||
|
target: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -138,6 +161,7 @@ pub trait ResolvedFollow: Send + Sync {
|
|||||||
&self,
|
&self,
|
||||||
follower: &UserId,
|
follower: &UserId,
|
||||||
target: &SocialIdentity,
|
target: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ use crate::{
|
|||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
},
|
},
|
||||||
ports::{
|
ports::{
|
||||||
EventPublisher, GoalCommand, MovieCommand, ReviewRepository, UserProfileFieldsRepository,
|
EventPublisher, FollowCommand, FollowWrite, GoalCommand, MovieCommand, ReviewRepository,
|
||||||
WatchEventCommand, WatchlistRepository,
|
SocialCommand, UserProfileFieldsRepository, WatchEventCommand, WatchlistRepository,
|
||||||
|
},
|
||||||
|
value_objects::{
|
||||||
|
FollowTarget, GoalId, MovieId, ReviewId, SocialIdentity, UserId, WatchEventId,
|
||||||
},
|
},
|
||||||
value_objects::{GoalId, MovieId, ReviewId, UserId, WatchEventId},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fn refused() -> DomainError {
|
fn refused() -> DomainError {
|
||||||
@@ -151,3 +153,74 @@ impl WatchEventCommand for RefusingWatchEventCommand {
|
|||||||
Err(refused())
|
Err(refused())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct RefusingSocialCommand;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SocialCommand for RefusingSocialCommand {
|
||||||
|
async fn follow(
|
||||||
|
&self,
|
||||||
|
_: &UserId,
|
||||||
|
_: &FollowTarget,
|
||||||
|
_: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
Err(refused())
|
||||||
|
}
|
||||||
|
async fn unfollow(
|
||||||
|
&self,
|
||||||
|
_: &UserId,
|
||||||
|
_: &SocialIdentity,
|
||||||
|
_: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
Err(refused())
|
||||||
|
}
|
||||||
|
async fn accept_follow(
|
||||||
|
&self,
|
||||||
|
_: &UserId,
|
||||||
|
_: &SocialIdentity,
|
||||||
|
_: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
Err(refused())
|
||||||
|
}
|
||||||
|
async fn reject_follow(
|
||||||
|
&self,
|
||||||
|
_: &UserId,
|
||||||
|
_: &SocialIdentity,
|
||||||
|
_: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
Err(refused())
|
||||||
|
}
|
||||||
|
async fn remove_follower(
|
||||||
|
&self,
|
||||||
|
_: &UserId,
|
||||||
|
_: &SocialIdentity,
|
||||||
|
_: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
Err(refused())
|
||||||
|
}
|
||||||
|
async fn block(
|
||||||
|
&self,
|
||||||
|
_: &UserId,
|
||||||
|
_: &SocialIdentity,
|
||||||
|
_: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
Err(refused())
|
||||||
|
}
|
||||||
|
async fn unblock(
|
||||||
|
&self,
|
||||||
|
_: &UserId,
|
||||||
|
_: &SocialIdentity,
|
||||||
|
_: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
Err(refused())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RefusingFollowCommand;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FollowCommand for RefusingFollowCommand {
|
||||||
|
async fn apply(&self, _: &[FollowWrite], _: &[DomainEvent]) -> Result<(), DomainError> {
|
||||||
|
Err(refused())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use async_trait::async_trait;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
ports::{BlockQuery, FollowGraphQuery, SocialCommand},
|
ports::{BlockQuery, FollowGraphQuery, SocialCommand},
|
||||||
value_objects::{FollowRelation, FollowStatus, SocialActor, SocialIdentity, UserId},
|
value_objects::{FollowRelation, FollowStatus, SocialActor, SocialIdentity, UserId},
|
||||||
};
|
};
|
||||||
@@ -28,6 +29,7 @@ impl SocialCommand for InMemoryStore {
|
|||||||
&self,
|
&self,
|
||||||
follower: &UserId,
|
follower: &UserId,
|
||||||
target: &crate::value_objects::FollowTarget,
|
target: &crate::value_objects::FollowTarget,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let identity = match target {
|
let identity = match target {
|
||||||
crate::value_objects::FollowTarget::Identity(id) => id.clone(),
|
crate::value_objects::FollowTarget::Identity(id) => id.clone(),
|
||||||
@@ -50,6 +52,8 @@ impl SocialCommand for InMemoryStore {
|
|||||||
return Err(DomainError::ValidationError("Already following".into()));
|
return Err(DomainError::ValidationError("Already following".into()));
|
||||||
}
|
}
|
||||||
store.push((follower.value(), identity, FollowState::Pending));
|
store.push((follower.value(), identity, FollowState::Pending));
|
||||||
|
drop(store);
|
||||||
|
self.record_events(events);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +61,7 @@ impl SocialCommand for InMemoryStore {
|
|||||||
&self,
|
&self,
|
||||||
follower: &UserId,
|
follower: &UserId,
|
||||||
target: &SocialIdentity,
|
target: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let mut store = self.follows.write().unwrap();
|
let mut store = self.follows.write().unwrap();
|
||||||
let before = store.len();
|
let before = store.len();
|
||||||
@@ -66,6 +71,8 @@ impl SocialCommand for InMemoryStore {
|
|||||||
"Follow relationship not found".into(),
|
"Follow relationship not found".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
drop(store);
|
||||||
|
self.record_events(events);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +80,7 @@ impl SocialCommand for InMemoryStore {
|
|||||||
&self,
|
&self,
|
||||||
owner: &UserId,
|
owner: &UserId,
|
||||||
requester: &SocialIdentity,
|
requester: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let mut store = self.follows.write().unwrap();
|
let mut store = self.follows.write().unwrap();
|
||||||
let target_identity = SocialIdentity::Local(owner.clone());
|
let target_identity = SocialIdentity::Local(owner.clone());
|
||||||
@@ -92,6 +100,8 @@ impl SocialCommand for InMemoryStore {
|
|||||||
};
|
};
|
||||||
if requester_matches && *t == target_identity && *state == FollowState::Pending {
|
if requester_matches && *t == target_identity && *state == FollowState::Pending {
|
||||||
*state = FollowState::Accepted;
|
*state = FollowState::Accepted;
|
||||||
|
drop(store);
|
||||||
|
self.record_events(events);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,6 +114,7 @@ impl SocialCommand for InMemoryStore {
|
|||||||
&self,
|
&self,
|
||||||
owner: &UserId,
|
owner: &UserId,
|
||||||
requester: &SocialIdentity,
|
requester: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let mut store = self.follows.write().unwrap();
|
let mut store = self.follows.write().unwrap();
|
||||||
let target_identity = SocialIdentity::Local(owner.clone());
|
let target_identity = SocialIdentity::Local(owner.clone());
|
||||||
@@ -123,6 +134,8 @@ impl SocialCommand for InMemoryStore {
|
|||||||
"Pending follow request not found".into(),
|
"Pending follow request not found".into(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
drop(store);
|
||||||
|
self.record_events(events);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +143,7 @@ impl SocialCommand for InMemoryStore {
|
|||||||
&self,
|
&self,
|
||||||
owner: &UserId,
|
owner: &UserId,
|
||||||
follower: &SocialIdentity,
|
follower: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let mut store = self.follows.write().unwrap();
|
let mut store = self.follows.write().unwrap();
|
||||||
let target_identity = SocialIdentity::Local(owner.clone());
|
let target_identity = SocialIdentity::Local(owner.clone());
|
||||||
@@ -144,21 +158,38 @@ impl SocialCommand for InMemoryStore {
|
|||||||
if store.len() == before {
|
if store.len() == before {
|
||||||
return Err(DomainError::NotFound("Follower not found".into()));
|
return Err(DomainError::NotFound("Follower not found".into()));
|
||||||
}
|
}
|
||||||
|
drop(store);
|
||||||
|
self.record_events(events);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
|
async fn block(
|
||||||
|
&self,
|
||||||
|
blocker: &UserId,
|
||||||
|
target: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
let mut store = self.blocked.write().unwrap();
|
let mut store = self.blocked.write().unwrap();
|
||||||
store.push((blocker.value(), target.clone()));
|
store.push((blocker.value(), target.clone()));
|
||||||
// Also remove any existing follow relationships
|
// Also remove any existing follow relationships
|
||||||
let mut follows = self.follows.write().unwrap();
|
let mut follows = self.follows.write().unwrap();
|
||||||
follows.retain(|(f, t, _)| !(*f == blocker.value() && t == target));
|
follows.retain(|(f, t, _)| !(*f == blocker.value() && t == target));
|
||||||
|
drop(follows);
|
||||||
|
drop(store);
|
||||||
|
self.record_events(events);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn unblock(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
|
async fn unblock(
|
||||||
|
&self,
|
||||||
|
blocker: &UserId,
|
||||||
|
target: &SocialIdentity,
|
||||||
|
events: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
let mut store = self.blocked.write().unwrap();
|
let mut store = self.blocked.write().unwrap();
|
||||||
store.retain(|(b, t)| !(*b == blocker.value() && t == target));
|
store.retain(|(b, t)| !(*b == blocker.value() && t == target));
|
||||||
|
drop(store);
|
||||||
|
self.record_events(events);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ impl SocialCommand for Stub {
|
|||||||
&self,
|
&self,
|
||||||
_: &domain::value_objects::UserId,
|
_: &domain::value_objects::UserId,
|
||||||
_: &domain::value_objects::FollowTarget,
|
_: &domain::value_objects::FollowTarget,
|
||||||
|
_: &[domain::events::DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -21,6 +22,7 @@ impl SocialCommand for Stub {
|
|||||||
&self,
|
&self,
|
||||||
_: &domain::value_objects::UserId,
|
_: &domain::value_objects::UserId,
|
||||||
_: &domain::value_objects::SocialIdentity,
|
_: &domain::value_objects::SocialIdentity,
|
||||||
|
_: &[domain::events::DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -28,6 +30,7 @@ impl SocialCommand for Stub {
|
|||||||
&self,
|
&self,
|
||||||
_: &domain::value_objects::UserId,
|
_: &domain::value_objects::UserId,
|
||||||
_: &domain::value_objects::SocialIdentity,
|
_: &domain::value_objects::SocialIdentity,
|
||||||
|
_: &[domain::events::DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -35,6 +38,7 @@ impl SocialCommand for Stub {
|
|||||||
&self,
|
&self,
|
||||||
_: &domain::value_objects::UserId,
|
_: &domain::value_objects::UserId,
|
||||||
_: &domain::value_objects::SocialIdentity,
|
_: &domain::value_objects::SocialIdentity,
|
||||||
|
_: &[domain::events::DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -42,6 +46,7 @@ impl SocialCommand for Stub {
|
|||||||
&self,
|
&self,
|
||||||
_: &domain::value_objects::UserId,
|
_: &domain::value_objects::UserId,
|
||||||
_: &domain::value_objects::SocialIdentity,
|
_: &domain::value_objects::SocialIdentity,
|
||||||
|
_: &[domain::events::DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -49,6 +54,7 @@ impl SocialCommand for Stub {
|
|||||||
&self,
|
&self,
|
||||||
_: &domain::value_objects::UserId,
|
_: &domain::value_objects::UserId,
|
||||||
_: &domain::value_objects::SocialIdentity,
|
_: &domain::value_objects::SocialIdentity,
|
||||||
|
_: &[domain::events::DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -56,6 +62,7 @@ impl SocialCommand for Stub {
|
|||||||
&self,
|
&self,
|
||||||
_: &domain::value_objects::UserId,
|
_: &domain::value_objects::UserId,
|
||||||
_: &domain::value_objects::SocialIdentity,
|
_: &domain::value_objects::SocialIdentity,
|
||||||
|
_: &[domain::events::DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -145,6 +152,7 @@ impl ResolvedFollow for Stub {
|
|||||||
&self,
|
&self,
|
||||||
_: &domain::value_objects::UserId,
|
_: &domain::value_objects::UserId,
|
||||||
_: &SocialIdentity,
|
_: &SocialIdentity,
|
||||||
|
_: &[domain::events::DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ async fn seed_user_with_dependents(store: &Arc<InMemoryStore>) -> (UserId, Movie
|
|||||||
&FollowTarget::Identity(SocialIdentity::Remote {
|
&FollowTarget::Identity(SocialIdentity::Remote {
|
||||||
actor_url: REMOTE_ACTOR.into(),
|
actor_url: REMOTE_ACTOR.into(),
|
||||||
}),
|
}),
|
||||||
|
&[],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -135,6 +136,7 @@ async fn seed_user_with_dependents(store: &Arc<InMemoryStore>) -> (UserId, Movie
|
|||||||
.follow(
|
.follow(
|
||||||
other.id(),
|
other.id(),
|
||||||
&FollowTarget::Identity(SocialIdentity::Local(user_id.clone())),
|
&FollowTarget::Identity(SocialIdentity::Local(user_id.clone())),
|
||||||
|
&[],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -144,6 +146,7 @@ async fn seed_user_with_dependents(store: &Arc<InMemoryStore>) -> (UserId, Movie
|
|||||||
&SocialIdentity::Remote {
|
&SocialIdentity::Remote {
|
||||||
actor_url: REMOTE_ACTOR.into(),
|
actor_url: REMOTE_ACTOR.into(),
|
||||||
},
|
},
|
||||||
|
&[],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user