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

@@ -44,7 +44,7 @@ pub trait ActivityPubRepository: Send + Sync {
/// Find the local UserId for a remote actor by its AP URL. /// Find the local UserId for a remote actor by its AP URL.
async fn find_remote_actor_id(&self, actor_ap_url: &str) async fn find_remote_actor_id(&self, actor_ap_url: &str)
-> Result<Option<UserId>, DomainError>; -> Result<Option<UserId>, DomainError>;
/// Ensure a remote actor placeholder exists; create one if absent. /// Ensure a remote actor placeholder exists; create one if absent.
/// Idempotent — safe to call multiple times with the same URL. /// Idempotent — safe to call multiple times with the same URL.
@@ -99,7 +99,7 @@ pub trait ActivityPubRepository: Send + Sync {
/// Return the AP actor URL and inbox URL for a user, if stored. /// Return the AP actor URL and inbox URL for a user, if stored.
/// Returns None for users that have not been federated. /// Returns None for users that have not been federated.
async fn get_actor_ap_urls(&self, user_id: &UserId) async fn get_actor_ap_urls(&self, user_id: &UserId)
-> Result<Option<ActorApUrls>, DomainError>; -> Result<Option<ActorApUrls>, DomainError>;
} }
#[async_trait] #[async_trait]

View File

@@ -18,7 +18,7 @@ pub mod user;
pub mod webfinger; pub mod webfinger;
pub use activitypub_federation::kinds::object::NoteType; pub use activitypub_federation::kinds::object::NoteType;
pub use ap_ports::{ActorApUrls, ActivityPubRepository, OutboxEntry, OutboundFederationPort}; pub use ap_ports::{ActivityPubRepository, ActorApUrls, OutboundFederationPort, OutboxEntry};
pub use content::ApObjectHandler; pub use content::ApObjectHandler;
pub use data::FederationData; pub use data::FederationData;
pub use error::Error; pub use error::Error;

View File

@@ -1666,10 +1666,7 @@ impl domain::ports::FederationSchedulerPort for ActivityPubService {
let empty = vec![]; let empty = vec![];
let items = val["orderedItems"].as_array().unwrap_or(&empty); let items = val["orderedItems"].as_array().unwrap_or(&empty);
for item in items { for item in items {
let actor_url = item let actor_url = item.as_str().or_else(|| item["id"].as_str()).unwrap_or("");
.as_str()
.or_else(|| item["id"].as_str())
.unwrap_or("");
if !actor_url.is_empty() { if !actor_url.is_empty() {
all_urls.push(actor_url.to_string()); all_urls.push(actor_url.to_string());
} }

View File

@@ -141,7 +141,8 @@ impl ApObjectHandler for ThoughtsObjectHandler {
"direct" "direct"
}; };
let thought_id = self.repo let thought_id = self
.repo
.accept_note( .accept_note(
ap_id.as_str(), ap_id.as_str(),
&author_id, &author_id,

View File

@@ -18,7 +18,13 @@ impl ApiKeyRepository for FakeApiKeyRepo {
Ok(()) Ok(())
} }
async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> { async fn find_by_hash(&self, hash: &str) -> Result<Option<ApiKey>, DomainError> {
Ok(self.0.lock().unwrap().iter().find(|k| k.key_hash == hash).cloned()) Ok(self
.0
.lock()
.unwrap()
.iter()
.find(|k| k.key_hash == hash)
.cloned())
} }
async fn list_for_user(&self, _uid: &UserId) -> Result<Vec<ApiKey>, DomainError> { async fn list_for_user(&self, _uid: &UserId) -> Result<Vec<ApiKey>, DomainError> {
Ok(vec![]) Ok(vec![])

View File

@@ -356,6 +356,5 @@ impl TryFrom<EventPayload> for DomainEvent {
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

View File

@@ -109,6 +109,5 @@ impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

View File

@@ -239,6 +239,5 @@ impl MessageSource for NatsMessageSource {
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;

View File

@@ -254,12 +254,11 @@ impl ActivityPubRepository for PgActivityPubRepository {
.into_domain()?; .into_domain()?;
// SELECT the id — works whether the INSERT was a no-op or not (idempotent). // SELECT the id — works whether the INSERT was a no-op or not (idempotent).
let row: (uuid::Uuid,) = let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1") .bind(ap_id)
.bind(ap_id) .fetch_one(&self.pool)
.fetch_one(&self.pool) .await
.await .into_domain()?;
.into_domain()?;
Ok(ThoughtId::from_uuid(row.0)) Ok(ThoughtId::from_uuid(row.0))
} }

View File

@@ -1,25 +1,56 @@
use super::*;
use activitypub_base::ActivityPubRepository;
#[sqlx::test(migrations = "./migrations")] use super::*;
async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) { use activitypub_base::ActivityPubRepository;
let repo = PgActivityPubRepository::new(pool);
let url = "https://mastodon.social/users/alice";
let id1 = repo.intern_remote_actor(url).await.unwrap();
let id2 = repo.intern_remote_actor(url).await.unwrap();
assert_eq!(id1, id2);
}
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn accept_and_retract_note(pool: sqlx::PgPool) { async fn intern_remote_actor_is_idempotent(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool); let repo = PgActivityPubRepository::new(pool);
let actor_url = "https://remote.example/users/bob"; let url = "https://mastodon.social/users/alice";
let ap_id = "https://remote.example/notes/1"; let id1 = repo.intern_remote_actor(url).await.unwrap();
let author = repo.intern_remote_actor(actor_url).await.unwrap(); let id2 = repo.intern_remote_actor(url).await.unwrap();
repo.accept_note( assert_eq!(id1, id2);
ap_id, }
&author,
"hello from remote", #[sqlx::test(migrations = "./migrations")]
async fn accept_and_retract_note(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool);
let actor_url = "https://remote.example/users/bob";
let ap_id = "https://remote.example/notes/1";
let author = repo.intern_remote_actor(actor_url).await.unwrap();
repo.accept_note(
ap_id,
&author,
"hello from remote",
chrono::Utc::now(),
false,
None,
"public",
None,
)
.await
.unwrap();
repo.retract_note(ap_id).await.unwrap();
}
#[sqlx::test(migrations = "./migrations")]
async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool);
assert_eq!(repo.count_local_notes().await.unwrap(), 0);
}
#[sqlx::test(migrations = "./migrations")]
async fn accept_note_returns_thought_id(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool.clone());
let actor_user_id = repo
.intern_remote_actor("https://remote.example/users/alice")
.await
.unwrap();
let thought_id = repo
.accept_note(
"https://remote.example/notes/1",
&actor_user_id,
"Hello #rust world",
chrono::Utc::now(), chrono::Utc::now(),
false, false,
None, None,
@@ -28,41 +59,11 @@
) )
.await .await
.unwrap(); .unwrap();
repo.retract_note(ap_id).await.unwrap();
}
#[sqlx::test(migrations = "./migrations")] let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
async fn count_local_notes_excludes_remote(pool: sqlx::PgPool) { .bind("https://remote.example/notes/1")
let repo = PgActivityPubRepository::new(pool); .fetch_one(&pool)
assert_eq!(repo.count_local_notes().await.unwrap(), 0); .await
} .unwrap();
assert_eq!(thought_id.as_uuid(), row.0);
#[sqlx::test(migrations = "./migrations")] }
async fn accept_note_returns_thought_id(pool: sqlx::PgPool) {
let repo = PgActivityPubRepository::new(pool.clone());
let actor_user_id = repo
.intern_remote_actor("https://remote.example/users/alice")
.await
.unwrap();
let thought_id = repo
.accept_note(
"https://remote.example/notes/1",
&actor_user_id,
"Hello #rust world",
chrono::Utc::now(),
false,
None,
"public",
None,
)
.await
.unwrap();
let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM thoughts WHERE ap_id=$1")
.bind("https://remote.example/notes/1")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(thought_id.as_uuid(), row.0);
}

View File

@@ -1,49 +1,50 @@
use super::*;
use crate::user::PgUserRepository;
use chrono::Utc;
use domain::ports::UserWriter;
use domain::{models::user::User, value_objects::*};
async fn seed_user(pool: &sqlx::PgPool) -> User { use super::*;
let repo = PgUserRepository::new(pool.clone()); use crate::user::PgUserRepository;
let u = User::new_local( use chrono::Utc;
UserId::new(), use domain::ports::UserWriter;
Username::new("alice").unwrap(), use domain::{models::user::User, value_objects::*};
Email::new("alice@ex.com").unwrap(),
PasswordHash("h".into()),
);
repo.save(&u).await.unwrap();
u
}
#[sqlx::test(migrations = "./migrations")] async fn seed_user(pool: &sqlx::PgPool) -> User {
async fn save_and_find_by_hash(pool: sqlx::PgPool) { let repo = PgUserRepository::new(pool.clone());
let user = seed_user(&pool).await; let u = User::new_local(
let repo = PgApiKeyRepository::new(pool); UserId::new(),
let key = ApiKey { Username::new("alice").unwrap(),
id: ApiKeyId::new(), Email::new("alice@ex.com").unwrap(),
user_id: user.id.clone(), PasswordHash("h".into()),
key_hash: "abc123".into(), );
name: "test".into(), repo.save(&u).await.unwrap();
created_at: Utc::now(), u
}; }
repo.save(&key).await.unwrap();
let found = repo.find_by_hash("abc123").await.unwrap().unwrap();
assert_eq!(found.name, "test");
}
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn delete_key(pool: sqlx::PgPool) { async fn save_and_find_by_hash(pool: sqlx::PgPool) {
let user = seed_user(&pool).await; let user = seed_user(&pool).await;
let repo = PgApiKeyRepository::new(pool); let repo = PgApiKeyRepository::new(pool);
let key = ApiKey { let key = ApiKey {
id: ApiKeyId::new(), id: ApiKeyId::new(),
user_id: user.id.clone(), user_id: user.id.clone(),
key_hash: "def456".into(), key_hash: "abc123".into(),
name: "key2".into(), name: "test".into(),
created_at: Utc::now(), created_at: Utc::now(),
}; };
repo.save(&key).await.unwrap(); repo.save(&key).await.unwrap();
repo.delete(&key.id, &user.id).await.unwrap(); let found = repo.find_by_hash("abc123").await.unwrap().unwrap();
assert!(repo.find_by_hash("def456").await.unwrap().is_none()); assert_eq!(found.name, "test");
} }
#[sqlx::test(migrations = "./migrations")]
async fn delete_key(pool: sqlx::PgPool) {
let user = seed_user(&pool).await;
let repo = PgApiKeyRepository::new(pool);
let key = ApiKey {
id: ApiKeyId::new(),
user_id: user.id.clone(),
key_hash: "def456".into(),
name: "key2".into(),
created_at: Utc::now(),
};
repo.save(&key).await.unwrap();
repo.delete(&key.id, &user.id).await.unwrap();
assert!(repo.find_by_hash("def456").await.unwrap().is_none());
}

