This commit is contained in:
2026-05-17 12:04:51 +02:00
parent 54910c6459
commit d813e59b5c
46 changed files with 1003 additions and 810 deletions

View File

@@ -1,7 +1,7 @@
use super::*;
use crate::testing::TestApRepo;
use activitypub_base::{ActorApUrls, OutboundFederationPort};
use async_trait::async_trait;
use crate::testing::TestApRepo;
use domain::{
errors::DomainError,
events::DomainEvent,
@@ -56,21 +56,12 @@ impl OutboundFederationPort for SpyPort {
self.announced.lock().unwrap().push(ap_id.to_string());
Ok(())
}
async fn broadcast_undo_announce(
&self,
_: &UserId,
ap_id: &str,
) -> Result<(), DomainError> {
async fn broadcast_undo_announce(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> {
self.undo_announced.lock().unwrap().push(ap_id.to_string());
Ok(())
}
async fn broadcast_like(
&self,
_: &UserId,
ap_id: &str,
_: &str,
) -> Result<(), DomainError> {
async fn broadcast_like(&self, _: &UserId, ap_id: &str, _: &str) -> Result<(), DomainError> {
self.liked.lock().unwrap().push(ap_id.to_string());
Ok(())
}
@@ -123,7 +114,11 @@ fn svc(store: &TestStore, spy: Arc<SpyPort>) -> FederationEventService {
}
}
fn svc_with_ap(store: &TestStore, ap_repo: TestApRepo, spy: Arc<SpyPort>) -> FederationEventService {
fn svc_with_ap(
store: &TestStore,
ap_repo: TestApRepo,
spy: Arc<SpyPort>,
) -> FederationEventService {
FederationEventService {
thoughts: Arc::new(store.clone()),
users: Arc::new(store.clone()),

View File

@@ -106,11 +106,7 @@ impl ActivityPubRepository for TestApRepo {
) -> Result<ThoughtId, DomainError> {
Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4()))
}
async fn apply_note_update(
&self,
_ap_id: &str,
_new_content: &str,
) -> Result<(), DomainError> {
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> {

View File

@@ -34,21 +34,16 @@ pub async fn register(
}
let hash = hasher.hash(&input.password).await?;
let user = User::new_local(UserId::new(), username, email, hash);
users
.save(&user)
.await
.map_err(|e| match e {
DomainError::UniqueViolation { field: "username" } => {
DomainError::Conflict("username taken".into())
}
DomainError::UniqueViolation { field: "email" } => {
DomainError::Conflict("email taken".into())
}
DomainError::UniqueViolation { .. } => {
DomainError::Conflict("already exists".into())
}
other => other,
})?;
users.save(&user).await.map_err(|e| match e {
DomainError::UniqueViolation { field: "username" } => {
DomainError::Conflict("username taken".into())
}
DomainError::UniqueViolation { field: "email" } => {
DomainError::Conflict("email taken".into())
}
DomainError::UniqueViolation { .. } => DomainError::Conflict("already exists".into()),
other => other,
})?;
events
.publish(&DomainEvent::UserRegistered {
user_id: user.id.clone(),

View File

@@ -3,7 +3,10 @@ use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{feed::{PageParams, Paginated, UserSummary}, user::User},
models::{
feed::{PageParams, Paginated, UserSummary},
user::User,
},
ports::{AuthService, GeneratedToken, PasswordHasher, UserReader, UserWriter},
testing::{NoOpEventPublisher, TestStore},
value_objects::{Email, PasswordHash, UserId, Username},
@@ -19,10 +22,7 @@ impl UserReader for ConflictOnSaveStore {
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> {
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> {
@@ -34,10 +34,16 @@ impl UserReader for ConflictOnSaveStore {
async fn count(&self) -> Result<i64, DomainError> {
self.0.count().await
}
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> {
async fn list_paginated(
&self,
page: PageParams,
) -> Result<Paginated<UserSummary>, DomainError> {
self.0.list_paginated(page).await
}
async fn find_by_ids(&self, ids: &[UserId]) -> Result<std::collections::HashMap<UserId, User>, DomainError> {
async fn find_by_ids(
&self,
ids: &[UserId],
) -> Result<std::collections::HashMap<UserId, User>, DomainError> {
self.0.find_by_ids(ids).await
}
}
@@ -57,7 +63,14 @@ impl UserWriter for ConflictOnSaveStore {
custom_css: Option<String>,
) -> Result<(), DomainError> {
self.0
.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css)
.update_profile(
user_id,
display_name,
bio,
avatar_url,
header_url,
custom_css,
)
.await
}
}
@@ -67,10 +80,7 @@ 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> {
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> {
@@ -82,10 +92,16 @@ impl UserReader for EmailConflictOnSaveStore {
async fn count(&self) -> Result<i64, DomainError> {
self.0.count().await
}
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> {
async fn list_paginated(
&self,
page: PageParams,
) -> Result<Paginated<UserSummary>, DomainError> {
self.0.list_paginated(page).await
}
async fn find_by_ids(&self, ids: &[UserId]) -> Result<std::collections::HashMap<UserId, User>, DomainError> {
async fn find_by_ids(
&self,
ids: &[UserId],
) -> Result<std::collections::HashMap<UserId, User>, DomainError> {
self.0.find_by_ids(ids).await
}
}
@@ -105,7 +121,14 @@ impl UserWriter for EmailConflictOnSaveStore {
custom_css: Option<String>,
) -> Result<(), DomainError> {
self.0
.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css)
.update_profile(
user_id,
display_name,
bio,
avatar_url,
header_url,
custom_css,
)
.await
}
}

View File

@@ -7,9 +7,9 @@ use domain::{
remote_actor::RemoteActor,
},
ports::{
EventPublisher, FederationActionPort, FederationFollowPort,
FederationFollowRequestPort, FederationSchedulerPort, FeedQuery, FeedRepository,
FollowRepository, RemoteActorConnectionRepository, UserReader,
EventPublisher, FederationActionPort, FederationFollowPort, FederationFollowRequestPort,
FederationSchedulerPort, FeedQuery, FeedRepository, FollowRepository,
RemoteActorConnectionRepository, UserReader,
},
value_objects::UserId,
};
@@ -86,7 +86,13 @@ pub async fn get_remote_actor_posts(
Some(id) => id,
None => ap_repo.intern_remote_actor(&actor.url).await?,
};
let result = feed.query(&FeedQuery::user(author_id, page.clone(), viewer_id.cloned())).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

@@ -13,5 +13,6 @@ 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());
feed.query(&FeedQuery::home(user_id.clone(), following_ids, page)).await
feed.query(&FeedQuery::home(user_id.clone(), following_ids, page))
.await
}

View File

@@ -5,7 +5,10 @@ use domain::{
feed::{EngagementStats, FeedEntry},
thought::{Thought, Visibility},
},
ports::{EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserReader},
ports::{
EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository,
UserReader,
},
value_objects::{Content, ThoughtId, UserId},
};
@@ -133,10 +136,20 @@ pub async fn get_thought_view(
.await?
.ok_or(DomainError::NotFound)?;
let mut map = engagement.get_for_thoughts(&[id.clone()], viewer).await?;
let (stats, viewer_ctx) = map.remove(id).unwrap_or(
(EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None)
);
Ok(FeedEntry { thought, author, stats, viewer: viewer_ctx })
let (stats, viewer_ctx) = map.remove(id).unwrap_or((
EngagementStats {
like_count: 0,
boost_count: 0,
reply_count: 0,
},
None,
));
Ok(FeedEntry {
thought,
author,
stats,
viewer: viewer_ctx,
})
}
/// Fetches a thread (root + replies) enriched with authors + real engagement stats.
@@ -169,10 +182,20 @@ pub async fn get_thread_views(
.get(&thought.user_id)
.cloned()
.ok_or(DomainError::NotFound)?;
let (stats, viewer_ctx) = engagement_map.remove(&thought.id).unwrap_or(
(EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None)
);
entries.push(FeedEntry { thought, author, stats, viewer: viewer_ctx });
let (stats, viewer_ctx) = engagement_map.remove(&thought.id).unwrap_or((
EngagementStats {
like_count: 0,
boost_count: 0,
reply_count: 0,
},
None,
));
entries.push(FeedEntry {
thought,
author,
stats,
viewer: viewer_ctx,
});
}
Ok(entries)
}

View File

@@ -31,9 +31,16 @@ async fn create_thought_saves_and_stages_outbox_event() {
let outbox = TestOutbox::default();
let u = user();
store.users.lock().unwrap().push(u.clone());
let out = create_thought(&store, &store, &store, &NoOpEventPublisher, &outbox, input(u.id.clone()))
.await
.unwrap();
let out = create_thought(
&store,
&store,
&store,
&NoOpEventPublisher,
&outbox,
input(u.id.clone()),
)
.await
.unwrap();
assert_eq!(out.thought.content.as_str(), "hello");
let staged = outbox.staged();
assert_eq!(staged.len(), 1);
@@ -64,7 +71,9 @@ async fn delete_thought_stages_outbox_event() {
let staged = outbox.staged();
assert_eq!(staged.len(), 1);
assert!(matches!(&staged[0], DomainEvent::ThoughtDeleted { thought_id, .. } if *thought_id == tid));
assert!(
matches!(&staged[0], DomainEvent::ThoughtDeleted { thought_id, .. } if *thought_id == tid)
);
}
#[tokio::test]
@@ -82,9 +91,15 @@ async fn delete_own_thought_succeeds() {
)
.await
.unwrap();
delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &out.thought.id, &u.id)
.await
.unwrap();
delete_thought(
&store,
&NoOpEventPublisher,
&NoOpOutboxWriter,
&out.thought.id,
&u.id,
)
.await
.unwrap();
assert!(store.thoughts.lock().unwrap().is_empty());
}
@@ -113,9 +128,15 @@ async fn delete_other_thought_returns_not_found() {
)
.await
.unwrap();
let err = delete_thought(&store, &NoOpEventPublisher, &NoOpOutboxWriter, &out.thought.id, &bob.id)
.await
.unwrap_err();
let err = delete_thought(
&store,
&NoOpEventPublisher,
&NoOpOutboxWriter,
&out.thought.id,
&bob.id,
)
.await
.unwrap_err();
assert!(matches!(err, DomainError::NotFound));
}
@@ -124,9 +145,16 @@ async fn edit_thought_changes_content_and_emits_event() {
let store = TestStore::default();
let alice = user();
store.users.lock().unwrap().push(alice.clone());
let out = create_thought(&store, &store, &store, &NoOpEventPublisher, &NoOpOutboxWriter, input(alice.id.clone()))
.await
.unwrap();
let out = create_thought(
&store,
&store,
&store,
&NoOpEventPublisher,
&NoOpOutboxWriter,
input(alice.id.clone()),
)
.await
.unwrap();
let tid = out.thought.id.clone();
edit_thought(&store, &store, &tid, &alice.id, "updated".to_string())
@@ -222,9 +250,13 @@ fn make_thought(user_id: UserId) -> Thought {
async fn get_thought_view_returns_feed_entry() {
let store = TestStore::default();
let user = make_user();
<TestStore as UserWriter>::save(&store, &user).await.unwrap();
<TestStore as UserWriter>::save(&store, &user)
.await
.unwrap();
let thought = make_thought(user.id.clone());
<TestStore as ThoughtRepository>::save(&store, &thought).await.unwrap();
<TestStore as ThoughtRepository>::save(&store, &thought)
.await
.unwrap();
let entry = get_thought_view(&store, &store, &store, &thought.id, None)
.await
@@ -248,9 +280,13 @@ async fn get_thought_view_returns_not_found_for_missing_thought() {
async fn get_thread_views_batches_correctly() {
let store = TestStore::default();
let user = make_user();
<TestStore as UserWriter>::save(&store, &user).await.unwrap();
<TestStore as UserWriter>::save(&store, &user)
.await
.unwrap();
let root = make_thought(user.id.clone());
<TestStore as ThoughtRepository>::save(&store, &root).await.unwrap();
<TestStore as ThoughtRepository>::save(&store, &root)
.await
.unwrap();
let reply = Thought::new_local(
ThoughtId::new(),
user.id.clone(),
@@ -260,7 +296,9 @@ async fn get_thread_views_batches_correctly() {
None,
false,
);
<TestStore as ThoughtRepository>::save(&store, &reply).await.unwrap();
<TestStore as ThoughtRepository>::save(&store, &reply)
.await
.unwrap();
let entries = get_thread_views(&store, &store, &store, &root.id, None)
.await