refactor: 5 architectural improvements (Tasks 2-5 + Task 6 fix)
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 5m2s
test / unit (pull_request) Successful in 16m19s
test / integration (pull_request) Failing after 17m15s

- feat(domain): Hashtag value object with canonical extract() — unifies two
  divergent private implementations; fields pre-compute raw/normalized/url_slug/ap_name

- feat(presentation): Deps<S: FromAppState> extractor — each handler now
  declares its exact dependency surface; AppState unchanged; handlers
  become unit-testable without mocking all 20 deps

- refactor(feed): replace 5 flat FeedRepository methods with FeedQuery/FeedScope
  — single query() method; SQL shared logic lives once; adding feed types
  no longer requires 5 edits

- refactor(activitypub): ActivityPubRepository + OutboundFederationPort moved
  out of domain::ports into activitypub-base::ap_ports — domain crate no
  longer knows about AP IDs, inboxes, or actor URLs

- fix(outbox): OutboxRelay now opens a per-row transaction so FOR UPDATE
  SKIP LOCKED actually holds the lock during publish + mark_delivered
This commit is contained in:
2026-05-15 18:54:20 +02:00
parent 6024a65060
commit 0592861edd
37 changed files with 1401 additions and 865 deletions

View File

@@ -4,8 +4,9 @@ version = "0.1.0"
edition = "2021"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
domain = { workspace = true }
activitypub-base = { workspace = true }
async-trait = { workspace = true }
thiserror = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }

View File

@@ -1,2 +1,5 @@
pub mod services;
pub mod use_cases;
#[cfg(test)]
pub mod testing;

View File