View File

@@ -1,34 +1,35 @@
use super::*;
use crate::test_helpers::seed_user;
use chrono::Utc;
use domain::value_objects::*;
#[sqlx::test(migrations = "./migrations")] use super::*;
async fn block_exists(pool: sqlx::PgPool) { use crate::test_helpers::seed_user;
let alice = seed_user(&pool, "alice", "alice@ex.com").await; use chrono::Utc;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; use domain::value_objects::*;
let repo = PgBlockRepository::new(pool);
let block = Block {
blocker_id: alice.id.clone(),
blocked_id: bob.id.clone(),
created_at: Utc::now(),
};
repo.save(&block).await.unwrap();
assert!(repo.exists(&alice.id, &bob.id).await.unwrap());
assert!(!repo.exists(&bob.id, &alice.id).await.unwrap());
}
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn unblock(pool: sqlx::PgPool) { async fn block_exists(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgBlockRepository::new(pool); let repo = PgBlockRepository::new(pool);
let block = Block { let block = Block {
blocker_id: alice.id.clone(), blocker_id: alice.id.clone(),
blocked_id: bob.id.clone(), blocked_id: bob.id.clone(),
created_at: Utc::now(), created_at: Utc::now(),
}; };
repo.save(&block).await.unwrap(); repo.save(&block).await.unwrap();
repo.delete(&alice.id, &bob.id).await.unwrap(); assert!(repo.exists(&alice.id, &bob.id).await.unwrap());
assert!(!repo.exists(&alice.id, &bob.id).await.unwrap()); assert!(!repo.exists(&bob.id, &alice.id).await.unwrap());
} }
#[sqlx::test(migrations = "./migrations")]
async fn unblock(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgBlockRepository::new(pool);
let block = Block {
blocker_id: alice.id.clone(),
blocked_id: bob.id.clone(),
created_at: Utc::now(),
};
repo.save(&block).await.unwrap();
repo.delete(&alice.id, &bob.id).await.unwrap();
assert!(!repo.exists(&alice.id, &bob.id).await.unwrap());
}

View File

@@ -1,35 +1,36 @@
use super::*;
use crate::test_helpers::seed_user_and_thought;
use chrono::Utc;
use domain::value_objects::*;
#[sqlx::test(migrations = "./migrations")] use super::*;
async fn boost_and_count(pool: sqlx::PgPool) { use crate::test_helpers::seed_user_and_thought;
let (user, thought) = seed_user_and_thought(&pool).await; use chrono::Utc;
let repo = PgBoostRepository::new(pool); use domain::value_objects::*;
let boost = Boost {
id: BoostId::new(),
user_id: user.id.clone(),
thought_id: thought.id.clone(),
ap_id: None,
created_at: Utc::now(),
};
repo.save(&boost).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
}
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn unboost(pool: sqlx::PgPool) { async fn boost_and_count(pool: sqlx::PgPool) {
let (user, thought) = seed_user_and_thought(&pool).await; let (user, thought) = seed_user_and_thought(&pool).await;
let repo = PgBoostRepository::new(pool); let repo = PgBoostRepository::new(pool);
let boost = Boost { let boost = Boost {
id: BoostId::new(), id: BoostId::new(),
user_id: user.id.clone(), user_id: user.id.clone(),
thought_id: thought.id.clone(), thought_id: thought.id.clone(),
ap_id: None, ap_id: None,
created_at: Utc::now(), created_at: Utc::now(),
}; };
repo.save(&boost).await.unwrap(); repo.save(&boost).await.unwrap();
repo.delete(&user.id, &thought.id).await.unwrap(); assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0); }
}
#[sqlx::test(migrations = "./migrations")]
async fn unboost(pool: sqlx::PgPool) {
let (user, thought) = seed_user_and_thought(&pool).await;
let repo = PgBoostRepository::new(pool);
let boost = Boost {
id: BoostId::new(),
user_id: user.id.clone(),
thought_id: thought.id.clone(),
ap_id: None,
created_at: Utc::now(),
};
repo.save(&boost).await.unwrap();
repo.delete(&user.id, &thought.id).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
}

View File

@@ -1,69 +1,76 @@
use super::*;
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::{
models::{
feed::PageParams,
thought::{Thought, Visibility},
user::User,
},
ports::{FeedQuery, ThoughtRepository, UserWriter},
value_objects::*,
};
async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) { use super::*;
let urepo = PgUserRepository::new(pool.clone()); use crate::{thought::PgThoughtRepository, user::PgUserRepository};
let trepo = PgThoughtRepository::new(pool.clone()); use domain::{
let u = User::new_local( models::{
UserId::new(), feed::PageParams,
Username::new(username).unwrap(), thought::{Thought, Visibility},
Email::new(format!("{username}@ex.com")).unwrap(), user::User,
PasswordHash("h".into()), },
); ports::{FeedQuery, ThoughtRepository, UserWriter},
urepo.save(&u).await.unwrap(); value_objects::*,
let t = Thought::new_local( };
ThoughtId::new(),
u.id.clone(), async fn seed(pool: &sqlx::PgPool, username: &str, content: &str) -> (User, Thought) {
Content::new_local(content).unwrap(), let urepo = PgUserRepository::new(pool.clone());
let trepo = PgThoughtRepository::new(pool.clone());
let u = User::new_local(
UserId::new(),
Username::new(username).unwrap(),
Email::new(format!("{username}@ex.com")).unwrap(),
PasswordHash("h".into()),
);
urepo.save(&u).await.unwrap();
let t = Thought::new_local(
ThoughtId::new(),
u.id.clone(),
Content::new_local(content).unwrap(),
None,
Visibility::Public,
None,
false,
);
trepo.save(&t).await.unwrap();
(u, t)
}
#[sqlx::test(migrations = "./migrations")]
async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) {
let (_, _) = seed(&pool, "alice", "hello").await;
let repo = PgFeedRepository::new(pool);
let result = repo
.query(&FeedQuery::public(
PageParams {
page: 1,
per_page: 20,
},
None, None,
Visibility::Public, ))
.await
.unwrap();
assert_eq!(result.total, 1);
assert_eq!(result.items[0].thought.content.as_str(), "hello");
}
#[sqlx::test(migrations = "./migrations")]
async fn search_returns_matching_thoughts(pool: sqlx::PgPool) {
let (_, _) = seed(&pool, "alice", "hello world").await;
let (_, _) = seed(&pool, "bob", "goodbye world").await;
let repo = PgFeedRepository::new(pool);
let result = repo
.query(&FeedQuery::search(
"hello world",
PageParams {
page: 1,
per_page: 20,
},
None, None,
false, ))
); .await
trepo.save(&t).await.unwrap(); .unwrap();
(u, t) assert!(result.total >= 1);
} assert!(result
.items
#[sqlx::test(migrations = "./migrations")] .iter()
async fn public_feed_returns_local_thoughts(pool: sqlx::PgPool) { .any(|e| e.thought.content.as_str() == "hello world"));
let (_, _) = seed(&pool, "alice", "hello").await; }
let repo = PgFeedRepository::new(pool);
let result = repo
.query(&FeedQuery::public(
PageParams { page: 1, per_page: 20 },
None,
))
.await
.unwrap();
assert_eq!(result.total, 1);
assert_eq!(result.items[0].thought.content.as_str(), "hello");
}
#[sqlx::test(migrations = "./migrations")]
async fn search_returns_matching_thoughts(pool: sqlx::PgPool) {
let (_, _) = seed(&pool, "alice", "hello world").await;
let (_, _) = seed(&pool, "bob", "goodbye world").await;
let repo = PgFeedRepository::new(pool);
let result = repo
.query(&FeedQuery::search(
"hello world",
PageParams { page: 1, per_page: 20 },
None,
))
.await
.unwrap();
assert!(result.total >= 1);
assert!(result
.items
.iter()
.any(|e| e.thought.content.as_str() == "hello world"));
}

