structural refactor and codebase improvements

This commit is contained in:
2026-08-09 14:58:14 +02:00
parent 22b1dd3f56
commit c9715baab8
247 changed files with 11515 additions and 3063 deletions

View File

@@ -15,5 +15,8 @@ serde_json = { workspace = true }
email_address = "0.2.9"
[dev-dependencies]
tokio = { workspace = true }
[features]
test-helpers = []

View File

@@ -38,3 +38,23 @@ pub struct FederatedProfile {
pub avatar_url: Option<String>,
pub banner_url: Option<String>,
}
/// A domain-blocklist entry as presented to admin surfaces. Mirrors what the
/// federation adapter can supply; `blocked_at` is a pre-formatted string
/// because that is what the underlying store returns and every consumer
/// renders it verbatim.
#[derive(Debug, Clone)]
pub struct BlockedDomainInfo {
pub domain: String,
pub reason: Option<String>,
pub blocked_at: String,
}
/// A remote actor the local instance follows, reduced to the two fields any
/// consumer actually reads. Deliberately narrower than the federation
/// library's actor type — widening it is a decision, not an oversight.
#[derive(Debug, Clone)]
pub struct FollowedActorInfo {
pub url: String,
pub outbox_url: Option<String>,
}

View File

@@ -0,0 +1,103 @@
use async_trait::async_trait;
use uuid::Uuid;
use crate::{
errors::DomainError,
models::{BlockedDomainInfo, FollowedActorInfo},
};
/// Serves ActivityPub documents for content negotiation. Presentation calls
/// this when a peer sends `Accept: application/activity+json`.
#[async_trait]
pub trait ApDocumentPort: Send + Sync {
async fn actor_json(&self, user_id: &str) -> Result<String, DomainError>;
async fn followers_collection_json(
&self,
user_id: Uuid,
page: Option<u32>,
) -> Result<String, DomainError>;
async fn following_collection_json(
&self,
user_id: Uuid,
page: Option<u32>,
) -> Result<String, DomainError>;
}
/// Instance-wide domain blocklist administration. Presentation calls this from
/// the admin API and the admin HTML pages; nothing outside presentation does.
#[async_trait]
pub trait InstanceBlocklistPort: Send + Sync {
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomainInfo>, DomainError>;
async fn add_blocked_domain(
&self,
domain: &str,
reason: Option<&str>,
) -> Result<(), DomainError>;
async fn remove_blocked_domain(&self, domain: &str) -> Result<(), DomainError>;
}
/// Pulling remote content in, and pushing local content out, after a follow
/// is established. Worker-side only — no HTTP handler calls this.
#[async_trait]
pub trait ApBackfillPort: Send + Sync {
async fn get_following(
&self,
local_user_id: Uuid,
) -> Result<Vec<FollowedActorInfo>, DomainError>;
async fn import_remote_outbox(
&self,
outbox_url: &str,
actor_url: &str,
) -> Result<(), DomainError>;
async fn run_backfill_for_follower(
&self,
owner_user_id: Uuid,
follower_inbox_url: String,
) -> Result<(), DomainError>;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ports::noop::{NoopApBackfill, NoopApDocument, NoopInstanceBlocklist};
#[tokio::test]
async fn noop_document_returns_empty_strings() {
let p = NoopApDocument;
assert_eq!(p.actor_json("anything").await.unwrap(), "");
assert_eq!(
p.followers_collection_json(uuid::Uuid::nil(), None)
.await
.unwrap(),
""
);
assert_eq!(
p.following_collection_json(uuid::Uuid::nil(), Some(2))
.await
.unwrap(),
""
);
}
#[tokio::test]
async fn noop_blocklist_returns_empty_and_ok() {
let p = NoopInstanceBlocklist;
assert!(p.get_blocked_domains().await.unwrap().is_empty());
p.add_blocked_domain("evil.example", Some("spam"))
.await
.unwrap();
p.remove_blocked_domain("evil.example").await.unwrap();
}
#[tokio::test]
async fn noop_backfill_returns_empty_and_ok() {
let p = NoopApBackfill;
assert!(p.get_following(uuid::Uuid::nil()).await.unwrap().is_empty());
p.import_remote_outbox("https://a/outbox", "https://a")
.await
.unwrap();
p.run_backfill_for_follower(uuid::Uuid::nil(), "https://a/inbox".into())
.await
.unwrap();
}
}

View File