@@ -1,8 +1,9 @@
use activitypub_base::{ActivityPubRepository, OutboundFederationPort};
use domain::{
errors::DomainError,
events::DomainEvent,
models::thought::Visibility,
ports::{ActivityPubRepository, OutboundFederationPort, ThoughtRepository, UserReader},
ports::{ThoughtRepository, UserReader},
value_objects::ThoughtId,
};
use std::sync::Arc;
@@ -212,13 +213,14 @@ impl FederationEventService {
#[cfg(test)]
mod tests {
use super::*;
use activitypub_base::{ActorApUrls, OutboundFederationPort};
use async_trait::async_trait;
use crate::testing::TestApRepo;
use domain::{
errors::DomainError,
events::DomainEvent,
models::thought::{Thought, Visibility},
models::user::User,
ports::{ActivityPubRepository, OutboundFederationPort},
testing::TestStore,
value_objects::*,
};
@@ -325,12 +327,23 @@ mod tests {
}
fn svc(store: &TestStore, spy: Arc<SpyPort>) -> FederationEventService {
let ap_repo = TestApRepo::new(store.clone());
FederationEventService {
thoughts: Arc::new(store.clone()),
users: Arc::new(store.clone()),
ap: spy,
base_url: "https://example.com".to_string(),
ap_repo: Arc::new(store.clone()),
ap_repo: Arc::new(ap_repo),
}
}
fn svc_with_ap(store: &TestStore, ap_repo: TestApRepo, spy: Arc<SpyPort>) -> FederationEventService {
FederationEventService {
thoughts: Arc::new(store.clone()),
users: Arc::new(store.clone()),
ap: spy,
base_url: "https://example.com".to_string(),
ap_repo: Arc::new(ap_repo),
}
}
@@ -452,7 +465,8 @@ mod tests {
let alice = alice();
let mut thought = local_thought(alice.id.clone());
thought.local = false;
store.thought_ap_ids.lock().unwrap().insert(
let ap_repo = TestApRepo::new(store.clone());
ap_repo.inner.thought_ap_ids.lock().unwrap().insert(
thought.id.clone(),
"https://mastodon.social/users/bob/statuses/123".into(),
);
@@ -460,7 +474,7 @@ mod tests {
store.thoughts.lock().unwrap().push(thought.clone());
let spy = Arc::new(SpyPort::default());
svc(&store, spy.clone())
svc_with_ap(&store, ap_repo, spy.clone())
.process(&DomainEvent::BoostAdded {
boost_id: BoostId::new(),
user_id: alice.id.clone(),
@@ -604,14 +618,15 @@ mod tests {
let alice = alice();
let mut thought = local_thought(alice.id.clone());
thought.local = false;
store.thought_ap_ids.lock().unwrap().insert(
let ap_repo = TestApRepo::new(store.clone());
ap_repo.inner.thought_ap_ids.lock().unwrap().insert(
thought.id.clone(),
"https://mastodon.social/users/bob/statuses/456".into(),
);
store.thoughts.lock().unwrap().push(thought.clone());
let spy = Arc::new(SpyPort::default());
svc(&store, spy.clone())
svc_with_ap(&store, ap_repo, spy.clone())
.process(&DomainEvent::BoostRemoved {
user_id: alice.id.clone(),
thought_id: thought.id.clone(),
@@ -673,28 +688,28 @@ mod tests {
PasswordHash("h".into()),
);
author.local = false;
store.actor_ap_urls.lock().unwrap().insert(
author.id.clone(),
domain::ports::ActorApUrls {
ap_id: "https://mastodon.social/users/author".into(),
inbox_url: "https://mastodon.social/users/author/inbox".into(),
},
);
let thought = local_thought(author.id.clone());
store.thought_ap_ids.lock().unwrap().insert(
thought.id.clone(),
"https://mastodon.social/posts/123".into(),
);
let liker = alice();
store.users.lock().unwrap().push(author.clone());
store.users.lock().unwrap().push(liker.clone());
store.thoughts.lock().unwrap().push(thought.clone());
let ap_repo = TestApRepo::new(store.clone());
ap_repo.actor_ap_urls.lock().unwrap().insert(
author.id.clone(),
ActorApUrls {
ap_id: "https://mastodon.social/users/author".into(),
inbox_url: "https://mastodon.social/users/author/inbox".into(),
},
);
ap_repo.inner.thought_ap_ids.lock().unwrap().insert(
thought.id.clone(),
"https://mastodon.social/posts/123".into(),
);
let spy = Arc::new(SpyPort::default());
svc(&store, spy.clone())
svc_with_ap(&store, ap_repo, spy.clone())
.process(&DomainEvent::LikeAdded {
like_id: LikeId::new(),
user_id: liker.id,

View File

@@ -0,0 +1,150 @@
/// Test helpers for application-layer tests that need activitypub_base traits.
use activitypub_base::{ActivityPubRepository, ActorApUrls, OutboxEntry};
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::user::User,
testing::TestStore,
value_objects::{Email, PasswordHash, ThoughtId, UserId, Username},
};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
/// Extends `TestStore` with AP-specific lookup maps.
#[derive(Default, Clone)]
pub struct TestApRepo {
pub inner: TestStore,
/// UserId → ActorApUrls (for get_actor_ap_urls)
pub actor_ap_urls: Arc<Mutex<HashMap<UserId, ActorApUrls>>>,
}
impl TestApRepo {
pub fn new(inner: TestStore) -> Self {
Self {
inner,
actor_ap_urls: Default::default(),
}
}
}
#[async_trait]
impl ActivityPubRepository for TestApRepo {
async fn outbox_entries_for_actor(
&self,
_uid: &UserId,
) -> Result<Vec<OutboxEntry>, DomainError> {
Ok(vec![])
}
async fn outbox_page_for_actor(
&self,
_uid: &UserId,
_before: Option<chrono::DateTime<chrono::Utc>>,
_limit: usize,
) -> Result<Vec<OutboxEntry>, DomainError> {
Ok(vec![])
}
async fn find_remote_actor_id(
&self,
actor_ap_url: &str,
) -> Result<Option<UserId>, DomainError> {
Ok(self
.inner
.actor_ap_ids
.lock()
.unwrap()
.get(actor_ap_url)
.cloned())
}
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError> {
if let Some(uid) = self.find_remote_actor_id(actor_ap_url).await? {
return Ok(uid);
}
let uid = UserId::new();
let handle = url::Url::parse(actor_ap_url)
.map(|u| u.path().trim_start_matches('/').replace('/', "_"))
.unwrap_or_else(|_| format!("remote_{}", &uid.to_string()[..8]));
let user = User {
id: uid.clone(),
username: Username::from_trusted(handle),
email: Email::from_trusted(format!("{}@remote", uid)),
password_hash: PasswordHash("".into()),
display_name: None,
bio: None,
avatar_url: None,
header_url: None,
custom_css: None,
local: false,
created_at: chrono::Utc::now(),
updated_at: chrono::Utc::now(),
};
self.inner.users.lock().unwrap().push(user);
self.inner
.actor_ap_ids
.lock()
.unwrap()
.insert(actor_ap_url.to_string(), uid.clone());
Ok(uid)
}
async fn update_remote_actor_display(
&self,
_user_id: &UserId,
_display_name: Option<&str>,
_avatar_url: Option<&str>,
) -> Result<(), DomainError> {
Ok(())
}
async fn accept_note(
&self,
_ap_id: &str,
_author_id: &UserId,
_content: &str,
_published: chrono::DateTime<chrono::Utc>,
_sensitive: bool,
_content_warning: Option<String>,
_visibility: &str,
_in_reply_to: Option<&str>,
) -> Result<(), DomainError> {
Ok(())
}
async fn apply_note_update(
&self,
_ap_id: &str,
_new_content: &str,
) -> Result<(), DomainError> {
Ok(())
}
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {
Ok(())
}
async fn retract_actor_notes(&self, _actor_ap_url: &str) -> Result<(), DomainError> {
Ok(())
}
async fn count_local_notes(&self) -> Result<u64, DomainError> {
Ok(self
.inner
.thoughts
.lock()
.unwrap()
.iter()
.filter(|t| t.local)
.count() as u64)
}
async fn get_thought_ap_id(
&self,
thought_id: &ThoughtId,
) -> Result<Option<String>, DomainError> {
Ok(self
.inner
.thought_ap_ids
.lock()
.unwrap()
.get(thought_id)
.cloned())
}
async fn get_actor_ap_urls(
&self,
user_id: &UserId,
) -> Result<Option<ActorApUrls>, DomainError> {
Ok(self.actor_ap_urls.lock().unwrap().get(user_id).cloned())
}
}

View File

@@ -38,13 +38,11 @@ pub async fn register(
.save(&user)
.await
.map_err(|e| match e {
DomainError::Conflict(ref c) if c.contains("username") => {
DomainError::Conflict("username taken".into())
}
DomainError::Conflict(ref c) if c.contains("email") => {
DomainError::Conflict("email taken".into())
}
DomainError::Conflict(_) => DomainError::Conflict("already exists".into()),
DomainError::Conflict(c) => match c.as_str() {
"users_username_key" => DomainError::Conflict("username taken".into()),
"users_email_key" => DomainError::Conflict("email taken".into()),
_ => DomainError::Conflict("already exists".into()),
},
other => other,
})?;
events
@@ -111,6 +109,7 @@ mod tests {
/// Simulates a concurrent registration that slips past the pre-checks and
/// hits the DB unique constraint — exactly what happens in the TOCTOU window.
struct ConflictOnSaveStore(TestStore);
struct EmailConflictOnSaveStore(TestStore);
#[async_trait]
impl UserReader for ConflictOnSaveStore {
@@ -154,6 +153,48 @@ mod tests {
}
}
#[async_trait]
impl UserReader for EmailConflictOnSaveStore {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
self.0.find_by_id(id).await
}
async fn find_by_username(
&self,
username: &Username,
) -> Result<Option<User>, DomainError> {
self.0.find_by_username(username).await
}
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
self.0.find_by_email(email).await
}
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
self.0.list_with_stats().await
}
async fn count(&self) -> Result<i64, DomainError> {
self.0.count().await
}
}
#[async_trait]
impl UserWriter for EmailConflictOnSaveStore {
async fn save(&self, _user: &User) -> Result<(), DomainError> {
Err(DomainError::Conflict("users_email_key".into()))
}
async fn update_profile(
&self,
user_id: &UserId,
display_name: Option<String>,
bio: Option<String>,
avatar_url: Option<String>,
header_url: Option<String>,
custom_css: Option<String>,
) -> Result<(), DomainError> {
self.0
.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css)
.await
}
}
struct FakeHasher;
#[async_trait]
impl PasswordHasher for FakeHasher {
@@ -315,4 +356,17 @@ mod tests {
err
);
}
#[tokio::test]
async fn register_maps_db_conflict_on_email_to_conflict() {
let store = EmailConflictOnSaveStore(TestStore::default());
let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
.await
.unwrap_err();
assert!(
matches!(err, DomainError::Conflict(ref m) if m == "email taken"),
"expected 'email taken', got: {:?}",
err
);
}
}

View File

@@ -1,3 +1,4 @@
use activitypub_base::ActivityPubRepository;
use domain::{
errors::DomainError,
models::{
@@ -6,9 +7,9 @@ use domain::{
remote_actor::RemoteActor,
},
ports::{
ActivityPubRepository, EventPublisher, FederationActionPort, FederationFollowPort,
FederationFollowRequestPort, FederationSchedulerPort, FeedRepository, FollowRepository,
RemoteActorConnectionRepository, UserReader,
EventPublisher, FederationActionPort, FederationFollowPort,
FederationFollowRequestPort, FederationSchedulerPort, FeedQuery, FeedRepository,
FollowRepository, RemoteActorConnectionRepository, UserReader,
},
value_objects::UserId,
};
@@ -85,7 +86,7 @@ pub async fn get_remote_actor_posts(
Some(id) => id,
None => ap_repo.intern_remote_actor(&actor.url).await?,
};
let result = feed.user_feed(&author_id, &page, viewer_id).await?;
let result = feed.query(&FeedQuery::user(author_id, page.clone(), viewer_id.cloned())).await?;
if let Some(outbox_url) = actor.outbox_url {
let _ = scheduler
.schedule_actor_posts_fetch(&actor.url, &outbox_url)

View File

@@ -4,7 +4,7 @@ use domain::{
feed::{FeedEntry, PageParams, Paginated, UserSummary},
user::User,
},
ports::{FeedRepository, FollowRepository, TagRepository, UserReader},
ports::{FeedQuery, FeedRepository, FollowRepository, TagRepository, UserReader},
value_objects::UserId,
};
@@ -16,7 +16,7 @@ pub async fn get_home_feed(
) -> Result<Paginated<FeedEntry>, DomainError> {
let mut following_ids = follows.get_accepted_following_ids(user_id).await?;
following_ids.push(user_id.clone()); // include own thoughts in home feed
feed.home_feed(&following_ids, &page, Some(user_id)).await
feed.query(&FeedQuery::home(user_id.clone(), following_ids, page)).await
}
pub async fn get_public_feed(
@@ -24,7 +24,7 @@ pub async fn get_public_feed(
viewer_id: Option<&UserId>,
page: PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
feed.public_feed(&page, viewer_id).await
feed.query(&FeedQuery::public(page, viewer_id.cloned())).await
}
pub async fn get_user_feed(
@@ -33,7 +33,7 @@ pub async fn get_user_feed(
page: PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
feed.user_feed(user_id, &page, viewer_id).await
feed.query(&FeedQuery::user(user_id.clone(), page, viewer_id.cloned())).await
}
pub async fn get_followers(
@@ -58,7 +58,7 @@ pub async fn get_by_tag(
page: PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
feed.tag_feed(tag_name, &page, viewer_id).await
feed.query(&FeedQuery::tag(tag_name, page, viewer_id.cloned())).await
}
pub async fn search(
@@ -67,7 +67,7 @@ pub async fn search(
page: PageParams,
viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
feed.search(query, &page, viewer_id).await
feed.query(&FeedQuery::search(query, page, viewer_id.cloned())).await
}
pub async fn list_users(users: &dyn UserReader) -> Result<Vec<UserSummary>, DomainError> {

View File

@@ -6,30 +6,6 @@ use domain::{
value_objects::{Content, ThoughtId, UserId},
};
fn extract_hashtags(content: &str) -> Vec<String> {
let mut tags = Vec::new();
let mut chars = content.char_indices().peekable();
while let Some((_, c)) = chars.next() {
if c == '#'
&& chars
.peek()
.map(|(_, nc)| nc.is_alphanumeric())
.unwrap_or(false)
{
let tag: String = chars
.by_ref()
.take_while(|(_, nc)| nc.is_alphanumeric() || *nc == '_')
.map(|(_, nc)| nc)
.collect();
if !tag.is_empty() {
tags.push(tag.to_lowercase());
}
}
}
tags.dedup();
tags
}
fn require_owner(thought: &Thought, user_id: &UserId) -> Result<(), DomainError> {
if thought.user_id != *user_id {
return Err(DomainError::NotFound);
@@ -76,8 +52,8 @@ pub async fn create_thought(
thoughts.save(&thought).await?;
// Extract and attach hashtags from content.
for tag_name in extract_hashtags(content.as_str()) {
if let Ok(tag) = tags.find_or_create(&tag_name).await {
for h in domain::hashtag::extract(content.as_str()) {
if let Ok(tag) = tags.find_or_create(&h.normalized).await {
let _ = tags.attach_to_thought(&thought.id, tag.id).await;
}
}
@@ -195,6 +171,33 @@ mod tests {
assert!(matches!(staged[0], DomainEvent::ThoughtCreated { .. }));
}
#[tokio::test]
async fn delete_thought_stages_outbox_event() {
let store = TestStore::default();
let outbox = TestOutbox::default();
let u = user();
store.users.lock().unwrap().push(u.clone());
let out = create_thought(
&store,
&store,
&store,
&NoOpEventPublisher,
&NoOpOutboxWriter,
input(u.id.clone()),
)
.await
.unwrap();
let tid = out.thought.id.clone();
delete_thought(&store, &NoOpEventPublisher, &outbox, &tid, &u.id)
.await
.unwrap();
let staged = outbox.staged();
assert_eq!(staged.len(), 1);
assert!(matches!(&staged[0], DomainEvent::ThoughtDeleted { thought_id, .. } if *thought_id == tid));
}
#[tokio::test]
async fn delete_own_thought_succeeds() {
let store = TestStore::default();