View File

@@ -1,58 +1,59 @@
use super::*;
use crate::test_helpers::seed_user;
use chrono::Utc;
use domain::value_objects::*;
#[sqlx::test(migrations = "./migrations")] use super::*;
async fn save_and_find_follow(pool: sqlx::PgPool) { use crate::test_helpers::seed_user;
let alice = seed_user(&pool, "alice", "alice@ex.com").await; use chrono::Utc;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; use domain::value_objects::*;
let repo = PgFollowRepository::new(pool);
let follow = Follow {
follower_id: alice.id.clone(),
following_id: bob.id.clone(),
state: FollowState::Accepted,
ap_id: None,
created_at: Utc::now(),
};
repo.save(&follow).await.unwrap();
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
assert_eq!(found.state, FollowState::Accepted);
}
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn update_state(pool: sqlx::PgPool) { async fn save_and_find_follow(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgFollowRepository::new(pool); let repo = PgFollowRepository::new(pool);
let follow = Follow { let follow = Follow {
follower_id: alice.id.clone(), follower_id: alice.id.clone(),
following_id: bob.id.clone(), following_id: bob.id.clone(),
state: FollowState::Pending, state: FollowState::Accepted,
ap_id: None, ap_id: None,
created_at: Utc::now(), created_at: Utc::now(),
}; };
repo.save(&follow).await.unwrap(); repo.save(&follow).await.unwrap();
repo.update_state(&alice.id, &bob.id, &FollowState::Accepted) let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
.await assert_eq!(found.state, FollowState::Accepted);
.unwrap(); }
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
assert_eq!(found.state, FollowState::Accepted);
}
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn get_accepted_following_ids(pool: sqlx::PgPool) { async fn update_state(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgFollowRepository::new(pool); let repo = PgFollowRepository::new(pool);
let follow = Follow { let follow = Follow {
follower_id: alice.id.clone(), follower_id: alice.id.clone(),
following_id: bob.id.clone(), following_id: bob.id.clone(),
state: FollowState::Accepted, state: FollowState::Pending,
ap_id: None, ap_id: None,
created_at: Utc::now(), created_at: Utc::now(),
}; };
repo.save(&follow).await.unwrap(); repo.save(&follow).await.unwrap();
let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap(); repo.update_state(&alice.id, &bob.id, &FollowState::Accepted)
assert_eq!(ids, vec![bob.id]); .await
} .unwrap();
let found = repo.find(&alice.id, &bob.id).await.unwrap().unwrap();
assert_eq!(found.state, FollowState::Accepted);
}
#[sqlx::test(migrations = "./migrations")]
async fn get_accepted_following_ids(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgFollowRepository::new(pool);
let follow = Follow {
follower_id: alice.id.clone(),
following_id: bob.id.clone(),
state: FollowState::Accepted,
ap_id: None,
created_at: Utc::now(),
};
repo.save(&follow).await.unwrap();
let ids = repo.get_accepted_following_ids(&alice.id).await.unwrap();
assert_eq!(ids, vec![bob.id]);
}

View File

@@ -1,15 +1,15 @@
pub mod activitypub; pub mod activitypub;
pub mod engagement;
pub mod api_key; pub mod api_key;
pub mod block; pub mod block;
pub mod boost; pub mod boost;
mod db_error; mod db_error;
pub mod engagement;
pub mod failed_event; pub mod failed_event;
pub mod outbox;
pub mod feed; pub mod feed;
pub mod follow; pub mod follow;
pub mod like; pub mod like;
pub mod notification; pub mod notification;
pub mod outbox;
pub mod remote_actor; pub mod remote_actor;
pub mod remote_actor_connections; pub mod remote_actor_connections;
pub mod tag; pub mod tag;

View File

@@ -1,35 +1,36 @@
use super::*;
use crate::test_helpers::seed_user_and_thought;
use chrono::Utc;
use domain::value_objects::*;
#[sqlx::test(migrations = "./migrations")] use super::*;
async fn like_and_count(pool: sqlx::PgPool) { use crate::test_helpers::seed_user_and_thought;
let (user, thought) = seed_user_and_thought(&pool).await; use chrono::Utc;
let repo = PgLikeRepository::new(pool); use domain::value_objects::*;
let like = Like {
id: LikeId::new(),
user_id: user.id.clone(),
thought_id: thought.id.clone(),
ap_id: None,
created_at: Utc::now(),
};
repo.save(&like).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
}
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn unlike(pool: sqlx::PgPool) { async fn like_and_count(pool: sqlx::PgPool) {
let (user, thought) = seed_user_and_thought(&pool).await; let (user, thought) = seed_user_and_thought(&pool).await;
let repo = PgLikeRepository::new(pool); let repo = PgLikeRepository::new(pool);
let like = Like { let like = Like {
id: LikeId::new(), id: LikeId::new(),
user_id: user.id.clone(), user_id: user.id.clone(),
thought_id: thought.id.clone(), thought_id: thought.id.clone(),
ap_id: None, ap_id: None,
created_at: Utc::now(), created_at: Utc::now(),
}; };
repo.save(&like).await.unwrap(); repo.save(&like).await.unwrap();
repo.delete(&user.id, &thought.id).await.unwrap(); assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 1);
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0); }
}
#[sqlx::test(migrations = "./migrations")]
async fn unlike(pool: sqlx::PgPool) {
let (user, thought) = seed_user_and_thought(&pool).await;
let repo = PgLikeRepository::new(pool);
let like = Like {
id: LikeId::new(),
user_id: user.id.clone(),
thought_id: thought.id.clone(),
ap_id: None,
created_at: Utc::now(),
};
repo.save(&like).await.unwrap();
repo.delete(&user.id, &thought.id).await.unwrap();
assert_eq!(repo.count_for_thought(&thought.id).await.unwrap(), 0);
}

View File

@@ -1,67 +1,68 @@
use super::*;
use crate::test_helpers; use super::*;
use chrono::Utc; use crate::test_helpers;
use domain::{ use chrono::Utc;
models::{notification::NotificationKind, user::User}, use domain::{
value_objects::*, models::{notification::NotificationKind, user::User},
value_objects::*,
};
#[sqlx::test(migrations = "./migrations")]
async fn save_and_list(pool: sqlx::PgPool) {
let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await;
let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgNotificationRepository::new(pool);
use domain::models::feed::PageParams;
let n = Notification {
id: NotificationId::new(),
user_id: user.id.clone(),
kind: NotificationKind::Follow {
from_user_id: from_user.id.clone(),
},
read: false,
created_at: Utc::now(),
}; };
repo.save(&n).await.unwrap();
#[sqlx::test(migrations = "./migrations")] let page = repo
async fn save_and_list(pool: sqlx::PgPool) { .list_for_user(
let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await; &user.id,
let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await; &PageParams {
let repo = PgNotificationRepository::new(pool); page: 1,
use domain::models::feed::PageParams; per_page: 20,
let n = Notification {
id: NotificationId::new(),
user_id: user.id.clone(),
kind: NotificationKind::Follow {
from_user_id: from_user.id.clone(),
}, },
read: false, )
created_at: Utc::now(), .await
}; .unwrap();
repo.save(&n).await.unwrap(); assert_eq!(page.total, 1);
let page = repo assert!(!page.items[0].read);
.list_for_user( }
&user.id,
&PageParams {
page: 1,
per_page: 20,
},
)
.await
.unwrap();
assert_eq!(page.total, 1);
assert!(!page.items[0].read);
}
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn mark_all_read(pool: sqlx::PgPool) { async fn mark_all_read(pool: sqlx::PgPool) {
let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await; let user = test_helpers::seed_user(&pool, "alice", "alice@ex.com").await;
let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await; let from_user = test_helpers::seed_user(&pool, "bob", "bob@ex.com").await;
let repo = PgNotificationRepository::new(pool); let repo = PgNotificationRepository::new(pool);
use domain::models::feed::PageParams; use domain::models::feed::PageParams;
let n = Notification { let n = Notification {
id: NotificationId::new(), id: NotificationId::new(),
user_id: user.id.clone(), user_id: user.id.clone(),
kind: NotificationKind::Follow { kind: NotificationKind::Follow {
from_user_id: from_user.id.clone(), from_user_id: from_user.id.clone(),
},
read: false,
created_at: Utc::now(),
};
repo.save(&n).await.unwrap();
repo.mark_all_read(&user.id).await.unwrap();
let page = repo
.list_for_user(
&user.id,
&PageParams {
page: 1,
per_page: 20,
}, },
read: false, )
created_at: Utc::now(), .await
}; .unwrap();
repo.save(&n).await.unwrap(); assert!(page.items[0].read);
repo.mark_all_read(&user.id).await.unwrap(); }
let page = repo
.list_for_user(
&user.id,
&PageParams {
page: 1,
per_page: 20,
},
)
.await
.unwrap();
assert!(page.items[0].read);
}

