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

@@ -39,8 +39,6 @@ pub struct TestStore {
pub actor_ap_ids: Arc<Mutex<HashMap<String, UserId>>>,
/// ThoughtId → AP object URL (used by get_thought_ap_id)
pub thought_ap_ids: Arc<Mutex<HashMap<ThoughtId, String>>>,
/// UserId → ActorApUrls (used by get_actor_ap_urls)
pub actor_ap_urls: Arc<Mutex<HashMap<UserId, ActorApUrls>>>,
}
#[async_trait]
@@ -706,63 +704,7 @@ impl RemoteActorConnectionRepository for TestStore {
#[async_trait]
impl FeedRepository for TestStore {
async fn home_feed(
&self,
_ids: &[UserId],
_p: &PageParams,
_v: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
Ok(Paginated {
items: vec![],
total: 0,
page: 1,
per_page: 20,
})
}
async fn public_feed(
&self,
_p: &PageParams,
_v: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
Ok(Paginated {
items: vec![],
total: 0,
page: 1,
per_page: 20,
})
}
async fn search(
&self,
_q: &str,
_p: &PageParams,
_v: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
Ok(Paginated {
items: vec![],
total: 0,
page: 1,
per_page: 20,
})
}
async fn tag_feed(
&self,
_tag_name: &str,
_page: &PageParams,
_viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
Ok(Paginated {
items: vec![],
total: 0,
page: 1,
per_page: 20,
})
}
async fn user_feed(
&self,
_user_id: &UserId,
_page: &PageParams,
_viewer_id: Option<&UserId>,
) -> Result<Paginated<FeedEntry>, DomainError> {
async fn query(&self, _q: &crate::ports::FeedQuery) -> Result<Paginated<FeedEntry>, DomainError> {
Ok(Paginated {
items: vec![],
total: 0,
@@ -801,109 +743,6 @@ impl SearchPort for TestStore {
}
}
#[async_trait]
impl ActivityPubRepository for TestStore {
async fn outbox_entries_for_actor(
&self,
_uid: &UserId,
) -> Result<Vec<crate::ports::OutboxEntry>, DomainError> {
Ok(vec![])
}
async fn outbox_page_for_actor(
&self,
_uid: &UserId,
_before: Option<chrono::DateTime<chrono::Utc>>,
_limit: usize,
) -> Result<Vec<crate::ports::OutboxEntry>, DomainError> {
Ok(vec![])
}
async fn find_remote_actor_id(
&self,
actor_ap_url: &str,
) -> Result<Option<UserId>, DomainError> {
Ok(self.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 = crate::models::user::User {
id: uid.clone(),
username: Username::from_trusted(handle.clone()),
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.users.lock().unwrap().push(user);
self.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
.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.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())
}
}
#[async_trait]
impl FederationSchedulerPort for TestStore {
@@ -964,31 +803,6 @@ impl OutboxWriter for NoOpOutboxWriter {
}
}
#[cfg(test)]
mod ap_repo_tests {
use super::*;
use crate::value_objects::UserId;
#[tokio::test]
async fn test_store_outbox_returns_empty() {
let store = TestStore::default();
let result = store
.outbox_entries_for_actor(&UserId::new())
.await
.unwrap();
assert!(result.is_empty());
}
#[tokio::test]
async fn test_store_intern_creates_placeholder() {
let store = TestStore::default();
let url = "https://example.com/users/alice";
let id1 = store.intern_remote_actor(url).await.unwrap();
let id2 = store.intern_remote_actor(url).await.unwrap();
assert_eq!(id1, id2, "intern must be idempotent");
}
}
#[cfg(test)]
mod federation_port_tests {
use super::*;