@@ -2,7 +2,7 @@ use async_trait::async_trait;
use crate::{
errors::DomainError,
value_objects::{FollowStatus, SocialActor},
value_objects::{FollowRelation, FollowStatus, SocialActor},
};
#[async_trait]
@@ -50,31 +50,29 @@ pub trait FollowCommand: Send + Sync {
#[async_trait]
pub trait FollowQuery: Send + Sync {
async fn get_following(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError>;
async fn get_following(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError>;
async fn get_followers(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError>;
async fn get_followers(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError>;
async fn get_pending_followers(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError>;
async fn get_pending_following(
&self,
user_id: uuid::Uuid,
) -> Result<Vec<SocialActor>, DomainError>;
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError>;
async fn count_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError>;
async fn is_following(
async fn count_pending_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError>;
async fn get_relation(
&self,
follower_id: uuid::Uuid,
viewer_id: uuid::Uuid,
target_actor_url: &str,
) -> Result<bool, DomainError>;
) -> Result<FollowRelation, DomainError>;
}

View File

@@ -2,6 +2,7 @@ pub mod auth;
pub mod diary;
pub mod events;
pub mod federated_profile;
pub mod federation;
pub mod follow;
pub mod goals;
pub mod image_fetcher;
@@ -22,6 +23,7 @@ pub use auth::*;
pub use diary::*;
pub use events::*;
pub use federated_profile::*;
pub use federation::*;
pub use follow::*;
pub use goals::*;
pub use image_fetcher::*;

View File

@@ -2,7 +2,7 @@ use async_trait::async_trait;
use crate::{
errors::DomainError,
value_objects::{SocialActor, SocialIdentity, UserId},
value_objects::{FollowRelation, SocialActor, SocialIdentity, UserId},
};
// ── NoopRemoteWatchlistRepository ─────────────────────────────────────────────
@@ -73,7 +73,7 @@ impl super::SocialCommand for NoopSocialCommand {
pub struct NoopSocialQuery;
#[async_trait]
impl super::SocialQuery for NoopSocialQuery {
impl super::FollowGraphQuery for NoopSocialQuery {
async fn get_following(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
Ok(vec![])
}
@@ -83,18 +83,32 @@ impl super::SocialQuery for NoopSocialQuery {
async fn get_pending_followers(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
Ok(vec![])
}
async fn get_pending_following(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
Ok(vec![])
}
async fn count_following(&self, _: &UserId) -> Result<usize, DomainError> {
Ok(0)
}
async fn count_followers(&self, _: &UserId) -> Result<usize, DomainError> {
Ok(0)
}
async fn count_pending_followers(&self, _: &UserId) -> Result<usize, DomainError> {
Ok(0)
}
async fn get_relation(
&self,
_: &UserId,
_: &SocialIdentity,
) -> Result<FollowRelation, DomainError> {
Ok(FollowRelation::default())
}
}
#[async_trait]
impl super::BlockQuery for NoopSocialQuery {
async fn get_blocked(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
Ok(vec![])
}
async fn is_following(&self, _: &UserId, _: &SocialIdentity) -> Result<bool, DomainError> {
Ok(false)
}
}
// ── NoopFederationAdminQuery ─────────────────────────────────────────────────
@@ -110,3 +124,70 @@ impl super::FederationAdminQuery for NoopFederationAdminQuery {
Ok(vec![])
}
}
// ── NoopApDocument ───────────────────────────────────────────────────────────
/// Stub used when federation is disabled — every operation is a no-op.
pub struct NoopApDocument;
#[async_trait]
impl super::ApDocumentPort for NoopApDocument {
async fn actor_json(&self, _: &str) -> Result<String, DomainError> {
Ok(String::new())
}
async fn followers_collection_json(
&self,
_: uuid::Uuid,
_: Option<u32>,
) -> Result<String, DomainError> {
Ok(String::new())
}
async fn following_collection_json(
&self,
_: uuid::Uuid,
_: Option<u32>,
) -> Result<String, DomainError> {
Ok(String::new())
}
}
// ── NoopInstanceBlocklist ────────────────────────────────────────────────────
/// Stub used when federation is disabled — every operation is a no-op.
pub struct NoopInstanceBlocklist;
#[async_trait]
impl super::InstanceBlocklistPort for NoopInstanceBlocklist {
async fn get_blocked_domains(
&self,
) -> Result<Vec<crate::models::BlockedDomainInfo>, DomainError> {
Ok(vec![])
}
async fn add_blocked_domain(&self, _: &str, _: Option<&str>) -> Result<(), DomainError> {
Ok(())
}
async fn remove_blocked_domain(&self, _: &str) -> Result<(), DomainError> {
Ok(())
}
}
// ── NoopApBackfill ───────────────────────────────────────────────────────────
/// Stub used when federation is disabled — every operation is a no-op.
pub struct NoopApBackfill;
#[async_trait]
impl super::ApBackfillPort for NoopApBackfill {
async fn get_following(
&self,
_: uuid::Uuid,
) -> Result<Vec<crate::models::FollowedActorInfo>, DomainError> {
Ok(vec![])
}
async fn import_remote_outbox(&self, _: &str, _: &str) -> Result<(), DomainError> {
Ok(())
}
async fn run_backfill_for_follower(&self, _: uuid::Uuid, _: String) -> Result<(), DomainError> {
Ok(())
}
}

View File

@@ -7,7 +7,7 @@ use crate::{
DiaryEntry, FederationFlags, RemoteActorInfo, RemoteGoalEntry, RemoteWatchlistEntry,
WatchlistWithMovie,
},
value_objects::{FollowTarget, MovieId, SocialActor, SocialIdentity, UserId},
value_objects::{FollowRelation, FollowTarget, MovieId, SocialActor, SocialIdentity, UserId},
};
// ── Unified social ports (ADR-0002) ─────────────────────────────────────────
@@ -43,24 +43,31 @@ pub trait SocialCommand: Send + Sync {
}
#[async_trait]
pub trait SocialQuery: Send + Sync {
pub trait FollowGraphQuery: Send + Sync {
async fn get_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError>;
async fn get_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError>;
async fn get_pending_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError>;
async fn get_pending_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError>;
async fn count_following(&self, user: &UserId) -> Result<usize, DomainError>;
async fn count_followers(&self, user: &UserId) -> Result<usize, DomainError>;
async fn get_blocked(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError>;
async fn count_pending_followers(&self, user: &UserId) -> Result<usize, DomainError>;
async fn is_following(
async fn get_relation(
&self,
follower: &UserId,
viewer: &UserId,
target: &SocialIdentity,
) -> Result<bool, DomainError>;
) -> Result<FollowRelation, DomainError>;
}
#[async_trait]
pub trait BlockQuery: Send + Sync {
async fn get_blocked(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError>;
}
#[async_trait]
@@ -119,3 +126,223 @@ pub trait LocalApContentQuery: Send + Sync {
limit: usize,
) -> Result<Vec<DiaryEntry>, DomainError>;
}
/// Resolves a `FollowTarget` (a handle or an already-known identity) to the
/// `SocialIdentity` that should be dispatched on — local vs. remote.
///
/// Split out from `SocialCommand::follow` because the decision must happen
/// once, before dispatch, and both `LocalSocialService` and
/// `CompositeSocialAdapter` need to observe its result rather than each
/// re-deriving it (which would need `UserRepository` on the composite and
/// duplicate the local/remote fallthrough logic).
#[async_trait]
pub trait FollowTargetResolver: Send + Sync {
async fn resolve_target(&self, target: &FollowTarget) -> Result<SocialIdentity, DomainError>;
}
/// Performs the local follow write for a target whose `SocialIdentity` a caller
/// has *already* resolved via `FollowTargetResolver::resolve_target`.
///
/// Exists so `CompositeSocialAdapter` — which must call `resolve_target` first
/// to decide local vs. remote dispatch — doesn't then hand the raw
/// `FollowTarget` to `SocialCommand::follow` and pay for a second resolution of
/// the same handle. That second resolution wasn't just wasteful: on a
/// federation-ON deployment it could also change the answer (the target user
/// deleted between the two calls, e.g.), turning a local follow into a bogus
/// "federation is not enabled" error.
///
/// `LocalSocialService::follow` is `resolve_target_identity` then
/// `follow_resolved` — the self-follow guard and the two-sided write live only
/// in the latter, so a caller that already has the identity (the composite)
/// and one that doesn't (federation-off `SocialCommand::follow`, wired
/// directly) both end up running the exact same write path.
#[async_trait]
pub trait ResolvedFollow: Send + Sync {
async fn follow_resolved(
&self,
follower: &UserId,
target: &SocialIdentity,
) -> Result<(), DomainError>;
}
/// The subset of social behavior that needs no ActivityPub — everything a
/// single-instance deployment can do with only its own database.
///
/// A marker supertrait with a blanket impl, so any type implementing all five
/// parts is usable as `Arc<dyn LocalSocial>` without a separate registration.
/// Same shape as `k_ap::FollowRepository` over its five follow traits.
///
/// `CompositeSocialAdapter` takes one of these for its local branches, which is
/// what lets the `activitypub` crate stay unaware of `application`.
pub trait LocalSocial:
SocialCommand + FollowGraphQuery + BlockQuery + FollowTargetResolver + ResolvedFollow
{
}
impl<T: SocialCommand + FollowGraphQuery + BlockQuery + FollowTargetResolver + ResolvedFollow>
LocalSocial for T
{
}
#[cfg(test)]
mod local_social_tests {
use super::*;
use std::sync::Arc;
struct Stub;
#[async_trait]
impl SocialCommand for Stub {
async fn follow(
&self,
_: &crate::value_objects::UserId,
_: &crate::value_objects::FollowTarget,
) -> Result<(), DomainError> {
Ok(())
}
async fn unfollow(
&self,
_: &crate::value_objects::UserId,
_: &crate::value_objects::SocialIdentity,
) -> Result<(), DomainError> {
Ok(())
}
async fn accept_follow(
&self,
_: &crate::value_objects::UserId,
_: &crate::value_objects::SocialIdentity,
) -> Result<(), DomainError> {
Ok(())
}
async fn reject_follow(
&self,
_: &crate::value_objects::UserId,
_: &crate::value_objects::SocialIdentity,
) -> Result<(), DomainError> {
Ok(())
}
async fn remove_follower(
&self,
_: &crate::value_objects::UserId,
_: &crate::value_objects::SocialIdentity,
) -> Result<(), DomainError> {
Ok(())
}
async fn block(
&self,
_: &crate::value_objects::UserId,
_: &crate::value_objects::SocialIdentity,
) -> Result<(), DomainError> {
Ok(())
}
async fn unblock(
&self,
_: &crate::value_objects::UserId,
_: &crate::value_objects::SocialIdentity,
) -> Result<(), DomainError> {
Ok(())
}
}
#[async_trait]
impl FollowGraphQuery for Stub {
async fn get_following(
&self,
_: &crate::value_objects::UserId,
) -> Result<Vec<crate::value_objects::SocialActor>, DomainError> {
Ok(vec![])
}
async fn get_followers(
&self,
_: &crate::value_objects::UserId,
) -> Result<Vec<crate::value_objects::SocialActor>, DomainError> {
Ok(vec![])
}
async fn get_pending_followers(
&self,
_: &crate::value_objects::UserId,
) -> Result<Vec<crate::value_objects::SocialActor>, DomainError> {
Ok(vec![])
}
async fn get_pending_following(
&self,
_: &crate::value_objects::UserId,
) -> Result<Vec<crate::value_objects::SocialActor>, DomainError> {
Ok(vec![])
}
async fn count_following(
&self,
_: &crate::value_objects::UserId,
) -> Result<usize, DomainError> {
Ok(0)
}
async fn count_followers(
&self,
_: &crate::value_objects::UserId,
) -> Result<usize, DomainError> {
Ok(0)
}
async fn count_pending_followers(
&self,
_: &crate::value_objects::UserId,
) -> Result<usize, DomainError> {
Ok(0)
}
async fn get_relation(
&self,
_: &crate::value_objects::UserId,
_: &crate::value_objects::SocialIdentity,
) -> Result<crate::value_objects::FollowRelation, DomainError> {
Err(DomainError::NotFound("stub".into()))
}
}
#[async_trait]
impl BlockQuery for Stub {
async fn get_blocked(
&self,
_: &crate::value_objects::UserId,
) -> Result<Vec<crate::value_objects::SocialActor>, DomainError> {
Ok(vec![])
}
}
#[async_trait]
impl FollowTargetResolver for Stub {
async fn resolve_target(
&self,
target: &crate::value_objects::FollowTarget,
) -> Result<SocialIdentity, DomainError> {
match target {
crate::value_objects::FollowTarget::Identity(id) => Ok(id.clone()),
crate::value_objects::FollowTarget::Handle(handle) => Ok(SocialIdentity::Remote {
actor_url: handle.clone(),
}),
}
}
}
#[async_trait]
impl ResolvedFollow for Stub {
async fn follow_resolved(
&self,
_: &crate::value_objects::UserId,
_: &SocialIdentity,
) -> Result<(), DomainError> {
Ok(())
}
}
/// The blanket impl must make any type implementing all five usable as
/// `Arc<dyn LocalSocial>`, and `LocalSocial` must stay object-safe.
#[test]
fn blanket_impl_yields_a_trait_object() {
let local: Arc<dyn LocalSocial> = Arc::new(Stub);
// Reachable through each supertrait without a separate Arc.
let _: &dyn SocialCommand = local.as_ref();
let _: &dyn FollowGraphQuery = local.as_ref();
let _: &dyn BlockQuery = local.as_ref();
let _: &dyn FollowTargetResolver = local.as_ref();
let _: &dyn ResolvedFollow = local.as_ref();
}
}

View File

@@ -85,12 +85,14 @@ impl MetadataClient for FakeMetadataClient {
pub struct FakeDiaryQuery {
histories: Mutex<HashMap<Uuid, (Movie, Vec<Review>)>>,
diary_page: Mutex<Option<Paginated<DiaryEntry>>>,
}
impl FakeDiaryQuery {
pub fn new() -> Arc<Self> {
Arc::new(Self {
histories: Mutex::new(HashMap::new()),
diary_page: Mutex::new(None),
})
}
@@ -100,6 +102,13 @@ impl FakeDiaryQuery {
.unwrap()
.insert(movie.id().value(), (movie, reviews));
}
/// Configures what `query_diary` returns. Without this, `query_diary` keeps
/// its original always-empty behavior — this is purely opt-in for tests that
/// need to prove entries actually flow through a use case.
pub fn set_diary_page(&self, page: Paginated<DiaryEntry>) {
*self.diary_page.lock().unwrap() = Some(page);
}
}
#[async_trait]
@@ -108,12 +117,17 @@ impl DiaryQuery for FakeDiaryQuery {
&self,
_filter: &DiaryFilter,
) -> Result<Paginated<DiaryEntry>, DomainError> {
Ok(Paginated {
items: vec![],
total_count: 0,
limit: 10,
offset: 0,
})
Ok(self
.diary_page
.lock()
.unwrap()
.clone()
.unwrap_or(Paginated {
items: vec![],
total_count: 0,
limit: 10,
offset: 0,
}))
}
async fn query_activity_feed(

View File

@@ -12,21 +12,23 @@ use crate::{
errors::DomainError,
models::{
FederationFlags, Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile,
MovieSummary, ProfileField, RefreshSession, Review, User, UserSettings, UserSummary,
WatchEvent, WatchEventStatus, WatchlistEntry, WatchlistWithMovie, WebhookToken,
MovieSummary, ProfileField, RefreshSession, RemoteWatchlistEntry, Review, User,
UserSettings, UserSummary, WatchEvent, WatchEventStatus, WatchlistEntry,
WatchlistWithMovie, WebhookToken,
collections::{PageParams, Paginated},
},
ports::{
GoalCommand, GoalQuery, ImportProfileRepository, ImportSessionRepository, MovieCommand,
MovieProfileRepository, MovieQuery, RefreshSessionRepository, ReviewRepository,
SocialCommand, SocialQuery, UserFederationSettingsQuery, UserProfileFieldsRepository,
UserRepository, UserSettingsRepository, WatchEventCommand, WatchEventQuery,
WatchlistRepository, WebhookTokenRepository,
BlockQuery, FollowGraphQuery, GoalCommand, GoalQuery, ImportProfileRepository,
ImportSessionRepository, MovieCommand, MovieProfileRepository, MovieQuery,
RefreshSessionRepository, RemoteWatchlistRepository, ReviewRepository, SocialCommand,
UserFederationSettingsQuery, UserProfileFieldsRepository, UserRepository,
UserSettingsRepository, WatchEventCommand, WatchEventQuery, WatchlistRepository,
WebhookTokenRepository,
},
value_objects::{
Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
ReleaseYear, ReviewId, SocialActor, SocialIdentity, UserId, Username, WatchEventId,
WebhookTokenId,
Email, ExternalMetadataId, FollowRelation, FollowStatus, GoalId, ImportProfileId,
ImportSessionId, MovieId, MovieTitle, ReleaseYear, ReviewId, SocialActor, SocialIdentity,
UserId, Username, WatchEventId, WebhookTokenId,
},
};
@@ -264,9 +266,13 @@ impl UserRepository for InMemoryUserRepository {
async fn update_profile(
&self,
_user_id: &UserId,
_profile: &crate::models::UserProfile,
user_id: &UserId,
profile: &crate::models::UserProfile,
) -> Result<(), DomainError> {
let mut store = self.store.lock().unwrap();
if let Some(user) = store.get_mut(&user_id.value()) {
user.update_profile(profile.clone());
}
Ok(())
}
}
@@ -339,6 +345,78 @@ impl WatchlistRepository for InMemoryWatchlistRepository {
}
}
// ── InMemoryRemoteWatchlistRepository ─────────────────────────────────────────
/// Unlike `NoopRemoteWatchlistRepository`, this actually stores what it's given —
/// needed by tests that must prove a federated-watchlist read returns real seeded
/// data, not just an empty default that happens to match by coincidence.
pub struct InMemoryRemoteWatchlistRepository {
store: Mutex<Vec<RemoteWatchlistEntry>>,
}
impl InMemoryRemoteWatchlistRepository {
pub fn new() -> Arc<Self> {
Arc::new(Self {
store: Mutex::new(Vec::new()),
})
}
pub fn with_entries(entries: Vec<RemoteWatchlistEntry>) -> Arc<Self> {
Arc::new(Self {
store: Mutex::new(entries),
})
}
}
#[async_trait]
impl RemoteWatchlistRepository for InMemoryRemoteWatchlistRepository {
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> {
self.store.lock().unwrap().push(entry);
Ok(())
}
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError> {
self.store
.lock()
.unwrap()
.retain(|e| !(e.ap_id == ap_id && e.actor_url == actor_url));
Ok(())
}
async fn get_by_actor_url(
&self,
actor_url: &str,
) -> Result<Vec<RemoteWatchlistEntry>, DomainError> {
Ok(self
.store
.lock()
.unwrap()
.iter()
.filter(|e| e.actor_url == actor_url)
.cloned()
.collect())
}
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError> {
self.store
.lock()
.unwrap()
.retain(|e| e.actor_url != actor_url);
Ok(())
}
/// Real adapters derive `uuid` from the actor URL via a SQL-side hash — not
/// domain logic, so this fake doesn't replicate it. It simply returns
/// everything seeded, which is all callers in this workspace need: tests seed
/// exactly the entries for the owner under test.
async fn get_by_derived_uuid(
&self,
_uuid: Uuid,
) -> Result<Vec<RemoteWatchlistEntry>, DomainError> {
Ok(self.store.lock().unwrap().clone())
}
}
// ── InMemoryGoalRepository ──────────────────────────────────────────────────
pub struct InMemoryGoalRepository {
@@ -1033,7 +1111,7 @@ impl SocialCommand for InMemorySocialRepository {
}
#[async_trait]
impl SocialQuery for InMemorySocialRepository {
impl FollowGraphQuery for InMemorySocialRepository {
async fn get_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
let store = self.follows.lock().unwrap();
Ok(store
@@ -1069,6 +1147,15 @@ impl SocialQuery for InMemorySocialRepository {
.collect())
}
async fn get_pending_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
let store = self.follows.lock().unwrap();
Ok(store
.iter()
.filter(|(f, _, state)| *f == user.value() && *state == FollowState::Pending)
.map(|(_, t, _)| Self::identity_to_actor(t))
.collect())
}
async fn count_following(&self, user: &UserId) -> Result<usize, DomainError> {
let store = self.follows.lock().unwrap();
Ok(store
@@ -1086,6 +1173,43 @@ impl SocialQuery for InMemorySocialRepository {
.count())
}
async fn count_pending_followers(&self, user: &UserId) -> Result<usize, DomainError> {
let store = self.follows.lock().unwrap();
let target = SocialIdentity::Local(user.clone());
Ok(store
.iter()
.filter(|(_, t, state)| *t == target && *state == FollowState::Pending)
.count())
}
async fn get_relation(
&self,
viewer: &UserId,
target: &SocialIdentity,
) -> Result<FollowRelation, DomainError> {
let store = self.follows.lock().unwrap();
let viewer_identity = SocialIdentity::Local(viewer.clone());
let state_to_status = |s: &FollowState| match s {
FollowState::Pending => FollowStatus::Pending,
FollowState::Accepted => FollowStatus::Accepted,
};
Ok(FollowRelation {
following: store
.iter()
.find(|(f, t, _)| *f == viewer.value() && t == target)
.map(|(_, _, s)| state_to_status(s)),
followed_by: store
.iter()
.find(|(f, t, _)| {
SocialIdentity::Local(UserId::from_uuid(*f)) == *target && *t == viewer_identity
})
.map(|(_, _, s)| state_to_status(s)),
})
}
}
#[async_trait]
impl BlockQuery for InMemorySocialRepository {
async fn get_blocked(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
let store = self.blocked.lock().unwrap();
Ok(store
@@ -1094,15 +1218,4 @@ impl SocialQuery for InMemorySocialRepository {
.map(|(_, t)| Self::identity_to_actor(t))
.collect())
}
async fn is_following(
&self,
follower: &UserId,
target: &SocialIdentity,
) -> Result<bool, DomainError> {
let store = self.follows.lock().unwrap();
Ok(store.iter().any(|(f, t, state)| {
*f == follower.value() && t == target && *state == FollowState::Accepted
}))
}
}

View File

@@ -7,14 +7,15 @@ use crate::{
FeedEntry, FeedSortBy, FieldMapping, FileFormat, FollowingFilter, ImportError,
ImportProfile, ImportSession, IndexableDocument, MovieProfile, MovieStats, ParsedFile,
Person, PersonCredits, PersonEnrichmentData, PersonId, RefreshSession, RemoteActorInfo,
ReviewHistory, SearchQuery, SearchResults, UserStats, UserTrends,
ReviewHistory, SearchQuery, SearchResults, UserStats, UserTrends, WatchlistEntry,
WatchlistWithMovie,
collections::{PageParams, Paginated},
},
ports::{
DiaryExporter, DiaryQuery, DocumentParser, ImportProfileRepository,
ImportSessionRepository, MovieProfileRepository, PersonCommand, PersonQuery,
PosterFetcherClient, RefreshSessionRepository, SearchCommand, SearchPort, StatsRepository,
UserProfileFieldsRepository,
UserProfileFieldsRepository, WatchlistRepository,
},
value_objects::{ImportProfileId, ImportSessionId, MovieId, PosterUrl, UserId},
};
@@ -280,6 +281,31 @@ impl DocumentParser for PanicDocumentParser {
}
}
pub struct PanicWatchlistRepository;
#[async_trait]
impl WatchlistRepository for PanicWatchlistRepository {
async fn add(&self, _: &WatchlistEntry) -> Result<(), DomainError> {
panic!("PanicWatchlistRepository called")
}
async fn remove(&self, _: &UserId, _: &MovieId) -> Result<(), DomainError> {
panic!("PanicWatchlistRepository called")
}
async fn remove_if_present(&self, _: &UserId, _: &MovieId) -> Result<bool, DomainError> {
panic!("PanicWatchlistRepository called")
}
async fn get_for_user(
&self,
_: &UserId,
_: &PageParams,
) -> Result<Paginated<WatchlistWithMovie>, DomainError> {
panic!("PanicWatchlistRepository called")
}
async fn contains(&self, _: &UserId, _: &MovieId) -> Result<bool, DomainError> {
panic!("PanicWatchlistRepository called")
}
}
pub struct PanicRemoteWatchlistRepository;
#[async_trait]

View File

@@ -200,3 +200,96 @@ fn watch_medium_display_round_trips() {
assert_eq!(parsed, v);
}
}
// ── InstanceIdentity ────────────────────────────────────────────────────────
fn instance() -> InstanceIdentity {
InstanceIdentity::new("https://md.example")
}
#[test]
fn instance_normalizes_trailing_slash() {
assert_eq!(
InstanceIdentity::new("https://md.example/").base_url(),
"https://md.example"
);
assert_eq!(
InstanceIdentity::new("https://md.example").base_url(),
"https://md.example"
);
}
#[test]
fn instance_host_strips_scheme_and_path() {
assert_eq!(instance().host(), "md.example");
assert_eq!(
InstanceIdentity::new("http://localhost:3000").host(),
"localhost:3000"
);
}
#[test]
fn instance_builds_actor_url_for_local_user() {
let uid = UserId::from_uuid(uuid::Uuid::nil());
assert_eq!(
instance().actor_url_for(&uid),
format!("https://md.example/users/{}", uuid::Uuid::nil())
);
}
#[test]
fn instance_builds_handle_and_image_url() {
assert_eq!(instance().handle_for("gabriel"), "@gabriel@md.example");
assert_eq!(
instance().image_url_for("avatars/a.webp"),
"https://md.example/images/avatars/a.webp"
);
}
#[test]
fn instance_identifies_own_actor_url_as_local() {
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
let url = instance().actor_url_for(&uid);
assert_eq!(instance().identify(&url), SocialIdentity::Local(uid));
}
#[test]
fn instance_identifies_foreign_actor_url_as_remote() {
let url = "https://other.example/users/bob";
assert_eq!(
instance().identify(url),
SocialIdentity::Remote {
actor_url: url.to_string()
}
);
}
#[test]
fn instance_identifies_own_host_with_bad_uuid_as_remote() {
let url = "https://md.example/users/not-a-uuid";
assert_eq!(
instance().identify(url),
SocialIdentity::Remote {
actor_url: url.to_string()
}
);
}
#[test]
fn instance_actor_url_round_trips_both_variants() {
let i = instance();
let local = SocialIdentity::Local(UserId::from_uuid(uuid::Uuid::new_v4()));
let remote = SocialIdentity::Remote {
actor_url: "https://other.example/users/bob".into(),
};
assert_eq!(i.identify(&i.actor_url_of(&local)), local);
assert_eq!(i.identify(&i.actor_url_of(&remote)), remote);
}
#[test]
fn instance_rejects_prefix_collision() {
// A different instance whose base_url merely starts the same must not read as local.
let i = InstanceIdentity::new("https://md.example");
let url = "https://md.example.evil.test/users/00000000-0000-0000-0000-000000000000";
assert!(matches!(i.identify(url), SocialIdentity::Remote { .. }));
}

View File

@@ -0,0 +1,62 @@
use super::{SocialIdentity, UserId};
/// This instance's own identity on the network. Owns every URL and handle
/// derivation that used to be spelled with a bare `base_url: &str`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InstanceIdentity {
base_url: String,
}
impl InstanceIdentity {
/// Normalizes away a trailing slash so every derived URL has exactly one.
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into().trim_end_matches('/').to_string(),
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
/// Host and port, as it appears in a fediverse handle.
pub fn host(&self) -> &str {
self.base_url
.split("://")
.nth(1)
.and_then(|s| s.split('/').next())
.unwrap_or("localhost")
}
pub fn actor_url_for(&self, user: &UserId) -> String {
format!("{}/users/{}", self.base_url, user.value())
}
pub fn handle_for(&self, username: &str) -> String {
format!("@{}@{}", username, self.host())
}
pub fn image_url_for(&self, path: &str) -> String {
format!("{}/images/{}", self.base_url, path)
}
pub fn actor_url_of(&self, identity: &SocialIdentity) -> String {
match identity {
SocialIdentity::Local(uid) => self.actor_url_for(uid),
SocialIdentity::Remote { actor_url } => actor_url.clone(),
}
}
/// Inverse of `actor_url_of`: decides whether an actor URL is ours.
pub fn identify(&self, actor_url: &str) -> SocialIdentity {
let prefix = format!("{}/users/", self.base_url);
if let Some(uuid_str) = actor_url.strip_prefix(&prefix)
&& let Ok(uuid) = uuid::Uuid::parse_str(uuid_str)
{
return SocialIdentity::Local(UserId::from_uuid(uuid));
}
SocialIdentity::Remote {
actor_url: actor_url.to_string(),
}
}
}

View File

@@ -1,10 +1,12 @@
mod ids;
mod instance;
mod movie;
mod review;
mod social;
mod user;
pub use ids::*;
pub use instance::*;
pub use movie::*;
pub use review::*;
pub use social::*;

View File

@@ -7,18 +7,6 @@ pub enum SocialIdentity {
}
impl SocialIdentity {
pub fn from_actor_url(actor_url: &str, base_url: &str) -> Self {
let prefix = format!("{}/users/", base_url);
if let Some(uuid_str) = actor_url.strip_prefix(&prefix)
&& let Ok(uuid) = uuid::Uuid::parse_str(uuid_str)
{
return Self::Local(UserId::from_uuid(uuid));
}
Self::Remote {
actor_url: actor_url.to_string(),
}
}
pub fn is_local(&self) -> bool {
matches!(self, Self::Local(_))
}
@@ -26,19 +14,6 @@ impl SocialIdentity {
pub fn is_remote(&self) -> bool {
matches!(self, Self::Remote { .. })
}
pub fn format_local_handle(username: &str, base_url: &str) -> String {
let host = Self::host_from_base_url(base_url);
format!("@{}@{}", username, host)
}
pub fn host_from_base_url(base_url: &str) -> &str {
base_url
.split("://")
.nth(1)
.and_then(|s| s.split('/').next())
.unwrap_or("localhost")
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -61,3 +36,11 @@ pub struct SocialActor {
pub display_name: Option<String>,
pub avatar_url: Option<String>,
}
/// Both directions of the follow edge between a viewer and a target.
/// `None` means no edge at all; `Some(Pending)` means requested but not accepted.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct FollowRelation {
pub following: Option<FollowStatus>,
pub followed_by: Option<FollowStatus>,
}