View File

@@ -1,48 +1,49 @@
use super::*;
use crate::{thought::PgThoughtRepository, user::PgUserRepository};
use domain::ports::{ThoughtRepository, UserWriter};
use domain::{
models::{
thought::{Thought, Visibility},
user::User,
},
value_objects::*,
};
#[sqlx::test(migrations = "./migrations")] use super::*;
async fn find_or_create_tag(pool: sqlx::PgPool) { use crate::{thought::PgThoughtRepository, user::PgUserRepository};
let repo = PgTagRepository::new(pool); use domain::ports::{ThoughtRepository, UserWriter};
let t1 = repo.find_or_create("rust").await.unwrap(); use domain::{
let t2 = repo.find_or_create("rust").await.unwrap(); models::{
assert_eq!(t1.id, t2.id); thought::{Thought, Visibility},
assert_eq!(t1.name, "rust"); user::User,
} },
value_objects::*,
};
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn attach_and_list(pool: sqlx::PgPool) { async fn find_or_create_tag(pool: sqlx::PgPool) {
let urepo = PgUserRepository::new(pool.clone()); let repo = PgTagRepository::new(pool);
let trepo = PgThoughtRepository::new(pool.clone()); let t1 = repo.find_or_create("rust").await.unwrap();
let u = User::new_local( let t2 = repo.find_or_create("rust").await.unwrap();
UserId::new(), assert_eq!(t1.id, t2.id);
Username::new("alice").unwrap(), assert_eq!(t1.name, "rust");
Email::new("alice@ex.com").unwrap(), }
PasswordHash("h".into()),
); #[sqlx::test(migrations = "./migrations")]
urepo.save(&u).await.unwrap(); async fn attach_and_list(pool: sqlx::PgPool) {
let t = Thought::new_local( let urepo = PgUserRepository::new(pool.clone());
ThoughtId::new(), let trepo = PgThoughtRepository::new(pool.clone());
u.id.clone(), let u = User::new_local(
Content::new_local("hi").unwrap(), UserId::new(),
None, Username::new("alice").unwrap(),
Visibility::Public, Email::new("alice@ex.com").unwrap(),
None, PasswordHash("h".into()),
false, );
); urepo.save(&u).await.unwrap();
trepo.save(&t).await.unwrap(); let t = Thought::new_local(
let repo = PgTagRepository::new(pool); ThoughtId::new(),
let tag = repo.find_or_create("greetings").await.unwrap(); u.id.clone(),
repo.attach_to_thought(&t.id, tag.id).await.unwrap(); Content::new_local("hi").unwrap(),
let tags = repo.list_for_thought(&t.id).await.unwrap(); None,
assert_eq!(tags.len(), 1); Visibility::Public,
assert_eq!(tags[0].name, "greetings"); None,
} false,
);
trepo.save(&t).await.unwrap();
let repo = PgTagRepository::new(pool);
let tag = repo.find_or_create("greetings").await.unwrap();
repo.attach_to_thought(&t.id, tag.id).await.unwrap();
let tags = repo.list_for_thought(&t.id).await.unwrap();
assert_eq!(tags.len(), 1);
assert_eq!(tags[0].name, "greetings");
}

View File

@@ -1,90 +1,91 @@
use super::*;
use crate::test_helpers::seed_user;
use domain::{
models::thought::{Thought, Visibility},
value_objects::*,
};
#[sqlx::test(migrations = "./migrations")] use super::*;
async fn save_and_find_thought(pool: sqlx::PgPool) { use crate::test_helpers::seed_user;
let user = seed_user(&pool, "alice", "alice@ex.com").await; use domain::{
let repo = PgThoughtRepository::new(pool); models::thought::{Thought, Visibility},
let t = Thought::new_local( value_objects::*,
ThoughtId::new(), };
user.id.clone(),
Content::new_local("hello world").unwrap(),
None,
Visibility::Public,
None,
false,
);
repo.save(&t).await.unwrap();
let found = repo.find_by_id(&t.id).await.unwrap().unwrap();
assert_eq!(found.content.as_str(), "hello world");
assert!(found.local);
}
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn delete_thought(pool: sqlx::PgPool) { async fn save_and_find_thought(pool: sqlx::PgPool) {
let user = seed_user(&pool, "bob", "bob@ex.com").await; let user = seed_user(&pool, "alice", "alice@ex.com").await;
let repo = PgThoughtRepository::new(pool); let repo = PgThoughtRepository::new(pool);
let t = Thought::new_local( let t = Thought::new_local(
ThoughtId::new(), ThoughtId::new(),
user.id.clone(), user.id.clone(),
Content::new_local("bye").unwrap(), Content::new_local("hello world").unwrap(),
None, None,
Visibility::Public, Visibility::Public,
None, None,
false, false,
); );
repo.save(&t).await.unwrap(); repo.save(&t).await.unwrap();
repo.delete(&t.id, &user.id).await.unwrap(); let found = repo.find_by_id(&t.id).await.unwrap().unwrap();
assert!(repo.find_by_id(&t.id).await.unwrap().is_none()); assert_eq!(found.content.as_str(), "hello world");
} assert!(found.local);
}
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn delete_wrong_owner_returns_not_found(pool: sqlx::PgPool) { async fn delete_thought(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let user = seed_user(&pool, "bob", "bob@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let repo = PgThoughtRepository::new(pool);
let repo = PgThoughtRepository::new(pool); let t = Thought::new_local(
let t = Thought::new_local( ThoughtId::new(),
ThoughtId::new(), user.id.clone(),
alice.id.clone(), Content::new_local("bye").unwrap(),
Content::new_local("secret").unwrap(), None,
None, Visibility::Public,
Visibility::Public, None,
None, false,
false, );
); repo.save(&t).await.unwrap();
repo.save(&t).await.unwrap(); repo.delete(&t.id, &user.id).await.unwrap();
let err = repo.delete(&t.id, &bob.id).await.unwrap_err(); assert!(repo.find_by_id(&t.id).await.unwrap().is_none());
assert!(matches!(err, DomainError::NotFound)); }
}
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) { async fn delete_wrong_owner_returns_not_found(pool: sqlx::PgPool) {
let user = seed_user(&pool, "charlie", "charlie@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let repo = PgThoughtRepository::new(pool); let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let root = Thought::new_local( let repo = PgThoughtRepository::new(pool);
ThoughtId::new(), let t = Thought::new_local(
user.id.clone(), ThoughtId::new(),
Content::new_local("root").unwrap(), alice.id.clone(),
None, Content::new_local("secret").unwrap(),
Visibility::Public, None,
None, Visibility::Public,
false, None,
); false,
let reply = Thought::new_local( );
ThoughtId::new(), repo.save(&t).await.unwrap();
user.id.clone(), let err = repo.delete(&t.id, &bob.id).await.unwrap_err();
Content::new_local("reply").unwrap(), assert!(matches!(err, DomainError::NotFound));
Some(root.id.clone()), }
Visibility::Public,
None, #[sqlx::test(migrations = "./migrations")]
false, async fn get_thread_returns_root_and_replies(pool: sqlx::PgPool) {
); let user = seed_user(&pool, "charlie", "charlie@ex.com").await;
repo.save(&root).await.unwrap(); let repo = PgThoughtRepository::new(pool);
repo.save(&reply).await.unwrap(); let root = Thought::new_local(
let thread = repo.get_thread(&root.id).await.unwrap(); ThoughtId::new(),
assert_eq!(thread.len(), 2); user.id.clone(),
} Content::new_local("root").unwrap(),
None,
Visibility::Public,
None,
false,
);
let reply = Thought::new_local(
ThoughtId::new(),
user.id.clone(),
Content::new_local("reply").unwrap(),
Some(root.id.clone()),
Visibility::Public,
None,
false,
);
repo.save(&root).await.unwrap();
repo.save(&reply).await.unwrap();
let thread = repo.get_thread(&root.id).await.unwrap();
assert_eq!(thread.len(), 2);
}

View File

