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

@@ -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,