refactor: 5 architectural improvements (Tasks 2-5 + Task 6 fix)
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 5m2s
test / unit (pull_request) Successful in 16m19s
test / integration (pull_request) Failing after 17m15s
Some checks failed
lint / lint (push) Has been cancelled
test / unit (push) Has been cancelled
test / integration (push) Has been cancelled
lint / lint (pull_request) Failing after 5m2s
test / unit (pull_request) Successful in 16m19s
test / integration (pull_request) Failing after 17m15s
- feat(domain): Hashtag value object with canonical extract() — unifies two divergent private implementations; fields pre-compute raw/normalized/url_slug/ap_name - feat(presentation): Deps<S: FromAppState> extractor — each handler now declares its exact dependency surface; AppState unchanged; handlers become unit-testable without mocking all 20 deps - refactor(feed): replace 5 flat FeedRepository methods with FeedQuery/FeedScope — single query() method; SQL shared logic lives once; adding feed types no longer requires 5 edits - refactor(activitypub): ActivityPubRepository + OutboundFederationPort moved out of domain::ports into activitypub-base::ap_ports — domain crate no longer knows about AP IDs, inboxes, or actor URLs - fix(outbox): OutboxRelay now opens a per-row transaction so FOR UPDATE SKIP LOCKED actually holds the lock during publish + mark_delivered
This commit is contained in:
@@ -4,8 +4,9 @@ version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
activitypub-base = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
pub mod services;
|
||||
pub mod use_cases;
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod testing;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use activitypub_base::{ActivityPubRepository, OutboundFederationPort};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::thought::Visibility,
|
||||
ports::{ActivityPubRepository, OutboundFederationPort, ThoughtRepository, UserReader},
|
||||
ports::{ThoughtRepository, UserReader},
|
||||
value_objects::ThoughtId,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
@@ -212,13 +213,14 @@ impl FederationEventService {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use activitypub_base::{ActorApUrls, OutboundFederationPort};
|
||||
use async_trait::async_trait;
|
||||
use crate::testing::TestApRepo;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::thought::{Thought, Visibility},
|
||||
models::user::User,
|
||||
ports::{ActivityPubRepository, OutboundFederationPort},
|
||||
testing::TestStore,
|
||||
value_objects::*,
|
||||
};
|
||||
@@ -325,12 +327,23 @@ mod tests {
|
||||
}
|
||||
|
||||
fn svc(store: &TestStore, spy: Arc<SpyPort>) -> FederationEventService {
|
||||
let ap_repo = TestApRepo::new(store.clone());
|
||||
FederationEventService {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
users: Arc::new(store.clone()),
|
||||
ap: spy,
|
||||
base_url: "https://example.com".to_string(),
|
||||
ap_repo: Arc::new(store.clone()),
|
||||
ap_repo: Arc::new(ap_repo),
|
||||
}
|
||||
}
|
||||
|
||||
fn svc_with_ap(store: &TestStore, ap_repo: TestApRepo, spy: Arc<SpyPort>) -> FederationEventService {
|
||||
FederationEventService {
|
||||
thoughts: Arc::new(store.clone()),
|
||||
users: Arc::new(store.clone()),
|
||||
ap: spy,
|
||||
base_url: "https://example.com".to_string(),
|
||||
ap_repo: Arc::new(ap_repo),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,7 +465,8 @@ mod tests {
|
||||
let alice = alice();
|
||||
let mut thought = local_thought(alice.id.clone());
|
||||
thought.local = false;
|
||||
store.thought_ap_ids.lock().unwrap().insert(
|
||||
let ap_repo = TestApRepo::new(store.clone());
|
||||
ap_repo.inner.thought_ap_ids.lock().unwrap().insert(
|
||||
thought.id.clone(),
|
||||
"https://mastodon.social/users/bob/statuses/123".into(),
|
||||
);
|
||||
@@ -460,7 +474,7 @@ mod tests {
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
svc_with_ap(&store, ap_repo, spy.clone())
|
||||
.process(&DomainEvent::BoostAdded {
|
||||
boost_id: BoostId::new(),
|
||||
user_id: alice.id.clone(),
|
||||
@@ -604,14 +618,15 @@ mod tests {
|
||||
let alice = alice();
|
||||
let mut thought = local_thought(alice.id.clone());
|
||||
thought.local = false;
|
||||
store.thought_ap_ids.lock().unwrap().insert(
|
||||
let ap_repo = TestApRepo::new(store.clone());
|
||||
ap_repo.inner.thought_ap_ids.lock().unwrap().insert(
|
||||
thought.id.clone(),
|
||||
"https://mastodon.social/users/bob/statuses/456".into(),
|
||||
);
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
svc_with_ap(&store, ap_repo, spy.clone())
|
||||
.process(&DomainEvent::BoostRemoved {
|
||||
user_id: alice.id.clone(),
|
||||
thought_id: thought.id.clone(),
|
||||
@@ -673,28 +688,28 @@ mod tests {
|
||||
PasswordHash("h".into()),
|
||||
);
|
||||
author.local = false;
|
||||
store.actor_ap_urls.lock().unwrap().insert(
|
||||
author.id.clone(),
|
||||
domain::ports::ActorApUrls {
|
||||
ap_id: "https://mastodon.social/users/author".into(),
|
||||
inbox_url: "https://mastodon.social/users/author/inbox".into(),
|
||||
},
|
||||
);
|
||||
|
||||
let thought = local_thought(author.id.clone());
|
||||
store.thought_ap_ids.lock().unwrap().insert(
|
||||
thought.id.clone(),
|
||||
"https://mastodon.social/posts/123".into(),
|
||||
);
|
||||
|
||||
let liker = alice();
|
||||
|
||||
store.users.lock().unwrap().push(author.clone());
|
||||
store.users.lock().unwrap().push(liker.clone());
|
||||
store.thoughts.lock().unwrap().push(thought.clone());
|
||||
|
||||
let ap_repo = TestApRepo::new(store.clone());
|
||||
ap_repo.actor_ap_urls.lock().unwrap().insert(
|
||||
author.id.clone(),
|
||||
ActorApUrls {
|
||||
ap_id: "https://mastodon.social/users/author".into(),
|
||||
inbox_url: "https://mastodon.social/users/author/inbox".into(),
|
||||
},
|
||||
);
|
||||
ap_repo.inner.thought_ap_ids.lock().unwrap().insert(
|
||||
thought.id.clone(),
|
||||
"https://mastodon.social/posts/123".into(),
|
||||
);
|
||||
|
||||
let spy = Arc::new(SpyPort::default());
|
||||
svc(&store, spy.clone())
|
||||
svc_with_ap(&store, ap_repo, spy.clone())
|
||||
.process(&DomainEvent::LikeAdded {
|
||||
like_id: LikeId::new(),
|
||||
user_id: liker.id,
|
||||
|
||||
150
crates/application/src/testing.rs
Normal file
150
crates/application/src/testing.rs
Normal file
@@ -0,0 +1,150 @@
|
||||
/// Test helpers for application-layer tests that need activitypub_base traits.
|
||||
use activitypub_base::{ActivityPubRepository, ActorApUrls, OutboxEntry};
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::user::User,
|
||||
testing::TestStore,
|
||||
value_objects::{Email, PasswordHash, ThoughtId, UserId, Username},
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Extends `TestStore` with AP-specific lookup maps.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct TestApRepo {
|
||||
pub inner: TestStore,
|
||||
/// UserId → ActorApUrls (for get_actor_ap_urls)
|
||||
pub actor_ap_urls: Arc<Mutex<HashMap<UserId, ActorApUrls>>>,
|
||||
}
|
||||
|
||||
impl TestApRepo {
|
||||
pub fn new(inner: TestStore) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
actor_ap_urls: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ActivityPubRepository for TestApRepo {
|
||||
async fn outbox_entries_for_actor(
|
||||
&self,
|
||||
_uid: &UserId,
|
||||
) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn outbox_page_for_actor(
|
||||
&self,
|
||||
_uid: &UserId,
|
||||
_before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
_limit: usize,
|
||||
) -> Result<Vec<OutboxEntry>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn find_remote_actor_id(
|
||||
&self,
|
||||
actor_ap_url: &str,
|
||||
) -> Result<Option<UserId>, DomainError> {
|
||||
Ok(self
|
||||
.inner
|
||||
.actor_ap_ids
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(actor_ap_url)
|
||||
.cloned())
|
||||
}
|
||||
async fn intern_remote_actor(&self, actor_ap_url: &str) -> Result<UserId, DomainError> {
|
||||
if let Some(uid) = self.find_remote_actor_id(actor_ap_url).await? {
|
||||
return Ok(uid);
|
||||
}
|
||||
let uid = UserId::new();
|
||||
let handle = url::Url::parse(actor_ap_url)
|
||||
.map(|u| u.path().trim_start_matches('/').replace('/', "_"))
|
||||
.unwrap_or_else(|_| format!("remote_{}", &uid.to_string()[..8]));
|
||||
let user = User {
|
||||
id: uid.clone(),
|
||||
username: Username::from_trusted(handle),
|
||||
email: Email::from_trusted(format!("{}@remote", uid)),
|
||||
password_hash: PasswordHash("".into()),
|
||||
display_name: None,
|
||||
bio: None,
|
||||
avatar_url: None,
|
||||
header_url: None,
|
||||
custom_css: None,
|
||||
local: false,
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
};
|
||||
self.inner.users.lock().unwrap().push(user);
|
||||
self.inner
|
||||
.actor_ap_ids
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(actor_ap_url.to_string(), uid.clone());
|
||||
Ok(uid)
|
||||
}
|
||||
async fn update_remote_actor_display(
|
||||
&self,
|
||||
_user_id: &UserId,
|
||||
_display_name: Option<&str>,
|
||||
_avatar_url: Option<&str>,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn accept_note(
|
||||
&self,
|
||||
_ap_id: &str,
|
||||
_author_id: &UserId,
|
||||
_content: &str,
|
||||
_published: chrono::DateTime<chrono::Utc>,
|
||||
_sensitive: bool,
|
||||
_content_warning: Option<String>,
|
||||
_visibility: &str,
|
||||
_in_reply_to: Option<&str>,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn apply_note_update(
|
||||
&self,
|
||||
_ap_id: &str,
|
||||
_new_content: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn retract_note(&self, _ap_id: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn retract_actor_notes(&self, _actor_ap_url: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn count_local_notes(&self) -> Result<u64, DomainError> {
|
||||
Ok(self
|
||||
.inner
|
||||
.thoughts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|t| t.local)
|
||||
.count() as u64)
|
||||
}
|
||||
async fn get_thought_ap_id(
|
||||
&self,
|
||||
thought_id: &ThoughtId,
|
||||
) -> Result<Option<String>, DomainError> {
|
||||
Ok(self
|
||||
.inner
|
||||
.thought_ap_ids
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(thought_id)
|
||||
.cloned())
|
||||
}
|
||||
async fn get_actor_ap_urls(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Option<ActorApUrls>, DomainError> {
|
||||
Ok(self.actor_ap_urls.lock().unwrap().get(user_id).cloned())
|
||||
}
|
||||
}
|
||||
@@ -38,13 +38,11 @@ pub async fn register(
|
||||
.save(&user)
|
||||
.await
|
||||
.map_err(|e| match e {
|
||||
DomainError::Conflict(ref c) if c.contains("username") => {
|
||||
DomainError::Conflict("username taken".into())
|
||||
}
|
||||
DomainError::Conflict(ref c) if c.contains("email") => {
|
||||
DomainError::Conflict("email taken".into())
|
||||
}
|
||||
DomainError::Conflict(_) => DomainError::Conflict("already exists".into()),
|
||||
DomainError::Conflict(c) => match c.as_str() {
|
||||
"users_username_key" => DomainError::Conflict("username taken".into()),
|
||||
"users_email_key" => DomainError::Conflict("email taken".into()),
|
||||
_ => DomainError::Conflict("already exists".into()),
|
||||
},
|
||||
other => other,
|
||||
})?;
|
||||
events
|
||||
@@ -111,6 +109,7 @@ mod tests {
|
||||
/// Simulates a concurrent registration that slips past the pre-checks and
|
||||
/// hits the DB unique constraint — exactly what happens in the TOCTOU window.
|
||||
struct ConflictOnSaveStore(TestStore);
|
||||
struct EmailConflictOnSaveStore(TestStore);
|
||||
|
||||
#[async_trait]
|
||||
impl UserReader for ConflictOnSaveStore {
|
||||
@@ -154,6 +153,48 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserReader for EmailConflictOnSaveStore {
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
self.0.find_by_id(id).await
|
||||
}
|
||||
async fn find_by_username(
|
||||
&self,
|
||||
username: &Username,
|
||||
) -> Result<Option<User>, DomainError> {
|
||||
self.0.find_by_username(username).await
|
||||
}
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||
self.0.find_by_email(email).await
|
||||
}
|
||||
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
|
||||
self.0.list_with_stats().await
|
||||
}
|
||||
async fn count(&self) -> Result<i64, DomainError> {
|
||||
self.0.count().await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserWriter for EmailConflictOnSaveStore {
|
||||
async fn save(&self, _user: &User) -> Result<(), DomainError> {
|
||||
Err(DomainError::Conflict("users_email_key".into()))
|
||||
}
|
||||
async fn update_profile(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
display_name: Option<String>,
|
||||
bio: Option<String>,
|
||||
avatar_url: Option<String>,
|
||||
header_url: Option<String>,
|
||||
custom_css: Option<String>,
|
||||
) -> Result<(), DomainError> {
|
||||
self.0
|
||||
.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeHasher;
|
||||
#[async_trait]
|
||||
impl PasswordHasher for FakeHasher {
|
||||
@@ -315,4 +356,17 @@ mod tests {
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_maps_db_conflict_on_email_to_conflict() {
|
||||
let store = EmailConflictOnSaveStore(TestStore::default());
|
||||
let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, DomainError::Conflict(ref m) if m == "email taken"),
|
||||
"expected 'email taken', got: {:?}",
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use activitypub_base::ActivityPubRepository;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
@@ -6,9 +7,9 @@ use domain::{
|
||||
remote_actor::RemoteActor,
|
||||
},
|
||||
ports::{
|
||||
ActivityPubRepository, EventPublisher, FederationActionPort, FederationFollowPort,
|
||||
FederationFollowRequestPort, FederationSchedulerPort, FeedRepository, FollowRepository,
|
||||
RemoteActorConnectionRepository, UserReader,
|
||||
EventPublisher, FederationActionPort, FederationFollowPort,
|
||||
FederationFollowRequestPort, FederationSchedulerPort, FeedQuery, FeedRepository,
|
||||
FollowRepository, RemoteActorConnectionRepository, UserReader,
|
||||
},
|
||||
value_objects::UserId,
|
||||
};
|
||||
@@ -85,7 +86,7 @@ pub async fn get_remote_actor_posts(
|
||||
Some(id) => id,
|
||||
None => ap_repo.intern_remote_actor(&actor.url).await?,
|
||||
};
|
||||
let result = feed.user_feed(&author_id, &page, viewer_id).await?;
|
||||
let result = feed.query(&FeedQuery::user(author_id, page.clone(), viewer_id.cloned())).await?;
|
||||
if let Some(outbox_url) = actor.outbox_url {
|
||||
let _ = scheduler
|
||||
.schedule_actor_posts_fetch(&actor.url, &outbox_url)
|
||||
|
||||
@@ -4,7 +4,7 @@ use domain::{
|
||||
feed::{FeedEntry, PageParams, Paginated, UserSummary},
|
||||
user::User,
|
||||
},
|
||||
ports::{FeedRepository, FollowRepository, TagRepository, UserReader},
|
||||
ports::{FeedQuery, FeedRepository, FollowRepository, TagRepository, UserReader},
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ pub async fn get_home_feed(
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
let mut following_ids = follows.get_accepted_following_ids(user_id).await?;
|
||||
following_ids.push(user_id.clone()); // include own thoughts in home feed
|
||||
feed.home_feed(&following_ids, &page, Some(user_id)).await
|
||||
feed.query(&FeedQuery::home(user_id.clone(), following_ids, page)).await
|
||||
}
|
||||
|
||||
pub async fn get_public_feed(
|
||||
@@ -24,7 +24,7 @@ pub async fn get_public_feed(
|
||||
viewer_id: Option<&UserId>,
|
||||
page: PageParams,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
feed.public_feed(&page, viewer_id).await
|
||||
feed.query(&FeedQuery::public(page, viewer_id.cloned())).await
|
||||
}
|
||||
|
||||
pub async fn get_user_feed(
|
||||
@@ -33,7 +33,7 @@ pub async fn get_user_feed(
|
||||
page: PageParams,
|
||||
viewer_id: Option<&UserId>,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
feed.user_feed(user_id, &page, viewer_id).await
|
||||
feed.query(&FeedQuery::user(user_id.clone(), page, viewer_id.cloned())).await
|
||||
}
|
||||
|
||||
pub async fn get_followers(
|
||||
@@ -58,7 +58,7 @@ pub async fn get_by_tag(
|
||||
page: PageParams,
|
||||
viewer_id: Option<&UserId>,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
feed.tag_feed(tag_name, &page, viewer_id).await
|
||||
feed.query(&FeedQuery::tag(tag_name, page, viewer_id.cloned())).await
|
||||
}
|
||||
|
||||
pub async fn search(
|
||||
@@ -67,7 +67,7 @@ pub async fn search(
|
||||
page: PageParams,
|
||||
viewer_id: Option<&UserId>,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
feed.search(query, &page, viewer_id).await
|
||||
feed.query(&FeedQuery::search(query, page, viewer_id.cloned())).await
|
||||
}
|
||||
|
||||
pub async fn list_users(users: &dyn UserReader) -> Result<Vec<UserSummary>, DomainError> {
|
||||
|
||||
@@ -6,30 +6,6 @@ use domain::{
|
||||
value_objects::{Content, ThoughtId, UserId},
|
||||
};
|
||||
|
||||
fn extract_hashtags(content: &str) -> Vec<String> {
|
||||
let mut tags = Vec::new();
|
||||
let mut chars = content.char_indices().peekable();
|
||||
while let Some((_, c)) = chars.next() {
|
||||
if c == '#'
|
||||
&& chars
|
||||
.peek()
|
||||
.map(|(_, nc)| nc.is_alphanumeric())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let tag: String = chars
|
||||
.by_ref()
|
||||
.take_while(|(_, nc)| nc.is_alphanumeric() || *nc == '_')
|
||||
.map(|(_, nc)| nc)
|
||||
.collect();
|
||||
if !tag.is_empty() {
|
||||
tags.push(tag.to_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
tags.dedup();
|
||||
tags
|
||||
}
|
||||
|
||||
fn require_owner(thought: &Thought, user_id: &UserId) -> Result<(), DomainError> {
|
||||
if thought.user_id != *user_id {
|
||||
return Err(DomainError::NotFound);
|
||||
@@ -76,8 +52,8 @@ pub async fn create_thought(
|
||||
thoughts.save(&thought).await?;
|
||||
|
||||
// Extract and attach hashtags from content.
|
||||
for tag_name in extract_hashtags(content.as_str()) {
|
||||
if let Ok(tag) = tags.find_or_create(&tag_name).await {
|
||||
for h in domain::hashtag::extract(content.as_str()) {
|
||||
if let Ok(tag) = tags.find_or_create(&h.normalized).await {
|
||||
let _ = tags.attach_to_thought(&thought.id, tag.id).await;
|
||||
}
|
||||
}
|
||||
@@ -195,6 +171,33 @@ mod tests {
|
||||
assert!(matches!(staged[0], DomainEvent::ThoughtCreated { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_thought_stages_outbox_event() {
|
||||
let store = TestStore::default();
|
||||
let outbox = TestOutbox::default();
|
||||
let u = user();
|
||||
store.users.lock().unwrap().push(u.clone());
|
||||
let out = create_thought(
|
||||
&store,
|
||||
&store,
|
||||
&store,
|
||||
&NoOpEventPublisher,
|
||||
&NoOpOutboxWriter,
|
||||
input(u.id.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let tid = out.thought.id.clone();
|
||||
|
||||
delete_thought(&store, &NoOpEventPublisher, &outbox, &tid, &u.id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let staged = outbox.staged();
|
||||
assert_eq!(staged.len(), 1);
|
||||
assert!(matches!(&staged[0], DomainEvent::ThoughtDeleted { thought_id, .. } if *thought_id == tid));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_own_thought_succeeds() {
|
||||
let store = TestStore::default();
|
||||
|
||||
Reference in New Issue
Block a user