@@ -1,47 +1,48 @@
use super::*;
use crate::user::PgUserRepository;
use domain::ports::UserWriter;
use domain::{models::user::User, value_objects::*};
async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User { use super::*;
let repo = PgUserRepository::new(pool.clone()); use crate::user::PgUserRepository;
let u = User::new_local( use domain::ports::UserWriter;
UserId::new(), use domain::{models::user::User, value_objects::*};
Username::new(username).unwrap(),
Email::new(email).unwrap(),
PasswordHash("h".into()),
);
repo.save(&u).await.unwrap();
u
}
#[sqlx::test(migrations = "./migrations")] async fn seed_user(pool: &sqlx::PgPool, username: &str, email: &str) -> User {
async fn set_and_list_top_friends(pool: sqlx::PgPool) { let repo = PgUserRepository::new(pool.clone());
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let u = User::new_local(
let bob = seed_user(&pool, "bob", "bob@ex.com").await; UserId::new(),
let repo = PgTopFriendRepository::new(pool); Username::new(username).unwrap(),
repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)]) Email::new(email).unwrap(),
.await PasswordHash("h".into()),
.unwrap(); );
let friends = repo.list_for_user(&alice.id).await.unwrap(); repo.save(&u).await.unwrap();
assert_eq!(friends.len(), 1); u
assert_eq!(friends[0].0.position, 1); }
assert_eq!(friends[0].1.username.as_str(), "bob");
}
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn replace_top_friends(pool: sqlx::PgPool) { async fn set_and_list_top_friends(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await; let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await; let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let carol = seed_user(&pool, "carol", "carol@ex.com").await; let repo = PgTopFriendRepository::new(pool);
let repo = PgTopFriendRepository::new(pool); repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)])
repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)]) .await
.await .unwrap();
.unwrap(); let friends = repo.list_for_user(&alice.id).await.unwrap();
repo.set_top_friends(&alice.id, vec![(carol.id.clone(), 1)]) assert_eq!(friends.len(), 1);
.await assert_eq!(friends[0].0.position, 1);
.unwrap(); assert_eq!(friends[0].1.username.as_str(), "bob");
let friends = repo.list_for_user(&alice.id).await.unwrap(); }
assert_eq!(friends.len(), 1);
assert_eq!(friends[0].1.username.as_str(), "carol"); #[sqlx::test(migrations = "./migrations")]
} async fn replace_top_friends(pool: sqlx::PgPool) {
let alice = seed_user(&pool, "alice", "alice@ex.com").await;
let bob = seed_user(&pool, "bob", "bob@ex.com").await;
let carol = seed_user(&pool, "carol", "carol@ex.com").await;
let repo = PgTopFriendRepository::new(pool);
repo.set_top_friends(&alice.id, vec![(bob.id.clone(), 1)])
.await
.unwrap();
repo.set_top_friends(&alice.id, vec![(carol.id.clone(), 1)])
.await
.unwrap();
let friends = repo.list_for_user(&alice.id).await.unwrap();
assert_eq!(friends.len(), 1);
assert_eq!(friends[0].1.username.as_str(), "carol");
}

View File

@@ -139,7 +139,10 @@ impl UserReader for PgUserRepository {
.into_domain() .into_domain()
} }
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> { async fn list_paginated(
&self,
page: PageParams,
) -> Result<Paginated<UserSummary>, DomainError> {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct Row { struct Row {
id: uuid::Uuid, id: uuid::Uuid,
@@ -187,7 +190,12 @@ impl UserReader for PgUserRepository {
following_count: r.following_count, following_count: r.following_count,
}) })
.collect(); .collect();
Ok(Paginated { items, total, page: page.page, per_page: page.per_page }) Ok(Paginated {
items,
total,
page: page.page,
per_page: page.per_page,
})
} }
async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError> { async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError> {
@@ -195,18 +203,19 @@ impl UserReader for PgUserRepository {
return Ok(HashMap::new()); return Ok(HashMap::new());
} }
let uuids: Vec<uuid::Uuid> = ids.iter().map(|id| id.as_uuid()).collect(); let uuids: Vec<uuid::Uuid> = ids.iter().map(|id| id.as_uuid()).collect();
let rows = sqlx::query_as::<_, UserRow>( let rows = sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE id = ANY($1)"))
&format!("{USER_SELECT} WHERE id = ANY($1)") .bind(&uuids[..])
) .fetch_all(&self.pool)
.bind(&uuids[..]) .await
.fetch_all(&self.pool) .into_domain()?;
.await
.into_domain()?;
Ok(rows.into_iter().map(|r| { Ok(rows
let user = User::from(r); .into_iter()
(user.id.clone(), user) .map(|r| {
}).collect()) let user = User::from(r);
(user.id.clone(), user)
})
.collect())
} }
} }

View File

@@ -1,69 +1,70 @@
use super::*;
use domain::{models::user::User, value_objects::*};
#[sqlx::test(migrations = "./migrations")] use super::*;
async fn save_and_find_by_id(pool: sqlx::PgPool) { use domain::{models::user::User, value_objects::*};
let repo = PgUserRepository::new(pool);
let user = User::new_local(
UserId::new(),
Username::new("alice").unwrap(),
Email::new("alice@ex.com").unwrap(),
PasswordHash("hash".into()),
);
repo.save(&user).await.unwrap();
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(found.username.as_str(), "alice");
assert_eq!(found.email.as_str(), "alice@ex.com");
}
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) { async fn save_and_find_by_id(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool); let repo = PgUserRepository::new(pool);
let result = repo let user = User::new_local(
.find_by_username(&Username::new("ghost").unwrap()) UserId::new(),
.await Username::new("alice").unwrap(),
.unwrap(); Email::new("alice@ex.com").unwrap(),
assert!(result.is_none()); PasswordHash("hash".into()),
} );
repo.save(&user).await.unwrap();
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(found.username.as_str(), "alice");
assert_eq!(found.email.as_str(), "alice@ex.com");
}
#[sqlx::test(migrations = "./migrations")] #[sqlx::test(migrations = "./migrations")]
async fn find_by_email(pool: sqlx::PgPool) { async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool); let repo = PgUserRepository::new(pool);
let user = User::new_local( let result = repo
UserId::new(), .find_by_username(&Username::new("ghost").unwrap())
Username::new("bob").unwrap(),
Email::new("bob@ex.com").unwrap(),
PasswordHash("hash".into()),
);
repo.save(&user).await.unwrap();
let found = repo
.find_by_email(&Email::new("bob@ex.com").unwrap())
.await
.unwrap();
assert!(found.is_some());
}
#[sqlx::test(migrations = "./migrations")]
async fn update_profile_changes_fields(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool);
let user = User::new_local(
UserId::new(),
Username::new("charlie").unwrap(),
Email::new("charlie@ex.com").unwrap(),
PasswordHash("hash".into()),
);
repo.save(&user).await.unwrap();
repo.update_profile(
&user.id,
Some("Charlie".into()),
Some("bio".into()),
None,
None,
None,
)
.await .await
.unwrap(); .unwrap();
let found = repo.find_by_id(&user.id).await.unwrap().unwrap(); assert!(result.is_none());
assert_eq!(found.display_name.as_deref(), Some("Charlie")); }
assert_eq!(found.bio.as_deref(), Some("bio"));
} #[sqlx::test(migrations = "./migrations")]
async fn find_by_email(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool);
let user = User::new_local(
UserId::new(),
Username::new("bob").unwrap(),
Email::new("bob@ex.com").unwrap(),
PasswordHash("hash".into()),
);
repo.save(&user).await.unwrap();
let found = repo
.find_by_email(&Email::new("bob@ex.com").unwrap())
.await
.unwrap();
assert!(found.is_some());
}
#[sqlx::test(migrations = "./migrations")]
async fn update_profile_changes_fields(pool: sqlx::PgPool) {
let repo = PgUserRepository::new(pool);
let user = User::new_local(
UserId::new(),
Username::new("charlie").unwrap(),
Email::new("charlie@ex.com").unwrap(),
PasswordHash("hash".into()),
);
repo.save(&user).await.unwrap();
repo.update_profile(
&user.id,
Some("Charlie".into()),
Some("bio".into()),
None,
None,
None,
)
.await
.unwrap();
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(found.display_name.as_deref(), Some("Charlie"));
assert_eq!(found.bio.as_deref(), Some("bio"));
}

View File

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

View File

@@ -106,11 +106,7 @@ impl ActivityPubRepository for TestApRepo {
) -> Result<ThoughtId, DomainError> { ) -> Result<ThoughtId, DomainError> {
Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4())) Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4()))
} }
async fn apply_note_update( async fn apply_note_update(&self, _ap_id: &str, _new_content: &str) -> Result<(), DomainError> {
&self,
_ap_id: &str,
_new_content: &str,
) -> Result<(), DomainError> {
Ok(()) Ok(())
} }
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> { 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 hash = hasher.hash(&input.password).await?;
let user = User::new_local(UserId::new(), username, email, hash); let user = User::new_local(UserId::new(), username, email, hash);
users users.save(&user).await.map_err(|e| match e {
.save(&user) DomainError::UniqueViolation { field: "username" } => {
.await DomainError::Conflict("username taken".into())
.map_err(|e| match e { }
DomainError::UniqueViolation { field: "username" } => { DomainError::UniqueViolation { field: "email" } => {
DomainError::Conflict("username taken".into()) DomainError::Conflict("email taken".into())
} }
DomainError::UniqueViolation { field: "email" } => { DomainError::UniqueViolation { .. } => DomainError::Conflict("already exists".into()),
DomainError::Conflict("email taken".into()) other => other,
} })?;
DomainError::UniqueViolation { .. } => {
DomainError::Conflict("already exists".into())
}
other => other,
})?;
events events
.publish(&DomainEvent::UserRegistered { .publish(&DomainEvent::UserRegistered {
user_id: user.id.clone(), user_id: user.id.clone(),

View File

@@ -3,7 +3,10 @@ use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{feed::{PageParams, Paginated, UserSummary}, user::User}, models::{
feed::{PageParams, Paginated, UserSummary},
user::User,
},
ports::{AuthService, GeneratedToken, PasswordHasher, UserReader, UserWriter}, ports::{AuthService, GeneratedToken, PasswordHasher, UserReader, UserWriter},
testing::{NoOpEventPublisher, TestStore}, testing::{NoOpEventPublisher, TestStore},
value_objects::{Email, PasswordHash, UserId, Username}, 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> { async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
self.0.find_by_id(id).await self.0.find_by_id(id).await
} }
async fn find_by_username( async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
&self,
username: &Username,
) -> Result<Option<User>, DomainError> {
self.0.find_by_username(username).await self.0.find_by_username(username).await
} }
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> { 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> { async fn count(&self) -> Result<i64, DomainError> {
self.0.count().await 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 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 self.0.find_by_ids(ids).await
} }
} }
@@ -57,7 +63,14 @@ impl UserWriter for ConflictOnSaveStore {
custom_css: Option<String>, custom_css: Option<String>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
self.0 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 .await
} }
} }
@@ -67,10 +80,7 @@ impl UserReader for EmailConflictOnSaveStore {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> { async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
self.0.find_by_id(id).await self.0.find_by_id(id).await
} }
async fn find_by_username( async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
&self,
username: &Username,
) -> Result<Option<User>, DomainError> {
self.0.find_by_username(username).await self.0.find_by_username(username).await
} }
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> { 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> { async fn count(&self) -> Result<i64, DomainError> {
self.0.count().await 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 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 self.0.find_by_ids(ids).await
} }
} }
@@ -105,7 +121,14 @@ impl UserWriter for EmailConflictOnSaveStore {
custom_css: Option<String>, custom_css: Option<String>,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
self.0 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 .await
} }
} }

View File

@@ -7,9 +7,9 @@ use domain::{
remote_actor::RemoteActor, remote_actor::RemoteActor,
}, },
ports::{ ports::{
EventPublisher, FederationActionPort, FederationFollowPort, EventPublisher, FederationActionPort, FederationFollowPort, FederationFollowRequestPort,
FederationFollowRequestPort, FederationSchedulerPort, FeedQuery, FeedRepository, FederationSchedulerPort, FeedQuery, FeedRepository, FollowRepository,
FollowRepository, RemoteActorConnectionRepository, UserReader, RemoteActorConnectionRepository, UserReader,
}, },
value_objects::UserId, value_objects::UserId,
}; };
@@ -86,7 +86,13 @@ pub async fn get_remote_actor_posts(
Some(id) => id, Some(id) => id,
None => ap_repo.intern_remote_actor(&actor.url).await?, 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 { if let Some(outbox_url) = actor.outbox_url {
let _ = scheduler let _ = scheduler
.schedule_actor_posts_fetch(&actor.url, &outbox_url) .schedule_actor_posts_fetch(&actor.url, &outbox_url)

View File

@@ -13,5 +13,6 @@ pub async fn get_home_feed(
) -> Result<Paginated<FeedEntry>, DomainError> { ) -> Result<Paginated<FeedEntry>, DomainError> {
let mut following_ids = follows.get_accepted_following_ids(user_id).await?; let mut following_ids = follows.get_accepted_following_ids(user_id).await?;
following_ids.push(user_id.clone()); 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}, feed::{EngagementStats, FeedEntry},
thought::{Thought, Visibility}, thought::{Thought, Visibility},
}, },
ports::{EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserReader}, ports::{
EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository,
UserReader,
},
value_objects::{Content, ThoughtId, UserId}, value_objects::{Content, ThoughtId, UserId},
}; };
@@ -133,10 +136,20 @@ pub async fn get_thought_view(
.await? .await?
.ok_or(DomainError::NotFound)?; .ok_or(DomainError::NotFound)?;
let mut map = engagement.get_for_thoughts(&[id.clone()], viewer).await?; let mut map = engagement.get_for_thoughts(&[id.clone()], viewer).await?;
let (stats, viewer_ctx) = map.remove(id).unwrap_or( let (stats, viewer_ctx) = map.remove(id).unwrap_or((
(EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None) EngagementStats {
); like_count: 0,
Ok(FeedEntry { thought, author, stats, viewer: viewer_ctx }) 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. /// 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) .get(&thought.user_id)
.cloned() .cloned()
.ok_or(DomainError::NotFound)?; .ok_or(DomainError::NotFound)?;
let (stats, viewer_ctx) = engagement_map.remove(&thought.id).unwrap_or( let (stats, viewer_ctx) = engagement_map.remove(&thought.id).unwrap_or((
(EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, None) EngagementStats {
); like_count: 0,
entries.push(FeedEntry { thought, author, stats, viewer: viewer_ctx }); boost_count: 0,
reply_count: 0,
},
None,
));
entries.push(FeedEntry {
thought,
author,
stats,
viewer: viewer_ctx,
});
} }
Ok(entries) Ok(entries)
} }

View File

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

View File

@@ -8,7 +8,11 @@ use std::sync::Arc;
use activitypub::ThoughtsObjectHandler; use activitypub::ThoughtsObjectHandler;
use activitypub_base::service::ActivityPubService; use activitypub_base::service::ActivityPubService;
use auth::ApiKeyServiceImpl; use auth::ApiKeyServiceImpl;
use domain::{errors::DomainError, events::DomainEvent, ports::{EventPublisher, OutboxWriter}}; use domain::{
errors::DomainError,
events::DomainEvent,
ports::{EventPublisher, OutboxWriter},
};
use event_transport::EventPublisherAdapter; use event_transport::EventPublisherAdapter;
use nats::NatsTransport; use nats::NatsTransport;
use postgres::activitypub::PgActivityPubRepository; use postgres::activitypub::PgActivityPubRepository;
@@ -130,9 +134,9 @@ pub async fn build(cfg: &Config) -> Infrastructure {
ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())), ap_repo: Arc::new(PgActivityPubRepository::new(pool.clone())),
remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())), remote_actor_connections: Arc::new(PgRemoteActorConnectionRepository::new(pool.clone())),
federation_scheduler: ap_service.clone() as Arc<dyn domain::ports::FederationSchedulerPort>, federation_scheduler: ap_service.clone() as Arc<dyn domain::ports::FederationSchedulerPort>,
api_key_auth: Arc::new(ApiKeyServiceImpl::new( api_key_auth: Arc::new(ApiKeyServiceImpl::new(Arc::new(
Arc::new(postgres::api_key::PgApiKeyRepository::new(pool.clone())), postgres::api_key::PgApiKeyRepository::new(pool.clone()),
)), ))),
engagement: Arc::new(PgEngagementRepository::new(pool.clone())), engagement: Arc::new(PgEngagementRepository::new(pool.clone())),
}; };

View File

@@ -58,7 +58,8 @@ pub trait UserReader: Send + Sync {
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>; async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError>;
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError>; async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError>;
async fn count(&self) -> Result<i64, DomainError>; async fn count(&self) -> Result<i64, DomainError>;
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError>; async fn list_paginated(&self, page: PageParams)
-> Result<Paginated<UserSummary>, DomainError>;
async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError>; async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError>;
} }
@@ -353,19 +354,43 @@ pub struct FeedQuery {
impl FeedQuery { impl FeedQuery {
pub fn home(viewer_id: UserId, following_ids: Vec<UserId>, page: PageParams) -> Self { pub fn home(viewer_id: UserId, following_ids: Vec<UserId>, page: PageParams) -> Self {
Self { scope: FeedScope::Home { following_ids }, page, viewer_id: Some(viewer_id) } Self {
scope: FeedScope::Home { following_ids },
page,
viewer_id: Some(viewer_id),
}
} }
pub fn public(page: PageParams, viewer_id: Option<UserId>) -> Self { pub fn public(page: PageParams, viewer_id: Option<UserId>) -> Self {
Self { scope: FeedScope::Public, page, viewer_id } Self {
scope: FeedScope::Public,
page,
viewer_id,
}
} }
pub fn tag(tag_name: impl Into<String>, page: PageParams, viewer_id: Option<UserId>) -> Self { pub fn tag(tag_name: impl Into<String>, page: PageParams, viewer_id: Option<UserId>) -> Self {
Self { scope: FeedScope::Tag { tag_name: tag_name.into() }, page, viewer_id } Self {
scope: FeedScope::Tag {
tag_name: tag_name.into(),
},
page,
viewer_id,
}
} }
pub fn user(user_id: UserId, page: PageParams, viewer_id: Option<UserId>) -> Self { pub fn user(user_id: UserId, page: PageParams, viewer_id: Option<UserId>) -> Self {
Self { scope: FeedScope::User { user_id }, page, viewer_id } Self {
scope: FeedScope::User { user_id },
page,
viewer_id,
}
} }
pub fn search(query: impl Into<String>, page: PageParams, viewer_id: Option<UserId>) -> Self { pub fn search(query: impl Into<String>, page: PageParams, viewer_id: Option<UserId>) -> Self {
Self { scope: FeedScope::Search { query: query.into() }, page, viewer_id } Self {
scope: FeedScope::Search {
query: query.into(),
},
page,
viewer_id,
}
} }
} }
@@ -392,7 +417,6 @@ pub trait SearchPort: Send + Sync {
) -> Result<Paginated<User>, DomainError>; ) -> Result<Paginated<User>, DomainError>;
} }
#[async_trait] #[async_trait]
pub trait FederationSchedulerPort: Send + Sync { pub trait FederationSchedulerPort: Send + Sync {
async fn schedule_actor_posts_fetch( async fn schedule_actor_posts_fetch(

View File

@@ -83,17 +83,30 @@ impl UserReader for TestStore {
.count() as i64) .count() as i64)
} }
async fn list_paginated(&self, page: PageParams) -> Result<Paginated<UserSummary>, DomainError> { async fn list_paginated(
&self,
page: PageParams,
) -> Result<Paginated<UserSummary>, DomainError> {
let all = self.list_with_stats().await?; let all = self.list_with_stats().await?;
let total = all.len() as i64; let total = all.len() as i64;
let start = page.offset() as usize; let start = page.offset() as usize;
let items: Vec<UserSummary> = all.into_iter().skip(start).take(page.limit() as usize).collect(); let items: Vec<UserSummary> = all
Ok(Paginated { items, total, page: page.page, per_page: page.per_page }) .into_iter()
.skip(start)
.take(page.limit() as usize)
.collect();
Ok(Paginated {
items,
total,
page: page.page,
per_page: page.per_page,
})
} }
async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError> { async fn find_by_ids(&self, ids: &[UserId]) -> Result<HashMap<UserId, User>, DomainError> {
let g = self.users.lock().unwrap(); let g = self.users.lock().unwrap();
let map = g.iter() let map = g
.iter()
.filter(|u| ids.contains(&u.id)) .filter(|u| ids.contains(&u.id))
.map(|u| (u.id.clone(), u.clone())) .map(|u| (u.id.clone(), u.clone()))
.collect(); .collect();
@@ -294,7 +307,16 @@ impl EngagementRepository for TestStore {
&self, &self,
thought_ids: &[ThoughtId], thought_ids: &[ThoughtId],
viewer_id: Option<&UserId>, viewer_id: Option<&UserId>,
) -> Result<HashMap<ThoughtId, (crate::models::feed::EngagementStats, Option<crate::models::feed::ViewerContext>)>, DomainError> { ) -> Result<
HashMap<
ThoughtId,
(
crate::models::feed::EngagementStats,
Option<crate::models::feed::ViewerContext>,
),
>,
DomainError,
> {
use crate::models::feed::{EngagementStats, ViewerContext}; use crate::models::feed::{EngagementStats, ViewerContext};
let likes = self.likes.lock().unwrap(); let likes = self.likes.lock().unwrap();
let boosts = self.boosts.lock().unwrap(); let boosts = self.boosts.lock().unwrap();
@@ -304,12 +326,29 @@ impl EngagementRepository for TestStore {
for tid in thought_ids { for tid in thought_ids {
let like_count = likes.iter().filter(|l| &l.thought_id == tid).count() as i64; let like_count = likes.iter().filter(|l| &l.thought_id == tid).count() as i64;
let boost_count = boosts.iter().filter(|b| &b.thought_id == tid).count() as i64; let boost_count = boosts.iter().filter(|b| &b.thought_id == tid).count() as i64;
let reply_count = thoughts.iter().filter(|t| t.in_reply_to_id.as_ref() == Some(tid)).count() as i64; let reply_count = thoughts
.iter()
.filter(|t| t.in_reply_to_id.as_ref() == Some(tid))
.count() as i64;
let viewer = viewer_id.map(|vid| ViewerContext { let viewer = viewer_id.map(|vid| ViewerContext {
liked: likes.iter().any(|l| &l.thought_id == tid && &l.user_id == vid), liked: likes
boosted: boosts.iter().any(|b| &b.thought_id == tid && &b.user_id == vid), .iter()
.any(|l| &l.thought_id == tid && &l.user_id == vid),
boosted: boosts
.iter()
.any(|b| &b.thought_id == tid && &b.user_id == vid),
}); });
result.insert(tid.clone(), (EngagementStats { like_count, boost_count, reply_count }, viewer)); result.insert(
tid.clone(),
(
EngagementStats {
like_count,
boost_count,
reply_count,
},
viewer,
),
);
} }
Ok(result) Ok(result)
} }
@@ -763,7 +802,10 @@ impl RemoteActorConnectionRepository for TestStore {
#[async_trait] #[async_trait]
impl FeedRepository for TestStore { impl FeedRepository for TestStore {
async fn query(&self, _q: &crate::ports::FeedQuery) -> Result<Paginated<FeedEntry>, DomainError> { async fn query(
&self,
_q: &crate::ports::FeedQuery,
) -> Result<Paginated<FeedEntry>, DomainError> {
Ok(Paginated { Ok(Paginated {
items: vec![], items: vec![],
total: 0, total: 0,

View File

@@ -8,11 +8,7 @@ use api_types::{
responses::{ApiKeyResponse, CreatedApiKeyResponse}, responses::{ApiKeyResponse, CreatedApiKeyResponse},
}; };
use application::use_cases::api_keys::{create_api_key, delete_api_key, list_api_keys}; use application::use_cases::api_keys::{create_api_key, delete_api_key, list_api_keys};
use axum::{ use axum::{extract::Path, http::StatusCode, Json};
extract::Path,
http::StatusCode,
Json,
};
use domain::{ports::ApiKeyRepository, value_objects::ApiKeyId}; use domain::{ports::ApiKeyRepository, value_objects::ApiKeyId};
use uuid::Uuid; use uuid::Uuid;

View File

@@ -1,8 +1,4 @@
use crate::{ use crate::{deps_struct, errors::ApiError, extractors::Deps};
deps_struct,
errors::ApiError,
extractors::Deps,
};
use api_types::{ use api_types::{
requests::{LoginRequest, RegisterRequest}, requests::{LoginRequest, RegisterRequest},
responses::{AuthResponse, ErrorResponse, UserResponse}, responses::{AuthResponse, ErrorResponse, UserResponse},

View File

@@ -4,6 +4,7 @@ use crate::{
handlers::feed::to_thought_response, handlers::feed::to_thought_response,
state::AppState, state::AppState,
}; };
use activitypub_base::ActivityPubRepository;
use api_types::{ use api_types::{
requests::PaginationQuery, requests::PaginationQuery,
responses::{ActorConnectionPageResponse, ActorConnectionResponse}, responses::{ActorConnectionPageResponse, ActorConnectionResponse},
@@ -15,7 +16,6 @@ use axum::{
extract::{Path, Query}, extract::{Path, Query},
Json, Json,
}; };
use activitypub_base::ActivityPubRepository;
use domain::{ use domain::{
models::feed::PageParams, models::feed::PageParams,
ports::{ ports::{

View File

@@ -16,7 +16,10 @@ use axum::{
}; };
use domain::{ use domain::{
models::feed::PageParams, models::feed::PageParams,
ports::{FederationActionPort, FeedQuery, FeedRepository, FollowRepository, SearchPort, TagRepository, UserRepository}, ports::{
FederationActionPort, FeedQuery, FeedRepository, FollowRepository, SearchPort,
TagRepository, UserRepository,
},
}; };
deps_struct!(FeedDeps { deps_struct!(FeedDeps {
@@ -224,7 +227,10 @@ pub async fn user_thoughts_handler(
page: q.page(), page: q.page(),
per_page: q.per_page(), per_page: q.per_page(),
}; };
let result = d.feed.query(&FeedQuery::user(user.id.clone(), page, viewer)).await?; let result = d
.feed
.query(&FeedQuery::user(user.id.clone(), page, viewer))
.await?;
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
"total": result.total, "total": result.total,
"page": result.page, "page": result.page,
@@ -241,7 +247,10 @@ pub async fn get_popular_tags(
.get("limit") .get("limit")
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(api_types::requests::DEFAULT_PER_PAGE as usize); .unwrap_or(api_types::requests::DEFAULT_PER_PAGE as usize);
let tags = d.tags.popular_tags(limit.min(api_types::requests::MAX_PER_PAGE as usize)).await?; let tags = d
.tags
.popular_tags(limit.min(api_types::requests::MAX_PER_PAGE as usize))
.await?;
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
"tags": tags.iter().map(|(name, count)| serde_json::json!({ "tags": tags.iter().map(|(name, count)| serde_json::json!({
"name": name, "name": name,
@@ -268,7 +277,10 @@ pub async fn tag_thoughts_handler(
page: q.page(), page: q.page(),
per_page: q.per_page(), per_page: q.per_page(),
}; };
let result = d.feed.query(&FeedQuery::tag(&tag_name, page, viewer)).await?; let result = d
.feed
.query(&FeedQuery::tag(&tag_name, page, viewer))
.await?;
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
"tag": tag_name, "tag": tag_name,
"total": result.total, "total": result.total,

View File

@@ -8,11 +8,7 @@ use application::use_cases::notifications::{
count_unread_notifications, list_notifications as uc_list_notifications, count_unread_notifications, list_notifications as uc_list_notifications,
mark_all_notifications_read, mark_notification_read as uc_mark_notification_read, mark_all_notifications_read, mark_notification_read as uc_mark_notification_read,
}; };
use axum::{ use axum::{extract::Path, http::StatusCode, Json};
extract::Path,
http::StatusCode,
Json,
};
use domain::{ use domain::{
models::feed::PageParams, ports::NotificationRepository, value_objects::NotificationId, models::feed::PageParams, ports::NotificationRepository, value_objects::NotificationId,
}; };

View File

@@ -1,3 +1,4 @@
use crate::handlers::auth::to_user_response;
use crate::{ use crate::{
deps_struct, deps_struct,
errors::ApiError, errors::ApiError,
@@ -5,14 +6,9 @@ use crate::{
}; };
use api_types::requests::SetTopFriendsRequest; use api_types::requests::SetTopFriendsRequest;
use api_types::responses::TopFriendsResponse; use api_types::responses::TopFriendsResponse;
use crate::handlers::auth::to_user_response;
use application::use_cases::profile::{get_top_friends, get_user_by_username, set_top_friends}; use application::use_cases::profile::{get_top_friends, get_user_by_username, set_top_friends};
use application::use_cases::social::*; use application::use_cases::social::*;
use axum::{ use axum::{extract::Path, http::StatusCode, Json};
extract::Path,
http::StatusCode,
Json,
};
use domain::{ use domain::{
ports::{ ports::{
BlockRepository, BoostRepository, EventPublisher, FederationActionPort, FollowRepository, BlockRepository, BoostRepository, EventPublisher, FederationActionPort, FollowRepository,

View File

@@ -9,18 +9,16 @@ use api_types::{
responses::ErrorResponse, responses::ErrorResponse,
}; };
use application::use_cases::thoughts::{ use application::use_cases::thoughts::{
create_thought, delete_thought, edit_thought, get_thread_views, get_thought_view, create_thought, delete_thought, edit_thought, get_thought_view, get_thread_views,
CreateThoughtInput, CreateThoughtInput,
}; };
use axum::{ use axum::{extract::Path, http::StatusCode, response::IntoResponse, Json};
extract::Path,
http::StatusCode,
response::IntoResponse,
Json,
};
use domain::{ use domain::{
models::feed::{EngagementStats, FeedEntry, ViewerContext}, models::feed::{EngagementStats, FeedEntry, ViewerContext},
ports::{EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository, UserRepository}, ports::{
EngagementRepository, EventPublisher, OutboxWriter, TagRepository, ThoughtRepository,
UserRepository,
},
value_objects::ThoughtId, value_objects::ThoughtId,
}; };
use uuid::Uuid; use uuid::Uuid;
@@ -74,8 +72,15 @@ pub async fn post_thought(
let entry = FeedEntry { let entry = FeedEntry {
thought: out.thought, thought: out.thought,
author, author,
stats: EngagementStats { like_count: 0, boost_count: 0, reply_count: 0 }, stats: EngagementStats {
viewer: Some(ViewerContext { liked: false, boosted: false }), like_count: 0,
boost_count: 0,
reply_count: 0,
},
viewer: Some(ViewerContext {
liked: false,
boosted: false,
}),
}; };
Ok((StatusCode::CREATED, Json(to_thought_response(&entry)))) Ok((StatusCode::CREATED, Json(to_thought_response(&entry))))
} }
@@ -101,7 +106,9 @@ pub async fn get_thought_handler(
viewer.as_ref(), viewer.as_ref(),
) )
.await?; .await?;
Ok(Json(serde_json::to_value(to_thought_response(&entry)).unwrap())) Ok(Json(
serde_json::to_value(to_thought_response(&entry)).unwrap(),
))
} }
#[utoipa::path( #[utoipa::path(
@@ -119,7 +126,14 @@ pub async fn delete_thought_handler(
AuthUser(uid): AuthUser, AuthUser(uid): AuthUser,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
delete_thought(&*d.thoughts, &*d.events, &*d.outbox, &ThoughtId::from_uuid(id), &uid).await?; delete_thought(
&*d.thoughts,
&*d.events,
&*d.outbox,
&ThoughtId::from_uuid(id),
&uid,
)
.await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }

View File

@@ -191,9 +191,7 @@ pub async fn get_users(
}))) })))
} }
pub async fn get_user_count( pub async fn get_user_count(Deps(d): Deps<UsersDeps>) -> Result<Json<serde_json::Value>, ApiError> {
Deps(d): Deps<UsersDeps>,
) -> Result<Json<serde_json::Value>, ApiError> {
let count = d.users.count().await?; let count = d.users.count().await?;
Ok(Json(serde_json::json!({ "count": count }))) Ok(Json(serde_json::json!({ "count": count })))
} }

View File

@@ -79,11 +79,7 @@ impl ActivityPubRepository for NoOpApRepo {
) -> Result<ThoughtId, DomainError> { ) -> Result<ThoughtId, DomainError> {
Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4())) Ok(ThoughtId::from_uuid(uuid::Uuid::new_v4()))
} }
async fn apply_note_update( async fn apply_note_update(&self, _ap_id: &str, _new_content: &str) -> Result<(), DomainError> {
&self,
_ap_id: &str,
_new_content: &str,
) -> Result<(), DomainError> {
Ok(()) Ok(())
} }
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> { async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {

View File

@@ -5,8 +5,8 @@ use std::sync::Arc;
use activitypub::ThoughtsObjectHandler; use activitypub::ThoughtsObjectHandler;
use activitypub_base::ActivityPubService; use activitypub_base::ActivityPubService;
use application::services::{FederationEventService, NotificationEventService};
use activitypub_base::{ActivityPubRepository, OutboundFederationPort}; use activitypub_base::{ActivityPubRepository, OutboundFederationPort};
use application::services::{FederationEventService, NotificationEventService};
use domain::ports::EventPublisher; use domain::ports::EventPublisher;
use postgres::activitypub::PgActivityPubRepository; use postgres::activitypub::PgActivityPubRepository;
use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository}; use postgres_federation::{PostgresApUserRepository, PostgresFederationRepository};

View File

@@ -44,7 +44,11 @@ async fn main() {
Ok(envelope) => { Ok(envelope) => {
let event = &envelope.event; let event = &envelope.event;
let event_type = event_payload::EventPayload::from(event).subject(); let event_type = event_payload::EventPayload::from(event).subject();
tracing::info!(event_type, delivery = envelope.delivery_count, "received event"); tracing::info!(
event_type,
delivery = envelope.delivery_count,
"received event"
);
let n = infra.handlers.notification.handle(event).await; let n = infra.handlers.notification.handle(event).await;
let f = infra.handlers.federation.handle(event).await; let f = infra.handlers.federation.handle(event).await;

View File

@@ -57,7 +57,11 @@ impl OutboxRelay {
let payload: EventPayload = match serde_json::from_value(row.payload.clone()) { let payload: EventPayload = match serde_json::from_value(row.payload.clone()) {
Ok(p) => p, Ok(p) => p,
Err(e) => { Err(e) => {
tracing::error!(seq = row.seq, event_type = row.event_type, "outbox: failed to deserialize payload: {e}"); tracing::error!(
seq = row.seq,
event_type = row.event_type,
"outbox: failed to deserialize payload: {e}"
);
// Mark delivered to avoid blocking; investigate manually. // Mark delivered to avoid blocking; investigate manually.
sqlx::query( sqlx::query(
"UPDATE outbox_events \ "UPDATE outbox_events \
@@ -75,7 +79,10 @@ impl OutboxRelay {
let domain_event = match DomainEvent::try_from(payload) { let domain_event = match DomainEvent::try_from(payload) {
Ok(ev) => ev, Ok(ev) => ev,
Err(e) => { Err(e) => {
tracing::error!(seq = row.seq, "outbox: failed to convert to DomainEvent: {e}"); tracing::error!(
seq = row.seq,
"outbox: failed to convert to DomainEvent: {e}"
);
sqlx::query( sqlx::query(
"UPDATE outbox_events \ "UPDATE outbox_events \
SET delivered = true, delivered_at = now() \ SET delivered = true, delivered_at = now() \
@@ -100,7 +107,11 @@ impl OutboxRelay {
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
tx.commit().await?; tx.commit().await?;
tracing::info!(seq = row.seq, event_type = row.event_type, "outbox: delivered"); tracing::info!(
seq = row.seq,
event_type = row.event_type,
"outbox: delivered"
);
} }
Err(e) => { Err(e) => {
tracing::warn!(seq = row.seq, "outbox: publish failed (will retry): {e}"); tracing::warn!(seq = row.seq, "outbox: publish failed (will retry): {e}");