feat: atomic goal, watchlist, profile writes
This commit is contained in:
@@ -6,8 +6,9 @@ mod repositories;
|
|||||||
mod shared;
|
mod shared;
|
||||||
|
|
||||||
pub use event_queue::{DbEventQueueConfig, SqliteEventQueue};
|
pub use event_queue::{DbEventQueueConfig, SqliteEventQueue};
|
||||||
|
use repositories::SqliteUserSettingsRepository;
|
||||||
pub use repositories::{
|
pub use repositories::{
|
||||||
SqliteApContentQuery, SqliteDiaryRepository, SqliteImageRefAdapter,
|
SqliteApContentQuery, SqliteDiaryRepository, SqliteGoalRepository, SqliteImageRefAdapter,
|
||||||
SqliteImportProfileRepository, SqliteImportSessionRepository, SqliteInsightsQuery,
|
SqliteImportProfileRepository, SqliteImportSessionRepository, SqliteInsightsQuery,
|
||||||
SqliteMovieDeduplicator, SqliteMovieProfileRepository, SqliteMovieRepository,
|
SqliteMovieDeduplicator, SqliteMovieProfileRepository, SqliteMovieRepository,
|
||||||
SqlitePersonAdapter, SqliteProcessedEventLedger, SqliteProfileFieldsRepository,
|
SqlitePersonAdapter, SqliteProcessedEventLedger, SqliteProfileFieldsRepository,
|
||||||
@@ -17,7 +18,6 @@ pub use repositories::{
|
|||||||
SqliteWrapUpRepository, SqliteWrapUpStatsQuery, create_federated_profile_query,
|
SqliteWrapUpRepository, SqliteWrapUpStatsQuery, create_federated_profile_query,
|
||||||
create_image_ref, create_person_adapter, create_search_adapter,
|
create_image_ref, create_person_adapter, create_search_adapter,
|
||||||
};
|
};
|
||||||
use repositories::{SqliteGoalRepository, SqliteUserSettingsRepository};
|
|
||||||
|
|
||||||
/// Row helpers shared with the `sqlite-federation` crate (which cannot live
|
/// Row helpers shared with the `sqlite-federation` crate (which cannot live
|
||||||
/// here because it depends on `activitypub`/`k-ap`).
|
/// here because it depends on `activitypub`/`k-ap`).
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
models::{Goal, GoalType},
|
models::{Goal, GoalType},
|
||||||
ports::{GoalCommand, GoalQuery},
|
ports::{GoalCommand, GoalQuery},
|
||||||
value_objects::{GoalId, UserId},
|
value_objects::{GoalId, UserId},
|
||||||
};
|
};
|
||||||
use sqlx::{Row, SqlitePool};
|
use sqlx::{Row, SqlitePool};
|
||||||
|
|
||||||
|
use crate::shared::enqueue_events;
|
||||||
|
|
||||||
pub struct SqliteGoalRepository {
|
pub struct SqliteGoalRepository {
|
||||||
pool: SqlitePool,
|
pool: SqlitePool,
|
||||||
}
|
}
|
||||||
@@ -19,7 +22,7 @@ impl SqliteGoalRepository {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl GoalCommand for SqliteGoalRepository {
|
impl GoalCommand for SqliteGoalRepository {
|
||||||
async fn save(&self, goal: &Goal) -> Result<(), DomainError> {
|
async fn save(&self, goal: &Goal, events: &[DomainEvent]) -> Result<(), DomainError> {
|
||||||
let id = goal.id().value().to_string();
|
let id = goal.id().value().to_string();
|
||||||
let user_id = goal.user_id().value().to_string();
|
let user_id = goal.user_id().value().to_string();
|
||||||
let year = goal.year() as i64;
|
let year = goal.year() as i64;
|
||||||
@@ -27,6 +30,11 @@ impl GoalCommand for SqliteGoalRepository {
|
|||||||
let goal_type = goal.goal_type().as_str();
|
let goal_type = goal.goal_type().as_str();
|
||||||
let created_at = goal.created_at().format("%Y-%m-%d %H:%M:%S").to_string();
|
let created_at = goal.created_at().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||||
|
|
||||||
|
let mut tx = self
|
||||||
|
.pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO goals (id, user_id, year, target_count, goal_type, created_at) \
|
"INSERT INTO goals (id, user_id, year, target_count, goal_type, created_at) \
|
||||||
VALUES (?, ?, ?, ?, ?, ?)",
|
VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
@@ -37,45 +45,62 @@ impl GoalCommand for SqliteGoalRepository {
|
|||||||
.bind(target)
|
.bind(target)
|
||||||
.bind(goal_type)
|
.bind(goal_type)
|
||||||
.bind(&created_at)
|
.bind(&created_at)
|
||||||
.execute(&self.pool)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(crate::shared::map_sqlx_error)?;
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
|
enqueue_events(&mut tx, events).await?;
|
||||||
Ok(())
|
tx.commit().await.map_err(crate::shared::map_sqlx_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update(&self, goal: &Goal) -> Result<(), DomainError> {
|
async fn update(&self, goal: &Goal, events: &[DomainEvent]) -> Result<(), DomainError> {
|
||||||
let id = goal.id().value().to_string();
|
let id = goal.id().value().to_string();
|
||||||
let target = goal.target_count() as i64;
|
let target = goal.target_count() as i64;
|
||||||
|
|
||||||
|
let mut tx = self
|
||||||
|
.pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
let result = sqlx::query("UPDATE goals SET target_count = ? WHERE id = ?")
|
let result = sqlx::query("UPDATE goals SET target_count = ? WHERE id = ?")
|
||||||
.bind(target)
|
.bind(target)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.execute(&self.pool)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(crate::shared::map_sqlx_error)?;
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
return Err(DomainError::NotFound("Goal not found".into()));
|
return Err(DomainError::NotFound("Goal not found".into()));
|
||||||
}
|
}
|
||||||
Ok(())
|
enqueue_events(&mut tx, events).await?;
|
||||||
|
tx.commit().await.map_err(crate::shared::map_sqlx_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, id: &GoalId, user_id: &UserId) -> Result<(), DomainError> {
|
async fn delete(
|
||||||
|
&self,
|
||||||
|
id: &GoalId,
|
||||||
|
user_id: &UserId,
|
||||||
|
events: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
let gid = id.value().to_string();
|
let gid = id.value().to_string();
|
||||||
let uid = user_id.value().to_string();
|
let uid = user_id.value().to_string();
|
||||||
|
|
||||||
|
let mut tx = self
|
||||||
|
.pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
let result = sqlx::query("DELETE FROM goals WHERE id = ? AND user_id = ?")
|
let result = sqlx::query("DELETE FROM goals WHERE id = ? AND user_id = ?")
|
||||||
.bind(&gid)
|
.bind(&gid)
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.execute(&self.pool)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(crate::shared::map_sqlx_error)?;
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
return Err(DomainError::NotFound("Goal not found".into()));
|
return Err(DomainError::NotFound("Goal not found".into()));
|
||||||
}
|
}
|
||||||
Ok(())
|
enqueue_events(&mut tx, events).await?;
|
||||||
|
tx.commit().await.map_err(crate::shared::map_sqlx_error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ use async_trait::async_trait;
|
|||||||
use sqlx::{Row, SqlitePool};
|
use sqlx::{Row, SqlitePool};
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError, models::ProfileField, ports::UserProfileFieldsRepository,
|
errors::DomainError, events::DomainEvent, models::ProfileField,
|
||||||
value_objects::UserId,
|
ports::UserProfileFieldsRepository, value_objects::UserId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::shared::enqueue_events;
|
||||||
|
|
||||||
pub struct SqliteProfileFieldsRepository {
|
pub struct SqliteProfileFieldsRepository {
|
||||||
pool: SqlitePool,
|
pool: SqlitePool,
|
||||||
}
|
}
|
||||||
@@ -41,12 +43,18 @@ impl UserProfileFieldsRepository for SqliteProfileFieldsRepository {
|
|||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
fields: Vec<ProfileField>,
|
fields: Vec<ProfileField>,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let id_str = user_id.value().to_string();
|
let id_str = user_id.value().to_string();
|
||||||
|
|
||||||
|
let mut tx = self
|
||||||
|
.pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
sqlx::query("DELETE FROM user_profile_fields WHERE user_id = ?")
|
sqlx::query("DELETE FROM user_profile_fields WHERE user_id = ?")
|
||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.execute(&self.pool)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(crate::shared::map_sqlx_error)?;
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
|
|
||||||
@@ -61,11 +69,12 @@ impl UserProfileFieldsRepository for SqliteProfileFieldsRepository {
|
|||||||
.bind(&field.name)
|
.bind(&field.name)
|
||||||
.bind(&field.value)
|
.bind(&field.value)
|
||||||
.bind(position)
|
.bind(position)
|
||||||
.execute(&self.pool)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(crate::shared::map_sqlx_error)?;
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
enqueue_events(&mut tx, events).await?;
|
||||||
|
tx.commit().await.map_err(crate::shared::map_sqlx_error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,8 +161,14 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
profile: &domain::models::UserProfile,
|
profile: &domain::models::UserProfile,
|
||||||
|
events: &[domain::events::DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let id_str = user_id.value().to_string();
|
let id_str = user_id.value().to_string();
|
||||||
|
let mut tx = self
|
||||||
|
.pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"UPDATE users SET display_name = ?, bio = ?, avatar_path = ?, banner_path = ?, also_known_as = ? WHERE id = ?",
|
"UPDATE users SET display_name = ?, bio = ?, avatar_path = ?, banner_path = ?, also_known_as = ? WHERE id = ?",
|
||||||
)
|
)
|
||||||
@@ -172,10 +178,11 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
.bind(&profile.banner_path)
|
.bind(&profile.banner_path)
|
||||||
.bind(&profile.also_known_as)
|
.bind(&profile.also_known_as)
|
||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.execute(&self.pool)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
Ok(())
|
crate::shared::enqueue_events(&mut tx, events).await?;
|
||||||
|
tx.commit().await.map_err(crate::shared::map_sqlx_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
models::{
|
models::{
|
||||||
WatchlistEntry, WatchlistWithMovie,
|
WatchlistEntry, WatchlistWithMovie,
|
||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
@@ -13,7 +14,7 @@ use sqlx::{Row, SqlitePool};
|
|||||||
use rows::WatchlistRow;
|
use rows::WatchlistRow;
|
||||||
|
|
||||||
mod rows;
|
mod rows;
|
||||||
use crate::shared::datetime_to_str;
|
use crate::shared::{datetime_to_str, enqueue_events};
|
||||||
|
|
||||||
pub struct SqliteWatchlistRepository {
|
pub struct SqliteWatchlistRepository {
|
||||||
pool: SqlitePool,
|
pool: SqlitePool,
|
||||||
@@ -27,12 +28,17 @@ impl SqliteWatchlistRepository {
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl WatchlistRepository for SqliteWatchlistRepository {
|
impl WatchlistRepository for SqliteWatchlistRepository {
|
||||||
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> {
|
async fn add(&self, entry: &WatchlistEntry, events: &[DomainEvent]) -> Result<(), DomainError> {
|
||||||
let id = entry.id.value().to_string();
|
let id = entry.id.value().to_string();
|
||||||
let user_id = entry.user_id.value().to_string();
|
let user_id = entry.user_id.value().to_string();
|
||||||
let movie_id = entry.movie_id.value().to_string();
|
let movie_id = entry.movie_id.value().to_string();
|
||||||
let added_at = datetime_to_str(&entry.added_at);
|
let added_at = datetime_to_str(&entry.added_at);
|
||||||
|
|
||||||
|
let mut tx = self
|
||||||
|
.pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT OR IGNORE INTO watchlist_entries (id, user_id, movie_id, added_at) \
|
"INSERT OR IGNORE INTO watchlist_entries (id, user_id, movie_id, added_at) \
|
||||||
VALUES (?, ?, ?, ?)",
|
VALUES (?, ?, ?, ?)",
|
||||||
@@ -41,22 +47,32 @@ impl WatchlistRepository for SqliteWatchlistRepository {
|
|||||||
.bind(&user_id)
|
.bind(&user_id)
|
||||||
.bind(&movie_id)
|
.bind(&movie_id)
|
||||||
.bind(&added_at)
|
.bind(&added_at)
|
||||||
.execute(&self.pool)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(crate::shared::map_sqlx_error)?;
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
|
enqueue_events(&mut tx, events).await?;
|
||||||
Ok(())
|
tx.commit().await.map_err(crate::shared::map_sqlx_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> {
|
async fn remove(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
movie_id: &MovieId,
|
||||||
|
events: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
let uid = user_id.value().to_string();
|
let uid = user_id.value().to_string();
|
||||||
let mid = movie_id.value().to_string();
|
let mid = movie_id.value().to_string();
|
||||||
|
|
||||||
|
let mut tx = self
|
||||||
|
.pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
let result =
|
let result =
|
||||||
sqlx::query("DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?")
|
sqlx::query("DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?")
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.bind(&mid)
|
.bind(&mid)
|
||||||
.execute(&self.pool)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(crate::shared::map_sqlx_error)?;
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
|
|
||||||
@@ -66,24 +82,36 @@ impl WatchlistRepository for SqliteWatchlistRepository {
|
|||||||
mid
|
mid
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
Ok(())
|
enqueue_events(&mut tx, events).await?;
|
||||||
|
tx.commit().await.map_err(crate::shared::map_sqlx_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn remove_if_present(
|
async fn remove_if_present(
|
||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
movie_id: &MovieId,
|
movie_id: &MovieId,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<bool, DomainError> {
|
) -> Result<bool, DomainError> {
|
||||||
let uid = user_id.value().to_string();
|
let uid = user_id.value().to_string();
|
||||||
let mid = movie_id.value().to_string();
|
let mid = movie_id.value().to_string();
|
||||||
|
let mut tx = self
|
||||||
|
.pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
let result =
|
let result =
|
||||||
sqlx::query("DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?")
|
sqlx::query("DELETE FROM watchlist_entries WHERE user_id = ? AND movie_id = ?")
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.bind(&mid)
|
.bind(&mid)
|
||||||
.execute(&self.pool)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(crate::shared::map_sqlx_error)?;
|
.map_err(crate::shared::map_sqlx_error)?;
|
||||||
Ok(result.rows_affected() > 0)
|
let removed = result.rows_affected() > 0;
|
||||||
|
if removed {
|
||||||
|
enqueue_events(&mut tx, events).await?;
|
||||||
|
}
|
||||||
|
tx.commit().await.map_err(crate::shared::map_sqlx_error)?;
|
||||||
|
Ok(removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_for_user(
|
async fn get_for_user(
|
||||||
|
|||||||
@@ -128,12 +128,15 @@ async fn seed_user_with_dependents(
|
|||||||
|
|
||||||
parts
|
parts
|
||||||
.watchlist
|
.watchlist
|
||||||
.add(&WatchlistEntry::new(user_id.clone(), movie.id().clone()))
|
.add(
|
||||||
|
&WatchlistEntry::new(user_id.clone(), movie.id().clone()),
|
||||||
|
&[],
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
parts
|
parts
|
||||||
.goal_command
|
.goal_command
|
||||||
.save(&test_goal(&user_id, 2024, 50))
|
.save(&test_goal(&user_id, 2024, 50), &[])
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
parts
|
parts
|
||||||
@@ -148,6 +151,7 @@ async fn seed_user_with_dependents(
|
|||||||
name: "Site".into(),
|
name: "Site".into(),
|
||||||
value: "https://example.com".into(),
|
value: "https://example.com".into(),
|
||||||
}],
|
}],
|
||||||
|
&[],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -256,7 +260,10 @@ async fn deleting_a_movie_cascades_to_watchlist_entries_and_every_enrichment_tab
|
|||||||
parts.movie_command.upsert_movie(&movie).await.unwrap();
|
parts.movie_command.upsert_movie(&movie).await.unwrap();
|
||||||
parts
|
parts
|
||||||
.watchlist
|
.watchlist
|
||||||
.add(&WatchlistEntry::new(user.id().clone(), movie.id().clone()))
|
.add(
|
||||||
|
&WatchlistEntry::new(user.id().clone(), movie.id().clone()),
|
||||||
|
&[],
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
parts
|
parts
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ mod image_ref_adapter_test;
|
|||||||
mod movie_dedup_test;
|
mod movie_dedup_test;
|
||||||
#[path = "repositories/movie_profile_repository_test.rs"]
|
#[path = "repositories/movie_profile_repository_test.rs"]
|
||||||
mod movie_profile_repository_test;
|
mod movie_profile_repository_test;
|
||||||
|
#[path = "repositories/outbox_support.rs"]
|
||||||
|
mod outbox_support;
|
||||||
|
#[path = "repositories/outbox_writes_test.rs"]
|
||||||
|
mod outbox_writes_test;
|
||||||
#[path = "repositories/person_adapter_test.rs"]
|
#[path = "repositories/person_adapter_test.rs"]
|
||||||
mod person_adapter_test;
|
mod person_adapter_test;
|
||||||
#[path = "repositories/review_repository_test.rs"]
|
#[path = "repositories/review_repository_test.rs"]
|
||||||
|
|||||||
38
crates/adapters/sqlite/tests/repositories/outbox_support.rs
Normal file
38
crates/adapters/sqlite/tests/repositories/outbox_support.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
//! Shared helpers for the transactional-outbox repository tests.
|
||||||
|
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use sqlx::{
|
||||||
|
SqlitePool,
|
||||||
|
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn migrated_pool() -> SqlitePool {
|
||||||
|
let opts = SqliteConnectOptions::from_str("sqlite::memory:").unwrap();
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect_with(opts)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlite::migrate(&pool).await.unwrap();
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn count(pool: &SqlitePool, table: &str) -> i64 {
|
||||||
|
sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}"))
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes every later `event_queue` insert abort, so a write whose own row
|
||||||
|
/// already landed inside the transaction must be rolled back with it.
|
||||||
|
pub async fn fail_event_inserts(pool: &SqlitePool) {
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TRIGGER fail_event_insert BEFORE INSERT ON event_queue \
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'enqueue failed'); END",
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
421
crates/adapters/sqlite/tests/repositories/outbox_writes_test.rs
Normal file
421
crates/adapters/sqlite/tests/repositories/outbox_writes_test.rs
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
//! Goal, watchlist and profile writes commit with their events or not at all.
|
||||||
|
|
||||||
|
use domain::{
|
||||||
|
events::DomainEvent,
|
||||||
|
models::{ProfileField, UserProfile, WatchlistEntry},
|
||||||
|
ports::{
|
||||||
|
GoalCommand, MovieCommand, UserProfileFieldsRepository, UserRepository, WatchlistRepository,
|
||||||
|
},
|
||||||
|
testing::{test_goal, test_movie, test_user},
|
||||||
|
value_objects::UserId,
|
||||||
|
};
|
||||||
|
use sqlite::{
|
||||||
|
SqliteGoalRepository, SqliteMovieRepository, SqliteProfileFieldsRepository,
|
||||||
|
SqliteUserRepository, SqliteWatchlistRepository,
|
||||||
|
};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use super::outbox_support::{count, fail_event_inserts, migrated_pool};
|
||||||
|
|
||||||
|
async fn pool_with_user() -> (SqlitePool, domain::models::User) {
|
||||||
|
let pool = migrated_pool().await;
|
||||||
|
let user = test_user("alice");
|
||||||
|
SqliteUserRepository::new(pool.clone())
|
||||||
|
.save(&user)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
(pool, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn goal_created(goal: &domain::models::Goal) -> DomainEvent {
|
||||||
|
DomainEvent::GoalCreated {
|
||||||
|
goal_id: goal.id().clone(),
|
||||||
|
user_id: goal.user_id().clone(),
|
||||||
|
year: goal.year(),
|
||||||
|
target_count: goal.target_count(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn goal_updated(goal: &domain::models::Goal) -> DomainEvent {
|
||||||
|
DomainEvent::GoalUpdated {
|
||||||
|
goal_id: goal.id().clone(),
|
||||||
|
user_id: goal.user_id().clone(),
|
||||||
|
year: goal.year(),
|
||||||
|
target_count: goal.target_count(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn goal_deleted(goal: &domain::models::Goal) -> DomainEvent {
|
||||||
|
DomainEvent::GoalDeleted {
|
||||||
|
goal_id: goal.id().clone(),
|
||||||
|
user_id: goal.user_id().clone(),
|
||||||
|
year: goal.year(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn saving_a_goal_writes_the_goal_and_its_event_in_one_commit() {
|
||||||
|
let (pool, user) = pool_with_user().await;
|
||||||
|
let goal = test_goal(user.id(), 2025, 50);
|
||||||
|
|
||||||
|
SqliteGoalRepository::new(pool.clone())
|
||||||
|
.save(&goal, &[goal_created(&goal)])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(count(&pool, "goals").await, 1);
|
||||||
|
assert_eq!(count(&pool, "event_queue").await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failing_enqueue_rolls_back_the_goal_save() {
|
||||||
|
let (pool, user) = pool_with_user().await;
|
||||||
|
let goal = test_goal(user.id(), 2025, 50);
|
||||||
|
fail_event_inserts(&pool).await;
|
||||||
|
|
||||||
|
let result = SqliteGoalRepository::new(pool.clone())
|
||||||
|
.save(&goal, &[goal_created(&goal)])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(count(&pool, "goals").await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn updating_a_goal_writes_the_change_and_its_event_in_one_commit() {
|
||||||
|
let (pool, user) = pool_with_user().await;
|
||||||
|
let repo = SqliteGoalRepository::new(pool.clone());
|
||||||
|
let mut goal = test_goal(user.id(), 2025, 50);
|
||||||
|
repo.save(&goal, &[]).await.unwrap();
|
||||||
|
goal.update_target(80).unwrap();
|
||||||
|
|
||||||
|
repo.update(&goal, &[goal_updated(&goal)]).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(count(&pool, "event_queue").await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failing_enqueue_rolls_back_the_goal_update() {
|
||||||
|
let (pool, user) = pool_with_user().await;
|
||||||
|
let repo = SqliteGoalRepository::new(pool.clone());
|
||||||
|
let mut goal = test_goal(user.id(), 2025, 50);
|
||||||
|
repo.save(&goal, &[]).await.unwrap();
|
||||||
|
goal.update_target(80).unwrap();
|
||||||
|
fail_event_inserts(&pool).await;
|
||||||
|
|
||||||
|
let result = repo.update(&goal, &[goal_updated(&goal)]).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
let target: i64 = sqlx::query_scalar("SELECT target_count FROM goals")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(target, 50, "update rolled back");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn updating_a_missing_goal_enqueues_no_event() {
|
||||||
|
let (pool, user) = pool_with_user().await;
|
||||||
|
let goal = test_goal(user.id(), 2025, 50);
|
||||||
|
|
||||||
|
let result = SqliteGoalRepository::new(pool.clone())
|
||||||
|
.update(&goal, &[goal_updated(&goal)])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(count(&pool, "event_queue").await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn deleting_a_goal_writes_the_removal_and_its_event_in_one_commit() {
|
||||||
|
let (pool, user) = pool_with_user().await;
|
||||||
|
let repo = SqliteGoalRepository::new(pool.clone());
|
||||||
|
let goal = test_goal(user.id(), 2025, 50);
|
||||||
|
repo.save(&goal, &[]).await.unwrap();
|
||||||
|
|
||||||
|
repo.delete(goal.id(), user.id(), &[goal_deleted(&goal)])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(count(&pool, "goals").await, 0);
|
||||||
|
assert_eq!(count(&pool, "event_queue").await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failing_enqueue_rolls_back_the_goal_delete() {
|
||||||
|
let (pool, user) = pool_with_user().await;
|
||||||
|
let repo = SqliteGoalRepository::new(pool.clone());
|
||||||
|
let goal = test_goal(user.id(), 2025, 50);
|
||||||
|
repo.save(&goal, &[]).await.unwrap();
|
||||||
|
fail_event_inserts(&pool).await;
|
||||||
|
|
||||||
|
let result = repo
|
||||||
|
.delete(goal.id(), user.id(), &[goal_deleted(&goal)])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(count(&pool, "goals").await, 1, "delete rolled back");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn watchlist_fixture() -> (SqlitePool, UserId, domain::models::Movie) {
|
||||||
|
let (pool, user) = pool_with_user().await;
|
||||||
|
let movie = test_movie("Heat");
|
||||||
|
SqliteMovieRepository::new(pool.clone())
|
||||||
|
.upsert_movie(&movie)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
(pool, user.id().clone(), movie)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn watchlist_added(entry: &WatchlistEntry, movie: &domain::models::Movie) -> DomainEvent {
|
||||||
|
DomainEvent::WatchlistEntryAdded {
|
||||||
|
user_id: entry.user_id.clone(),
|
||||||
|
movie_id: movie.id().clone(),
|
||||||
|
movie_title: movie.title().value().to_string(),
|
||||||
|
release_year: movie.release_year().value(),
|
||||||
|
external_metadata_id: None,
|
||||||
|
added_at: entry.added_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn watchlist_removed(user_id: &UserId, movie: &domain::models::Movie) -> DomainEvent {
|
||||||
|
DomainEvent::WatchlistEntryRemoved {
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
movie_id: movie.id().clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn adding_a_watchlist_entry_writes_the_entry_and_its_event_in_one_commit() {
|
||||||
|
let (pool, user_id, movie) = watchlist_fixture().await;
|
||||||
|
let entry = WatchlistEntry::new(user_id, movie.id().clone());
|
||||||
|
|
||||||
|
SqliteWatchlistRepository::new(pool.clone())
|
||||||
|
.add(&entry, &[watchlist_added(&entry, &movie)])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(count(&pool, "watchlist_entries").await, 1);
|
||||||
|
assert_eq!(count(&pool, "event_queue").await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failing_enqueue_rolls_back_the_watchlist_add() {
|
||||||
|
let (pool, user_id, movie) = watchlist_fixture().await;
|
||||||
|
let entry = WatchlistEntry::new(user_id, movie.id().clone());
|
||||||
|
fail_event_inserts(&pool).await;
|
||||||
|
|
||||||
|
let result = SqliteWatchlistRepository::new(pool.clone())
|
||||||
|
.add(&entry, &[watchlist_added(&entry, &movie)])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(count(&pool, "watchlist_entries").await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn removing_a_watchlist_entry_writes_the_removal_and_its_event_in_one_commit() {
|
||||||
|
let (pool, user_id, movie) = watchlist_fixture().await;
|
||||||
|
let repo = SqliteWatchlistRepository::new(pool.clone());
|
||||||
|
repo.add(
|
||||||
|
&WatchlistEntry::new(user_id.clone(), movie.id().clone()),
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
repo.remove(&user_id, movie.id(), &[watchlist_removed(&user_id, &movie)])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(count(&pool, "watchlist_entries").await, 0);
|
||||||
|
assert_eq!(count(&pool, "event_queue").await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failing_enqueue_rolls_back_the_watchlist_remove() {
|
||||||
|
let (pool, user_id, movie) = watchlist_fixture().await;
|
||||||
|
let repo = SqliteWatchlistRepository::new(pool.clone());
|
||||||
|
repo.add(
|
||||||
|
&WatchlistEntry::new(user_id.clone(), movie.id().clone()),
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
fail_event_inserts(&pool).await;
|
||||||
|
|
||||||
|
let result = repo
|
||||||
|
.remove(&user_id, movie.id(), &[watchlist_removed(&user_id, &movie)])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(
|
||||||
|
count(&pool, "watchlist_entries").await,
|
||||||
|
1,
|
||||||
|
"remove rolled back"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn removing_an_absent_watchlist_entry_enqueues_no_event() {
|
||||||
|
let (pool, user_id, movie) = watchlist_fixture().await;
|
||||||
|
|
||||||
|
let result = SqliteWatchlistRepository::new(pool.clone())
|
||||||
|
.remove(&user_id, movie.id(), &[watchlist_removed(&user_id, &movie)])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(count(&pool, "event_queue").await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn remove_if_present_enqueues_its_event_only_when_a_row_was_removed() {
|
||||||
|
let (pool, user_id, movie) = watchlist_fixture().await;
|
||||||
|
let repo = SqliteWatchlistRepository::new(pool.clone());
|
||||||
|
let event = watchlist_removed(&user_id, &movie);
|
||||||
|
|
||||||
|
let absent = repo
|
||||||
|
.remove_if_present(&user_id, movie.id(), std::slice::from_ref(&event))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!absent);
|
||||||
|
assert_eq!(count(&pool, "event_queue").await, 0);
|
||||||
|
|
||||||
|
repo.add(
|
||||||
|
&WatchlistEntry::new(user_id.clone(), movie.id().clone()),
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let present = repo
|
||||||
|
.remove_if_present(&user_id, movie.id(), std::slice::from_ref(&event))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(present);
|
||||||
|
assert_eq!(count(&pool, "watchlist_entries").await, 0);
|
||||||
|
assert_eq!(count(&pool, "event_queue").await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failing_enqueue_rolls_back_remove_if_present() {
|
||||||
|
let (pool, user_id, movie) = watchlist_fixture().await;
|
||||||
|
let repo = SqliteWatchlistRepository::new(pool.clone());
|
||||||
|
repo.add(
|
||||||
|
&WatchlistEntry::new(user_id.clone(), movie.id().clone()),
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
fail_event_inserts(&pool).await;
|
||||||
|
|
||||||
|
let result = repo
|
||||||
|
.remove_if_present(&user_id, movie.id(), &[watchlist_removed(&user_id, &movie)])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(
|
||||||
|
count(&pool, "watchlist_entries").await,
|
||||||
|
1,
|
||||||
|
"remove rolled back"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn updating_a_profile_writes_the_change_and_its_event_in_one_commit() {
|
||||||
|
let (pool, user) = pool_with_user().await;
|
||||||
|
let repo = SqliteUserRepository::new(pool.clone());
|
||||||
|
let profile = UserProfile {
|
||||||
|
bio: Some("hello".into()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
repo.update_profile(
|
||||||
|
user.id(),
|
||||||
|
&profile,
|
||||||
|
&[DomainEvent::UserUpdated {
|
||||||
|
user_id: user.id().clone(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let found = repo.find_by_id(user.id()).await.unwrap().unwrap();
|
||||||
|
assert_eq!(found.bio(), Some("hello"));
|
||||||
|
assert_eq!(count(&pool, "event_queue").await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failing_enqueue_rolls_back_the_profile_update() {
|
||||||
|
let (pool, user) = pool_with_user().await;
|
||||||
|
let repo = SqliteUserRepository::new(pool.clone());
|
||||||
|
fail_event_inserts(&pool).await;
|
||||||
|
|
||||||
|
let result = repo
|
||||||
|
.update_profile(
|
||||||
|
user.id(),
|
||||||
|
&UserProfile {
|
||||||
|
bio: Some("hello".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
&[DomainEvent::UserUpdated {
|
||||||
|
user_id: user.id().clone(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
let found = repo.find_by_id(user.id()).await.unwrap().unwrap();
|
||||||
|
assert_eq!(found.bio(), None, "profile update rolled back");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fields() -> Vec<ProfileField> {
|
||||||
|
vec![ProfileField {
|
||||||
|
name: "Site".into(),
|
||||||
|
value: "https://example.com".into(),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn setting_profile_fields_writes_them_and_the_event_in_one_commit() {
|
||||||
|
let (pool, user) = pool_with_user().await;
|
||||||
|
|
||||||
|
SqliteProfileFieldsRepository::new(pool.clone())
|
||||||
|
.set_fields(
|
||||||
|
user.id(),
|
||||||
|
fields(),
|
||||||
|
&[DomainEvent::UserUpdated {
|
||||||
|
user_id: user.id().clone(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(count(&pool, "user_profile_fields").await, 1);
|
||||||
|
assert_eq!(count(&pool, "event_queue").await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failing_enqueue_rolls_back_the_profile_fields_replacement() {
|
||||||
|
let (pool, user) = pool_with_user().await;
|
||||||
|
let repo = SqliteProfileFieldsRepository::new(pool.clone());
|
||||||
|
repo.set_fields(user.id(), fields(), &[]).await.unwrap();
|
||||||
|
fail_event_inserts(&pool).await;
|
||||||
|
|
||||||
|
let result = repo
|
||||||
|
.set_fields(
|
||||||
|
user.id(),
|
||||||
|
vec![],
|
||||||
|
&[DomainEvent::UserUpdated {
|
||||||
|
user_id: user.id().clone(),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(
|
||||||
|
repo.get_fields(user.id()).await.unwrap().len(),
|
||||||
|
1,
|
||||||
|
"delete of the old fields rolled back"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +1,12 @@
|
|||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
ports::{MovieCommand, ReviewRepository, UserRepository},
|
ports::{MovieCommand, ReviewRepository, UserRepository},
|
||||||
testing::{test_movie, test_review, test_user},
|
testing::{test_movie, test_review, test_user},
|
||||||
};
|
};
|
||||||
use sqlite::{SqliteMovieRepository, SqliteReviewRepository, SqliteUserRepository};
|
use sqlite::{SqliteMovieRepository, SqliteReviewRepository, SqliteUserRepository};
|
||||||
use sqlx::{
|
use sqlx::SqlitePool;
|
||||||
SqlitePool,
|
|
||||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
|
||||||
};
|
|
||||||
|
|
||||||
async fn migrated_pool() -> SqlitePool {
|
use super::outbox_support::{count, fail_event_inserts, migrated_pool};
|
||||||
let opts = SqliteConnectOptions::from_str("sqlite::memory:").unwrap();
|
|
||||||
let pool = SqlitePoolOptions::new()
|
|
||||||
.max_connections(1)
|
|
||||||
.connect_with(opts)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
sqlite::migrate(&pool).await.unwrap();
|
|
||||||
pool
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn count(pool: &SqlitePool, table: &str) -> i64 {
|
|
||||||
sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}"))
|
|
||||||
.fetch_one(pool)
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn logged(review: &domain::models::Review) -> DomainEvent {
|
fn logged(review: &domain::models::Review) -> DomainEvent {
|
||||||
DomainEvent::ReviewLogged {
|
DomainEvent::ReviewLogged {
|
||||||
@@ -80,3 +59,103 @@ async fn a_failed_review_save_leaves_no_event_row() {
|
|||||||
assert_eq!(count(&pool, "reviews").await, 0);
|
assert_eq!(count(&pool, "reviews").await, 0);
|
||||||
assert_eq!(count(&pool, "event_queue").await, 0);
|
assert_eq!(count(&pool, "event_queue").await, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn updated(review: &domain::models::Review) -> DomainEvent {
|
||||||
|
DomainEvent::ReviewUpdated {
|
||||||
|
review_id: review.id().clone(),
|
||||||
|
movie_id: review.movie_id().clone(),
|
||||||
|
user_id: review.user_id().clone(),
|
||||||
|
rating: review.rating().clone(),
|
||||||
|
watched_at: *review.watched_at(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seeded() -> (SqlitePool, domain::models::Review) {
|
||||||
|
let pool = migrated_pool().await;
|
||||||
|
let movie = test_movie("Heat");
|
||||||
|
let user = test_user("alice");
|
||||||
|
SqliteUserRepository::new(pool.clone())
|
||||||
|
.save(&user)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
SqliteMovieRepository::new(pool.clone())
|
||||||
|
.upsert_movie(&movie)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let review = test_review(movie.id(), user.id(), 4);
|
||||||
|
(pool, review)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failing_enqueue_after_the_review_insert_rolls_the_review_back() {
|
||||||
|
let (pool, review) = seeded().await;
|
||||||
|
fail_event_inserts(&pool).await;
|
||||||
|
|
||||||
|
let result = SqliteReviewRepository::new(pool.clone())
|
||||||
|
.save_review(&review, &[logged(&review)])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(count(&pool, "reviews").await, 0);
|
||||||
|
assert_eq!(count(&pool, "event_queue").await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn updating_a_review_writes_the_change_and_its_event_in_one_commit() {
|
||||||
|
let (pool, review) = seeded().await;
|
||||||
|
let repo = SqliteReviewRepository::new(pool.clone());
|
||||||
|
repo.save_review(&review, &[]).await.unwrap();
|
||||||
|
let event = updated(&review);
|
||||||
|
|
||||||
|
repo.update_review(&review, &[event]).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(count(&pool, "event_queue").await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failing_enqueue_after_the_review_update_rolls_the_update_back() {
|
||||||
|
let (pool, review) = seeded().await;
|
||||||
|
let repo = SqliteReviewRepository::new(pool.clone());
|
||||||
|
repo.save_review(&review, &[]).await.unwrap();
|
||||||
|
fail_event_inserts(&pool).await;
|
||||||
|
let event = updated(&review);
|
||||||
|
|
||||||
|
let result = repo.update_review(&review, &[event]).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(count(&pool, "event_queue").await, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn deleting_a_review_writes_the_removal_and_its_event_in_one_commit() {
|
||||||
|
let (pool, review) = seeded().await;
|
||||||
|
let repo = SqliteReviewRepository::new(pool.clone());
|
||||||
|
repo.save_review(&review, &[]).await.unwrap();
|
||||||
|
let event = DomainEvent::ReviewDeleted {
|
||||||
|
review_id: review.id().clone(),
|
||||||
|
user_id: review.user_id().clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
repo.delete_review(review.id(), &[event]).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(count(&pool, "reviews").await, 0);
|
||||||
|
assert_eq!(count(&pool, "event_queue").await, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failing_enqueue_after_the_review_delete_rolls_the_delete_back() {
|
||||||
|
let (pool, review) = seeded().await;
|
||||||
|
let repo = SqliteReviewRepository::new(pool.clone());
|
||||||
|
repo.save_review(&review, &[]).await.unwrap();
|
||||||
|
fail_event_inserts(&pool).await;
|
||||||
|
let event = DomainEvent::ReviewDeleted {
|
||||||
|
review_id: review.id().clone(),
|
||||||
|
user_id: review.user_id().clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = repo.delete_review(review.id(), &[event]).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert_eq!(count(&pool, "reviews").await, 1, "delete rolled back");
|
||||||
|
assert_eq!(count(&pool, "event_queue").await, 0);
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ async fn update_profile_persists_bio_and_avatar() {
|
|||||||
avatar_path: Some("avatars/user1".to_string()),
|
avatar_path: Some("avatars/user1".to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
|
&[],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -88,10 +89,11 @@ async fn update_profile_clears_fields_with_none() {
|
|||||||
avatar_path: Some("path".to_string()),
|
avatar_path: Some("path".to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
|
&[],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
repo.update_profile(user.id(), &UserProfile::default())
|
repo.update_profile(user.id(), &UserProfile::default(), &[])
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -94,19 +94,17 @@ impl ReviewLogger for DefaultReviewLogger {
|
|||||||
events.push(review_event);
|
events.push(review_event);
|
||||||
self.review_repo.save_review(&review, &events).await?;
|
self.review_repo.save_review(&review, &events).await?;
|
||||||
|
|
||||||
let was_on_watchlist = self
|
// The removal event is recorded only if an entry was actually removed.
|
||||||
.watchlist_repo
|
self.watchlist_repo
|
||||||
.remove_if_present(review.user_id(), review.movie_id())
|
.remove_if_present(
|
||||||
.await?;
|
review.user_id(),
|
||||||
if was_on_watchlist {
|
review.movie_id(),
|
||||||
let _ = self
|
&[DomainEvent::WatchlistEntryRemoved {
|
||||||
.event_publisher
|
|
||||||
.publish(&DomainEvent::WatchlistEntryRemoved {
|
|
||||||
user_id: review.user_id().clone(),
|
user_id: review.user_id().clone(),
|
||||||
movie_id: review.movie_id().clone(),
|
movie_id: review.movie_id().clone(),
|
||||||
})
|
}],
|
||||||
.await;
|
)
|
||||||
}
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,19 +29,16 @@ pub async fn execute(
|
|||||||
cmd.target_count,
|
cmd.target_count,
|
||||||
GoalType::Movies,
|
GoalType::Movies,
|
||||||
)?;
|
)?;
|
||||||
deps.goal_command.save(&g).await?;
|
let event = DomainEvent::GoalCreated {
|
||||||
|
goal_id: g.id().clone(),
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
year: cmd.year,
|
||||||
|
target_count: cmd.target_count,
|
||||||
|
};
|
||||||
|
deps.goal_command.save(&g, &[event]).await?;
|
||||||
|
|
||||||
let current_count = deps.stats.count_reviews_in_year(&user_id, cmd.year).await?;
|
let current_count = deps.stats.count_reviews_in_year(&user_id, cmd.year).await?;
|
||||||
|
|
||||||
deps.event_publisher
|
|
||||||
.publish(&DomainEvent::GoalCreated {
|
|
||||||
goal_id: g.id().clone(),
|
|
||||||
user_id,
|
|
||||||
year: cmd.year,
|
|
||||||
target_count: cmd.target_count,
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(GoalWithProgress {
|
Ok(GoalWithProgress {
|
||||||
goal: g,
|
goal: g,
|
||||||
current_count,
|
current_count,
|
||||||
|
|||||||
@@ -11,15 +11,12 @@ pub async fn execute(deps: &GoalCommandDeps, cmd: DeleteGoalCommand) -> Result<(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DomainError::NotFound(format!("Goal for year {}", cmd.year)))?;
|
.ok_or_else(|| DomainError::NotFound(format!("Goal for year {}", cmd.year)))?;
|
||||||
|
|
||||||
deps.goal_command.delete(g.id(), &user_id).await?;
|
let event = DomainEvent::GoalDeleted {
|
||||||
|
goal_id: g.id().clone(),
|
||||||
deps.event_publisher
|
user_id: user_id.clone(),
|
||||||
.publish(&DomainEvent::GoalDeleted {
|
year: cmd.year,
|
||||||
goal_id: g.id().clone(),
|
};
|
||||||
user_id,
|
deps.goal_command.delete(g.id(), &user_id, &[event]).await?;
|
||||||
year: cmd.year,
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::ports::{EventPublisher, GoalCommand, GoalQuery, StatsRepository};
|
use domain::ports::{GoalCommand, GoalQuery, StatsRepository};
|
||||||
|
|
||||||
pub struct GoalCommandDeps {
|
pub struct GoalCommandDeps {
|
||||||
pub goal_command: Arc<dyn GoalCommand>,
|
pub goal_command: Arc<dyn GoalCommand>,
|
||||||
pub goal_query: Arc<dyn GoalQuery>,
|
pub goal_query: Arc<dyn GoalQuery>,
|
||||||
pub stats: Arc<dyn StatsRepository>,
|
pub stats: Arc<dyn StatsRepository>,
|
||||||
pub event_publisher: Arc<dyn EventPublisher>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct GoalQueryDeps {
|
pub struct GoalQueryDeps {
|
||||||
|
|||||||
@@ -17,19 +17,16 @@ pub async fn execute(
|
|||||||
.ok_or_else(|| DomainError::NotFound(format!("Goal for year {}", cmd.year)))?;
|
.ok_or_else(|| DomainError::NotFound(format!("Goal for year {}", cmd.year)))?;
|
||||||
|
|
||||||
g.update_target(cmd.target_count)?;
|
g.update_target(cmd.target_count)?;
|
||||||
deps.goal_command.update(&g).await?;
|
let event = DomainEvent::GoalUpdated {
|
||||||
|
goal_id: g.id().clone(),
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
year: cmd.year,
|
||||||
|
target_count: cmd.target_count,
|
||||||
|
};
|
||||||
|
deps.goal_command.update(&g, &[event]).await?;
|
||||||
|
|
||||||
let current_count = deps.stats.count_reviews_in_year(&user_id, cmd.year).await?;
|
let current_count = deps.stats.count_reviews_in_year(&user_id, cmd.year).await?;
|
||||||
|
|
||||||
deps.event_publisher
|
|
||||||
.publish(&DomainEvent::GoalUpdated {
|
|
||||||
goal_id: g.id().clone(),
|
|
||||||
user_id,
|
|
||||||
year: cmd.year,
|
|
||||||
target_count: cmd.target_count,
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(GoalWithProgress {
|
Ok(GoalWithProgress {
|
||||||
goal: g,
|
goal: g,
|
||||||
current_count,
|
current_count,
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ pub struct GetCurrentProfileDeps {
|
|||||||
|
|
||||||
pub struct UpdateProfileFieldsDeps {
|
pub struct UpdateProfileFieldsDeps {
|
||||||
pub profile_fields: Arc<dyn UserProfileFieldsRepository>,
|
pub profile_fields: Arc<dyn UserProfileFieldsRepository>,
|
||||||
pub event_publisher: Arc<dyn EventPublisher>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct GetSettingsDeps {
|
pub struct GetSettingsDeps {
|
||||||
|
|||||||
@@ -91,6 +91,16 @@ pub async fn execute(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let mut events = vec![DomainEvent::UserUpdated {
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
}];
|
||||||
|
if let Some(new_actor_url) = moved_to {
|
||||||
|
events.push(DomainEvent::UserAccountMoved {
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
new_actor_url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
deps.user
|
deps.user
|
||||||
.update_profile(
|
.update_profile(
|
||||||
&user_id,
|
&user_id,
|
||||||
@@ -102,24 +112,9 @@ pub async fn execute(
|
|||||||
also_known_as: cmd.also_known_as,
|
also_known_as: cmd.also_known_as,
|
||||||
profile_fields: vec![],
|
profile_fields: vec![],
|
||||||
},
|
},
|
||||||
|
&events,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
deps.event_publisher
|
|
||||||
.publish(&DomainEvent::UserUpdated {
|
|
||||||
user_id: user_id.clone(),
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if let Some(new_actor_url) = moved_to {
|
|
||||||
let _ = deps
|
|
||||||
.event_publisher
|
|
||||||
.publish(&DomainEvent::UserAccountMoved {
|
|
||||||
user_id,
|
|
||||||
new_actor_url,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ pub async fn execute(
|
|||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
UserProfile::validate_custom_fields(&cmd.fields)?;
|
UserProfile::validate_custom_fields(&cmd.fields)?;
|
||||||
let user_id = UserId::from_uuid(cmd.user_id);
|
let user_id = UserId::from_uuid(cmd.user_id);
|
||||||
deps.profile_fields.set_fields(&user_id, cmd.fields).await?;
|
let event = DomainEvent::UserUpdated {
|
||||||
deps.event_publisher
|
user_id: user_id.clone(),
|
||||||
.publish(&DomainEvent::UserUpdated { user_id })
|
};
|
||||||
.await?;
|
deps.profile_fields
|
||||||
Ok(())
|
.set_fields(&user_id, cmd.fields, &[event])
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,19 +23,15 @@ pub async fn execute(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let entry = WatchlistEntry::new(user_id.clone(), movie.id().clone());
|
let entry = WatchlistEntry::new(user_id.clone(), movie.id().clone());
|
||||||
deps.watchlist.add(&entry).await?;
|
let event = DomainEvent::WatchlistEntryAdded {
|
||||||
|
user_id,
|
||||||
let _ = deps
|
movie_id: movie.id().clone(),
|
||||||
.event_publisher
|
movie_title: movie.title().value().to_string(),
|
||||||
.publish(&DomainEvent::WatchlistEntryAdded {
|
release_year: movie.release_year().value(),
|
||||||
user_id,
|
external_metadata_id: movie.external_metadata_id().map(|e| e.value().to_string()),
|
||||||
movie_id: movie.id().clone(),
|
added_at: entry.added_at,
|
||||||
movie_title: movie.title().value().to_string(),
|
};
|
||||||
release_year: movie.release_year().value(),
|
deps.watchlist.add(&entry, &[event]).await?;
|
||||||
external_metadata_id: movie.external_metadata_id().map(|e| e.value().to_string()),
|
|
||||||
added_at: entry.added_at,
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,5 +29,4 @@ pub struct IsOnWatchlistDeps {
|
|||||||
|
|
||||||
pub struct RemoveFromWatchlistDeps {
|
pub struct RemoveFromWatchlistDeps {
|
||||||
pub watchlist: Arc<dyn WatchlistRepository>,
|
pub watchlist: Arc<dyn WatchlistRepository>,
|
||||||
pub event_publisher: Arc<dyn EventPublisher>,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,11 @@ pub async fn execute(
|
|||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let user_id = UserId::from_uuid(cmd.user_id);
|
let user_id = UserId::from_uuid(cmd.user_id);
|
||||||
let movie_id = MovieId::from_uuid(cmd.movie_id);
|
let movie_id = MovieId::from_uuid(cmd.movie_id);
|
||||||
deps.watchlist.remove(&user_id, &movie_id).await?;
|
let event = DomainEvent::WatchlistEntryRemoved {
|
||||||
|
user_id: user_id.clone(),
|
||||||
let _ = deps
|
movie_id: movie_id.clone(),
|
||||||
.event_publisher
|
};
|
||||||
.publish(&DomainEvent::WatchlistEntryRemoved { user_id, movie_id })
|
deps.watchlist.remove(&user_id, &movie_id, &[event]).await?;
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ async fn deleting_a_review_removes_it_and_records_its_event_together() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_refused_delete_records_neither_the_removal_nor_its_event() {
|
async fn a_refused_delete_propagates_and_records_nothing() {
|
||||||
let movies = InMemoryStore::new();
|
let movies = InMemoryStore::new();
|
||||||
let diary = FakeDiaryQuery::new();
|
let diary = FakeDiaryQuery::new();
|
||||||
let events = NoopEventPublisher::new();
|
let events = NoopEventPublisher::new();
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ async fn editing_a_review_saves_the_change_and_its_event_together() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_refused_edit_records_neither_the_change_nor_its_event() {
|
async fn a_refused_edit_propagates_and_records_nothing() {
|
||||||
let (reviews, review_id, user_id) = setup().await;
|
let (reviews, review_id, user_id) = setup().await;
|
||||||
let deps = EditReviewDeps {
|
let deps = EditReviewDeps {
|
||||||
review: Arc::new(WriteRefusingReviews(Arc::clone(&reviews))),
|
review: Arc::new(WriteRefusingReviews(Arc::clone(&reviews))),
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ async fn removes_from_watchlist_on_review() {
|
|||||||
|
|
||||||
// Add to watchlist
|
// Add to watchlist
|
||||||
let entry = WatchlistEntry::new(user_id.clone(), MovieId::from_uuid(movie_id));
|
let entry = WatchlistEntry::new(user_id.clone(), MovieId::from_uuid(movie_id));
|
||||||
watchlist.add(&entry).await.unwrap();
|
watchlist.add(&entry, &[]).await.unwrap();
|
||||||
assert_eq!(watchlist.watchlist_count(), 1);
|
assert_eq!(watchlist.watchlist_count(), 1);
|
||||||
|
|
||||||
// Log review for same movie
|
// Log review for same movie
|
||||||
@@ -80,6 +80,20 @@ async fn removes_from_watchlist_on_review() {
|
|||||||
logger.log_review(cmd).await.unwrap();
|
logger.log_review(cmd).await.unwrap();
|
||||||
|
|
||||||
assert_eq!(watchlist.watchlist_count(), 0);
|
assert_eq!(watchlist.watchlist_count(), 0);
|
||||||
|
assert!(
|
||||||
|
movies
|
||||||
|
.recorded_events()
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, domain::events::DomainEvent::WatchlistEntryRemoved { .. })),
|
||||||
|
"the removal event rides the watchlist write"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!events
|
||||||
|
.published()
|
||||||
|
.iter()
|
||||||
|
.any(|e| matches!(e, domain::events::DomainEvent::WatchlistEntryRemoved { .. })),
|
||||||
|
"no longer published out of band"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -150,7 +164,7 @@ async fn watchlist_not_present_does_not_publish_removed() {
|
|||||||
|
|
||||||
logger.log_review(cmd).await.unwrap();
|
logger.log_review(cmd).await.unwrap();
|
||||||
|
|
||||||
let published = events.published();
|
let published = movies.recorded_events();
|
||||||
assert!(
|
assert!(
|
||||||
!published
|
!published
|
||||||
.iter()
|
.iter()
|
||||||
@@ -260,7 +274,7 @@ async fn logging_a_review_saves_the_review_and_its_event_together() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_refused_save_records_neither_the_review_nor_its_event() {
|
async fn a_refused_save_propagates_and_records_nothing() {
|
||||||
let movies = InMemoryStore::new();
|
let movies = InMemoryStore::new();
|
||||||
let logger = DefaultReviewLogger::new(
|
let logger = DefaultReviewLogger::new(
|
||||||
Arc::clone(&movies) as _,
|
Arc::clone(&movies) as _,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use crate::support::create_goal_command;
|
use crate::support::create_goal_command;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::errors::DomainError;
|
||||||
use domain::events::DomainEvent;
|
use domain::events::DomainEvent;
|
||||||
use domain::testing::{FakeStatsRepository, InMemoryStore, NoopEventPublisher};
|
use domain::testing::{FakeStatsRepository, InMemoryStore, RefusingGoalCommand};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::support::TestContextBuilder;
|
use crate::support::TestContextBuilder;
|
||||||
@@ -13,12 +14,10 @@ use application::goals::deps::GoalCommandDeps;
|
|||||||
async fn creates_goal_and_returns_progress() {
|
async fn creates_goal_and_returns_progress() {
|
||||||
let goals = InMemoryStore::new();
|
let goals = InMemoryStore::new();
|
||||||
let stats = FakeStatsRepository::new();
|
let stats = FakeStatsRepository::new();
|
||||||
let events = NoopEventPublisher::new();
|
|
||||||
let deps = GoalCommandDeps {
|
let deps = GoalCommandDeps {
|
||||||
goal_command: Arc::clone(&goals) as _,
|
goal_command: Arc::clone(&goals) as _,
|
||||||
goal_query: Arc::clone(&goals) as _,
|
goal_query: Arc::clone(&goals) as _,
|
||||||
stats: Arc::clone(&stats) as _,
|
stats: Arc::clone(&stats) as _,
|
||||||
event_publisher: Arc::clone(&events) as _,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = create::execute(&deps, create_goal_command(2025, 50))
|
let result = create::execute(&deps, create_goal_command(2025, 50))
|
||||||
@@ -36,12 +35,10 @@ async fn creates_goal_with_review_count() {
|
|||||||
let goals = InMemoryStore::new();
|
let goals = InMemoryStore::new();
|
||||||
let stats = FakeStatsRepository::new();
|
let stats = FakeStatsRepository::new();
|
||||||
stats.set_review_count(Uuid::nil(), 2025, 5);
|
stats.set_review_count(Uuid::nil(), 2025, 5);
|
||||||
let events = NoopEventPublisher::new();
|
|
||||||
let deps = GoalCommandDeps {
|
let deps = GoalCommandDeps {
|
||||||
goal_command: Arc::clone(&goals) as _,
|
goal_command: Arc::clone(&goals) as _,
|
||||||
goal_query: Arc::clone(&goals) as _,
|
goal_query: Arc::clone(&goals) as _,
|
||||||
stats: Arc::clone(&stats) as _,
|
stats: Arc::clone(&stats) as _,
|
||||||
event_publisher: Arc::clone(&events) as _,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = create::execute(&deps, create_goal_command(2025, 50))
|
let result = create::execute(&deps, create_goal_command(2025, 50))
|
||||||
@@ -53,26 +50,45 @@ async fn creates_goal_with_review_count() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn emits_goal_created_event() {
|
async fn creating_a_goal_records_the_goal_and_its_event_together() {
|
||||||
let b = TestContextBuilder::new();
|
let goals = InMemoryStore::new();
|
||||||
let events = NoopEventPublisher::new();
|
|
||||||
let deps = GoalCommandDeps {
|
let deps = GoalCommandDeps {
|
||||||
goal_command: b.goal_command.clone(),
|
goal_command: Arc::clone(&goals) as _,
|
||||||
goal_query: b.goal_query.clone(),
|
goal_query: Arc::clone(&goals) as _,
|
||||||
stats: b.stats_repo.clone(),
|
stats: FakeStatsRepository::new() as _,
|
||||||
event_publisher: Arc::clone(&events) as _,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
create::execute(&deps, create_goal_command(2025, 10))
|
create::execute(&deps, create_goal_command(2025, 10))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let published = events.published();
|
assert_eq!(goals.goal_count(), 1);
|
||||||
assert!(
|
let recorded = goals.recorded_events();
|
||||||
published
|
assert_eq!(recorded.len(), 1);
|
||||||
.iter()
|
assert!(matches!(
|
||||||
.any(|e| matches!(e, DomainEvent::GoalCreated { year: 2025, .. }))
|
recorded[0],
|
||||||
);
|
DomainEvent::GoalCreated {
|
||||||
|
year: 2025,
|
||||||
|
target_count: 10,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_refused_create_propagates_and_records_nothing() {
|
||||||
|
let goals = InMemoryStore::new();
|
||||||
|
let deps = GoalCommandDeps {
|
||||||
|
goal_command: Arc::new(RefusingGoalCommand),
|
||||||
|
goal_query: Arc::clone(&goals) as _,
|
||||||
|
stats: FakeStatsRepository::new() as _,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = create::execute(&deps, create_goal_command(2025, 10)).await;
|
||||||
|
|
||||||
|
assert!(matches!(result, Err(DomainError::InfrastructureError(m)) if m == "refused"));
|
||||||
|
assert_eq!(goals.goal_count(), 0);
|
||||||
|
assert!(goals.recorded_events().is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -82,7 +98,6 @@ async fn rejects_duplicate_year() {
|
|||||||
goal_command: b.goal_command.clone(),
|
goal_command: b.goal_command.clone(),
|
||||||
goal_query: b.goal_query.clone(),
|
goal_query: b.goal_query.clone(),
|
||||||
stats: b.stats_repo.clone(),
|
stats: b.stats_repo.clone(),
|
||||||
event_publisher: b.event_publisher.clone(),
|
|
||||||
};
|
};
|
||||||
let cmd = create_goal_command(2025, 10);
|
let cmd = create_goal_command(2025, 10);
|
||||||
|
|
||||||
@@ -100,7 +115,6 @@ async fn rejects_year_before_2020() {
|
|||||||
goal_command: b.goal_command.clone(),
|
goal_command: b.goal_command.clone(),
|
||||||
goal_query: b.goal_query.clone(),
|
goal_query: b.goal_query.clone(),
|
||||||
stats: b.stats_repo.clone(),
|
stats: b.stats_repo.clone(),
|
||||||
event_publisher: b.event_publisher.clone(),
|
|
||||||
};
|
};
|
||||||
let result = create::execute(&deps, create_goal_command(2019, 10)).await;
|
let result = create::execute(&deps, create_goal_command(2019, 10)).await;
|
||||||
|
|
||||||
@@ -114,7 +128,6 @@ async fn rejects_zero_target() {
|
|||||||
goal_command: b.goal_command.clone(),
|
goal_command: b.goal_command.clone(),
|
||||||
goal_query: b.goal_query.clone(),
|
goal_query: b.goal_query.clone(),
|
||||||
stats: b.stats_repo.clone(),
|
stats: b.stats_repo.clone(),
|
||||||
event_publisher: b.event_publisher.clone(),
|
|
||||||
};
|
};
|
||||||
let result = create::execute(&deps, create_goal_command(2025, 0)).await;
|
let result = create::execute(&deps, create_goal_command(2025, 0)).await;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use crate::support::create_goal_command;
|
use crate::support::create_goal_command;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::testing::{FakeStatsRepository, InMemoryStore, NoopEventPublisher};
|
use domain::errors::DomainError;
|
||||||
|
use domain::events::DomainEvent;
|
||||||
|
use domain::testing::{FakeStatsRepository, InMemoryStore, RefusingGoalCommand};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::support::TestContextBuilder;
|
use crate::support::TestContextBuilder;
|
||||||
@@ -12,12 +14,10 @@ use application::goals::{commands::DeleteGoalCommand, create, delete};
|
|||||||
async fn deletes_existing_goal() {
|
async fn deletes_existing_goal() {
|
||||||
let goals = InMemoryStore::new();
|
let goals = InMemoryStore::new();
|
||||||
let stats = FakeStatsRepository::new();
|
let stats = FakeStatsRepository::new();
|
||||||
let events = NoopEventPublisher::new();
|
|
||||||
let deps = GoalCommandDeps {
|
let deps = GoalCommandDeps {
|
||||||
goal_command: Arc::clone(&goals) as _,
|
goal_command: Arc::clone(&goals) as _,
|
||||||
goal_query: Arc::clone(&goals) as _,
|
goal_query: Arc::clone(&goals) as _,
|
||||||
stats: Arc::clone(&stats) as _,
|
stats: Arc::clone(&stats) as _,
|
||||||
event_publisher: Arc::clone(&events) as _,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
create::execute(&deps, create_goal_command(2025, 10))
|
create::execute(&deps, create_goal_command(2025, 10))
|
||||||
@@ -45,7 +45,6 @@ async fn fails_when_not_found() {
|
|||||||
goal_command: b.goal_command.clone(),
|
goal_command: b.goal_command.clone(),
|
||||||
goal_query: b.goal_query.clone(),
|
goal_query: b.goal_query.clone(),
|
||||||
stats: b.stats_repo.clone(),
|
stats: b.stats_repo.clone(),
|
||||||
event_publisher: b.event_publisher.clone(),
|
|
||||||
};
|
};
|
||||||
let result = delete::execute(
|
let result = delete::execute(
|
||||||
&deps,
|
&deps,
|
||||||
@@ -58,3 +57,65 @@ async fn fails_when_not_found() {
|
|||||||
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn deleting_a_goal_removes_it_and_records_its_event_together() {
|
||||||
|
let goals = InMemoryStore::new();
|
||||||
|
let deps = GoalCommandDeps {
|
||||||
|
goal_command: Arc::clone(&goals) as _,
|
||||||
|
goal_query: Arc::clone(&goals) as _,
|
||||||
|
stats: FakeStatsRepository::new() as _,
|
||||||
|
};
|
||||||
|
create::execute(&deps, create_goal_command(2025, 10))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
delete::execute(
|
||||||
|
&deps,
|
||||||
|
DeleteGoalCommand {
|
||||||
|
user_id: Uuid::nil(),
|
||||||
|
year: 2025,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(goals.goal_count(), 0);
|
||||||
|
let recorded = goals.recorded_events();
|
||||||
|
assert_eq!(recorded.len(), 2, "created + deleted");
|
||||||
|
assert!(matches!(
|
||||||
|
recorded[1],
|
||||||
|
DomainEvent::GoalDeleted { year: 2025, .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_refused_delete_propagates_and_records_nothing() {
|
||||||
|
let goals = InMemoryStore::new();
|
||||||
|
let seed = GoalCommandDeps {
|
||||||
|
goal_command: Arc::clone(&goals) as _,
|
||||||
|
goal_query: Arc::clone(&goals) as _,
|
||||||
|
stats: FakeStatsRepository::new() as _,
|
||||||
|
};
|
||||||
|
create::execute(&seed, create_goal_command(2025, 10))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let before = goals.recorded_events().len();
|
||||||
|
let deps = GoalCommandDeps {
|
||||||
|
goal_command: Arc::new(RefusingGoalCommand),
|
||||||
|
..seed
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = delete::execute(
|
||||||
|
&deps,
|
||||||
|
DeleteGoalCommand {
|
||||||
|
user_id: Uuid::nil(),
|
||||||
|
year: 2025,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(matches!(result, Err(DomainError::InfrastructureError(m)) if m == "refused"));
|
||||||
|
assert_eq!(goals.goal_count(), 1);
|
||||||
|
assert_eq!(goals.recorded_events().len(), before);
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ async fn returns_goal_when_exists() {
|
|||||||
goal_command: b.goal_command.clone(),
|
goal_command: b.goal_command.clone(),
|
||||||
goal_query: b.goal_query.clone(),
|
goal_query: b.goal_query.clone(),
|
||||||
stats: b.stats_repo.clone(),
|
stats: b.stats_repo.clone(),
|
||||||
event_publisher: b.event_publisher.clone(),
|
|
||||||
};
|
};
|
||||||
let query_deps = GoalQueryDeps {
|
let query_deps = GoalQueryDeps {
|
||||||
goal_query: b.goal_query.clone(),
|
goal_query: b.goal_query.clone(),
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ async fn returns_all_goals_for_user() {
|
|||||||
goal_command: b.goal_command.clone(),
|
goal_command: b.goal_command.clone(),
|
||||||
goal_query: b.goal_query.clone(),
|
goal_query: b.goal_query.clone(),
|
||||||
stats: b.stats_repo.clone(),
|
stats: b.stats_repo.clone(),
|
||||||
event_publisher: b.event_publisher.clone(),
|
|
||||||
};
|
};
|
||||||
let query_deps = GoalQueryDeps {
|
let query_deps = GoalQueryDeps {
|
||||||
goal_query: b.goal_query.clone(),
|
goal_query: b.goal_query.clone(),
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
use crate::support::{create_goal_command, update_goal_command};
|
use crate::support::{create_goal_command, update_goal_command};
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::events::DomainEvent;
|
||||||
|
use domain::testing::{FakeStatsRepository, InMemoryStore, RefusingGoalCommand};
|
||||||
|
|
||||||
use crate::support::TestContextBuilder;
|
use crate::support::TestContextBuilder;
|
||||||
use application::goals::deps::GoalCommandDeps;
|
use application::goals::deps::GoalCommandDeps;
|
||||||
use application::goals::{create, update};
|
use application::goals::{create, update};
|
||||||
@@ -11,7 +17,6 @@ async fn updates_target_count() {
|
|||||||
goal_command: b.goal_command.clone(),
|
goal_command: b.goal_command.clone(),
|
||||||
goal_query: b.goal_query.clone(),
|
goal_query: b.goal_query.clone(),
|
||||||
stats: b.stats_repo.clone(),
|
stats: b.stats_repo.clone(),
|
||||||
event_publisher: b.event_publisher.clone(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
create::execute(&deps, create_goal_command(2025, 10))
|
create::execute(&deps, create_goal_command(2025, 10))
|
||||||
@@ -32,7 +37,6 @@ async fn fails_when_goal_not_found() {
|
|||||||
goal_command: b.goal_command.clone(),
|
goal_command: b.goal_command.clone(),
|
||||||
goal_query: b.goal_query.clone(),
|
goal_query: b.goal_query.clone(),
|
||||||
stats: b.stats_repo.clone(),
|
stats: b.stats_repo.clone(),
|
||||||
event_publisher: b.event_publisher.clone(),
|
|
||||||
};
|
};
|
||||||
let result = update::execute(&deps, update_goal_command(2025, 10)).await;
|
let result = update::execute(&deps, update_goal_command(2025, 10)).await;
|
||||||
|
|
||||||
@@ -46,7 +50,6 @@ async fn rejects_zero_target() {
|
|||||||
goal_command: b.goal_command.clone(),
|
goal_command: b.goal_command.clone(),
|
||||||
goal_query: b.goal_query.clone(),
|
goal_query: b.goal_query.clone(),
|
||||||
stats: b.stats_repo.clone(),
|
stats: b.stats_repo.clone(),
|
||||||
event_publisher: b.event_publisher.clone(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
create::execute(&deps, create_goal_command(2025, 10))
|
create::execute(&deps, create_goal_command(2025, 10))
|
||||||
@@ -57,3 +60,54 @@ async fn rejects_zero_target() {
|
|||||||
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn updating_a_goal_records_the_change_and_its_event_together() {
|
||||||
|
let goals = InMemoryStore::new();
|
||||||
|
let deps = GoalCommandDeps {
|
||||||
|
goal_command: Arc::clone(&goals) as _,
|
||||||
|
goal_query: Arc::clone(&goals) as _,
|
||||||
|
stats: FakeStatsRepository::new() as _,
|
||||||
|
};
|
||||||
|
create::execute(&deps, create_goal_command(2025, 10))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
update::execute(&deps, update_goal_command(2025, 100))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let recorded = goals.recorded_events();
|
||||||
|
assert_eq!(recorded.len(), 2, "created + updated");
|
||||||
|
assert!(matches!(
|
||||||
|
recorded[1],
|
||||||
|
DomainEvent::GoalUpdated {
|
||||||
|
year: 2025,
|
||||||
|
target_count: 100,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_refused_update_propagates_and_records_nothing() {
|
||||||
|
let goals = InMemoryStore::new();
|
||||||
|
let seed = GoalCommandDeps {
|
||||||
|
goal_command: Arc::clone(&goals) as _,
|
||||||
|
goal_query: Arc::clone(&goals) as _,
|
||||||
|
stats: FakeStatsRepository::new() as _,
|
||||||
|
};
|
||||||
|
create::execute(&seed, create_goal_command(2025, 10))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let before = goals.recorded_events().len();
|
||||||
|
let deps = GoalCommandDeps {
|
||||||
|
goal_command: Arc::new(RefusingGoalCommand),
|
||||||
|
..seed
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = update::execute(&deps, update_goal_command(2025, 100)).await;
|
||||||
|
|
||||||
|
assert!(matches!(result, Err(DomainError::InfrastructureError(m)) if m == "refused"));
|
||||||
|
assert_eq!(goals.recorded_events().len(), before);
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ async fn get_profile_settings_includes_profile_fields() {
|
|||||||
name: "pronouns".into(),
|
name: "pronouns".into(),
|
||||||
value: "they/them".into(),
|
value: "they/them".into(),
|
||||||
}],
|
}],
|
||||||
|
&[],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use domain::events::DomainEvent;
|
use domain::events::DomainEvent;
|
||||||
use domain::models::ProfileField;
|
use domain::models::ProfileField;
|
||||||
use domain::testing::{InMemoryStore, NoopEventPublisher};
|
use domain::testing::{InMemoryStore, RefusingProfileFieldsRepository};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use application::users::{
|
use application::users::{
|
||||||
@@ -11,15 +11,10 @@ use application::users::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn saves_profile_fields() {
|
async fn saving_profile_fields_records_the_change_and_its_event_together() {
|
||||||
let fields_repo = InMemoryStore::new();
|
let fields_repo = InMemoryStore::new();
|
||||||
let events = NoopEventPublisher::new();
|
|
||||||
let b = TestContextBuilder::new()
|
|
||||||
.with_profile_fields(Arc::clone(&fields_repo) as _)
|
|
||||||
.with_event_publisher(Arc::clone(&events) as _);
|
|
||||||
let deps = UpdateProfileFieldsDeps {
|
let deps = UpdateProfileFieldsDeps {
|
||||||
profile_fields: b.profile_fields_repo.clone(),
|
profile_fields: Arc::clone(&fields_repo) as _,
|
||||||
event_publisher: b.event_publisher.clone(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
update_profile_fields::execute(
|
update_profile_fields::execute(
|
||||||
@@ -41,12 +36,9 @@ async fn saves_profile_fields() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let published = events.published();
|
let recorded = fields_repo.recorded_events();
|
||||||
assert!(
|
assert_eq!(recorded.len(), 1);
|
||||||
published
|
assert!(matches!(recorded[0], DomainEvent::UserUpdated { .. }));
|
||||||
.iter()
|
|
||||||
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -54,7 +46,6 @@ async fn rejects_more_than_four_fields() {
|
|||||||
let b = TestContextBuilder::new();
|
let b = TestContextBuilder::new();
|
||||||
let deps = UpdateProfileFieldsDeps {
|
let deps = UpdateProfileFieldsDeps {
|
||||||
profile_fields: b.profile_fields_repo.clone(),
|
profile_fields: b.profile_fields_repo.clone(),
|
||||||
event_publisher: b.event_publisher.clone(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let fields: Vec<ProfileField> = (0..5)
|
let fields: Vec<ProfileField> = (0..5)
|
||||||
@@ -75,3 +66,24 @@ async fn rejects_more_than_four_fields() {
|
|||||||
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn refused_profile_fields_propagate_and_record_nothing() {
|
||||||
|
let deps = UpdateProfileFieldsDeps {
|
||||||
|
profile_fields: Arc::new(RefusingProfileFieldsRepository),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = update_profile_fields::execute(
|
||||||
|
&deps,
|
||||||
|
UpdateProfileFieldsCommand {
|
||||||
|
user_id: Uuid::nil(),
|
||||||
|
fields: vec![],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
Err(domain::errors::DomainError::InfrastructureError(m)) if m == "refused"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ async fn register_user(b: &TestContextBuilder, users: &Arc<InMemoryStore>) -> Uu
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn updates_display_name() {
|
async fn updating_the_profile_records_the_change_and_its_event_together() {
|
||||||
let users = InMemoryStore::new();
|
let users = InMemoryStore::new();
|
||||||
let events = NoopEventPublisher::new();
|
let events = NoopEventPublisher::new();
|
||||||
let b = TestContextBuilder::new()
|
let b = TestContextBuilder::new()
|
||||||
@@ -54,12 +54,9 @@ async fn updates_display_name() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let published = events.published();
|
let recorded = users.recorded_events();
|
||||||
assert!(
|
assert_eq!(recorded.len(), 1);
|
||||||
published
|
assert!(matches!(recorded[0], DomainEvent::UserUpdated { .. }));
|
||||||
.iter()
|
|
||||||
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -111,14 +108,15 @@ async fn uploads_avatar() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let published = events.published();
|
|
||||||
assert!(
|
assert!(
|
||||||
published
|
users
|
||||||
|
.recorded_events()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
|
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
published
|
events
|
||||||
|
.published()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|e| matches!(e, DomainEvent::ImageStored { .. }))
|
.any(|e| matches!(e, DomainEvent::ImageStored { .. }))
|
||||||
);
|
);
|
||||||
@@ -149,14 +147,15 @@ async fn uploads_banner() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let published = events.published();
|
|
||||||
assert!(
|
assert!(
|
||||||
published
|
users
|
||||||
|
.recorded_events()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
|
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
published
|
events
|
||||||
|
.published()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|e| matches!(e, DomainEvent::ImageStored { .. }))
|
.any(|e| matches!(e, DomainEvent::ImageStored { .. }))
|
||||||
);
|
);
|
||||||
@@ -232,16 +231,114 @@ async fn text_only_update_emits_user_updated_no_image_stored() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let published = events.published();
|
|
||||||
assert!(
|
assert!(
|
||||||
published
|
users
|
||||||
|
.recorded_events()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
|
.any(|e| matches!(e, DomainEvent::UserUpdated { .. }))
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!published
|
!events
|
||||||
|
.published()
|
||||||
.iter()
|
.iter()
|
||||||
.any(|e| matches!(e, DomainEvent::ImageStored { .. })),
|
.any(|e| matches!(e, DomainEvent::ImageStored { .. })),
|
||||||
"text-only update should not emit ImageStored"
|
"text-only update should not emit ImageStored"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn changing_also_known_as_records_the_move_with_the_update() {
|
||||||
|
let users = InMemoryStore::new();
|
||||||
|
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||||
|
let deps = UpdateProfileDeps {
|
||||||
|
user: b.user_repo.clone(),
|
||||||
|
object_storage: b.object_storage.clone(),
|
||||||
|
event_publisher: b.event_publisher.clone(),
|
||||||
|
};
|
||||||
|
let uid = register_user(&b, &users).await;
|
||||||
|
|
||||||
|
update_profile::execute(
|
||||||
|
&deps,
|
||||||
|
UpdateProfileCommand {
|
||||||
|
also_known_as: Some("https://other.example/users/alice".into()),
|
||||||
|
..update_profile_command(uid)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let recorded = users.recorded_events();
|
||||||
|
assert_eq!(recorded.len(), 2);
|
||||||
|
assert!(matches!(recorded[0], DomainEvent::UserUpdated { .. }));
|
||||||
|
assert!(matches!(recorded[1], DomainEvent::UserAccountMoved { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads from the store but refuses the profile write.
|
||||||
|
struct ProfileWriteRefusingUsers(Arc<InMemoryStore>);
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl UserRepository for ProfileWriteRefusingUsers {
|
||||||
|
async fn find_by_email(
|
||||||
|
&self,
|
||||||
|
e: &domain::value_objects::Email,
|
||||||
|
) -> Result<Option<domain::models::User>, domain::errors::DomainError> {
|
||||||
|
self.0.find_by_email(e).await
|
||||||
|
}
|
||||||
|
async fn find_by_username(
|
||||||
|
&self,
|
||||||
|
u: &domain::value_objects::Username,
|
||||||
|
) -> Result<Option<domain::models::User>, domain::errors::DomainError> {
|
||||||
|
self.0.find_by_username(u).await
|
||||||
|
}
|
||||||
|
async fn save(&self, u: &domain::models::User) -> Result<(), domain::errors::DomainError> {
|
||||||
|
self.0.save(u).await
|
||||||
|
}
|
||||||
|
async fn find_by_id(
|
||||||
|
&self,
|
||||||
|
id: &domain::value_objects::UserId,
|
||||||
|
) -> Result<Option<domain::models::User>, domain::errors::DomainError> {
|
||||||
|
self.0.find_by_id(id).await
|
||||||
|
}
|
||||||
|
async fn list_with_stats(
|
||||||
|
&self,
|
||||||
|
) -> Result<Vec<domain::models::UserSummary>, domain::errors::DomainError> {
|
||||||
|
self.0.list_with_stats().await
|
||||||
|
}
|
||||||
|
async fn update_profile(
|
||||||
|
&self,
|
||||||
|
_: &domain::value_objects::UserId,
|
||||||
|
_: &domain::models::UserProfile,
|
||||||
|
_: &[DomainEvent],
|
||||||
|
) -> Result<(), domain::errors::DomainError> {
|
||||||
|
Err(domain::errors::DomainError::InfrastructureError(
|
||||||
|
"refused".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_refused_profile_update_propagates_and_records_nothing() {
|
||||||
|
let users = InMemoryStore::new();
|
||||||
|
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||||
|
let uid = register_user(&b, &users).await;
|
||||||
|
let deps = UpdateProfileDeps {
|
||||||
|
user: Arc::new(ProfileWriteRefusingUsers(Arc::clone(&users))),
|
||||||
|
object_storage: b.object_storage.clone(),
|
||||||
|
event_publisher: b.event_publisher.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = update_profile::execute(
|
||||||
|
&deps,
|
||||||
|
UpdateProfileCommand {
|
||||||
|
display_name: Some("Alice W.".into()),
|
||||||
|
..update_profile_command(uid)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
Err(domain::errors::DomainError::InfrastructureError(m)) if m == "refused"
|
||||||
|
));
|
||||||
|
assert!(users.recorded_events().is_empty());
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ use domain::testing::test_movie_with_year;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
|
events::DomainEvent,
|
||||||
ports::MovieCommand,
|
ports::MovieCommand,
|
||||||
testing::{InMemoryStore, NoopEventPublisher},
|
testing::{InMemoryStore, NoopEventPublisher, RefusingWatchlistRepository},
|
||||||
};
|
};
|
||||||
|
|
||||||
use application::watchlist::{add, commands::AddToWatchlistCommand, deps::WatchlistAddDeps};
|
use application::watchlist::{add, commands::AddToWatchlistCommand, deps::WatchlistAddDeps};
|
||||||
@@ -108,3 +109,50 @@ async fn adding_the_same_movie_to_a_watchlist_twice_keeps_a_single_entry_and_suc
|
|||||||
|
|
||||||
assert_eq!(movies.watchlist_count(), 1);
|
assert_eq!(movies.watchlist_count(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn adding_an_entry_records_the_entry_and_its_event_together() {
|
||||||
|
let movies = InMemoryStore::new();
|
||||||
|
let movie = test_movie_with_year("Heat", 1995);
|
||||||
|
movies.upsert_movie(&movie).await.unwrap();
|
||||||
|
let deps = make_deps(Arc::clone(&movies), Arc::clone(&movies));
|
||||||
|
|
||||||
|
add::execute(
|
||||||
|
&deps,
|
||||||
|
add_to_watchlist_command(uuid::Uuid::new_v4(), movie.id().value()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(movies.watchlist_count(), 1);
|
||||||
|
let recorded = movies.recorded_events();
|
||||||
|
assert_eq!(recorded.len(), 1);
|
||||||
|
assert!(matches!(
|
||||||
|
recorded[0],
|
||||||
|
DomainEvent::WatchlistEntryAdded { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_refused_add_propagates_and_records_nothing() {
|
||||||
|
let movies = InMemoryStore::new();
|
||||||
|
let movie = test_movie_with_year("Heat", 1995);
|
||||||
|
movies.upsert_movie(&movie).await.unwrap();
|
||||||
|
let deps = WatchlistAddDeps {
|
||||||
|
watchlist: Arc::new(RefusingWatchlistRepository),
|
||||||
|
..make_deps(Arc::clone(&movies), Arc::clone(&movies))
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = add::execute(
|
||||||
|
&deps,
|
||||||
|
add_to_watchlist_command(uuid::Uuid::new_v4(), movie.id().value()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
Err(domain::errors::DomainError::InfrastructureError(m)) if m == "refused"
|
||||||
|
));
|
||||||
|
assert_eq!(movies.watchlist_count(), 0);
|
||||||
|
assert!(movies.recorded_events().is_empty());
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ async fn returns_true_when_present() {
|
|||||||
let uid = Uuid::new_v4();
|
let uid = Uuid::new_v4();
|
||||||
let mid = Uuid::new_v4();
|
let mid = Uuid::new_v4();
|
||||||
watchlist
|
watchlist
|
||||||
.add(&WatchlistEntry::new(
|
.add(
|
||||||
UserId::from_uuid(uid),
|
&WatchlistEntry::new(UserId::from_uuid(uid), MovieId::from_uuid(mid)),
|
||||||
MovieId::from_uuid(mid),
|
&[],
|
||||||
))
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
|||||||
use domain::events::DomainEvent;
|
use domain::events::DomainEvent;
|
||||||
use domain::models::WatchlistEntry;
|
use domain::models::WatchlistEntry;
|
||||||
use domain::ports::WatchlistRepository;
|
use domain::ports::WatchlistRepository;
|
||||||
use domain::testing::{InMemoryStore, NoopEventPublisher};
|
use domain::testing::{InMemoryStore, RefusingWatchlistRepository};
|
||||||
use domain::value_objects::{MovieId, UserId};
|
use domain::value_objects::{MovieId, UserId};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -13,22 +13,20 @@ use application::watchlist::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn removes_entry_and_emits_event() {
|
async fn removing_an_entry_records_the_removal_and_its_event_together() {
|
||||||
let watchlist = InMemoryStore::new();
|
let watchlist = InMemoryStore::new();
|
||||||
let events = NoopEventPublisher::new();
|
|
||||||
let uid = Uuid::new_v4();
|
let uid = Uuid::new_v4();
|
||||||
let mid = Uuid::new_v4();
|
let mid = Uuid::new_v4();
|
||||||
watchlist
|
watchlist
|
||||||
.add(&WatchlistEntry::new(
|
.add(
|
||||||
UserId::from_uuid(uid),
|
&WatchlistEntry::new(UserId::from_uuid(uid), MovieId::from_uuid(mid)),
|
||||||
MovieId::from_uuid(mid),
|
&[],
|
||||||
))
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let deps = RemoveFromWatchlistDeps {
|
let deps = RemoveFromWatchlistDeps {
|
||||||
watchlist: Arc::clone(&watchlist) as _,
|
watchlist: Arc::clone(&watchlist) as _,
|
||||||
event_publisher: Arc::clone(&events) as _,
|
|
||||||
};
|
};
|
||||||
remove::execute(
|
remove::execute(
|
||||||
&deps,
|
&deps,
|
||||||
@@ -41,12 +39,12 @@ async fn removes_entry_and_emits_event() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(watchlist.watchlist_count(), 0);
|
assert_eq!(watchlist.watchlist_count(), 0);
|
||||||
let published = events.published();
|
let recorded = watchlist.recorded_events();
|
||||||
assert!(
|
assert_eq!(recorded.len(), 1);
|
||||||
published
|
assert!(matches!(
|
||||||
.iter()
|
recorded[0],
|
||||||
.any(|e| matches!(e, DomainEvent::WatchlistEntryRemoved { .. }))
|
DomainEvent::WatchlistEntryRemoved { .. }
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -54,7 +52,6 @@ async fn fails_when_not_on_watchlist() {
|
|||||||
let b = TestContextBuilder::new();
|
let b = TestContextBuilder::new();
|
||||||
let deps = RemoveFromWatchlistDeps {
|
let deps = RemoveFromWatchlistDeps {
|
||||||
watchlist: b.watchlist_repo.clone(),
|
watchlist: b.watchlist_repo.clone(),
|
||||||
event_publisher: b.event_publisher.clone(),
|
|
||||||
};
|
};
|
||||||
let result = remove::execute(
|
let result = remove::execute(
|
||||||
&deps,
|
&deps,
|
||||||
@@ -67,3 +64,44 @@ async fn fails_when_not_on_watchlist() {
|
|||||||
|
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failed_removal_records_no_event() {
|
||||||
|
let store = InMemoryStore::new();
|
||||||
|
let deps = RemoveFromWatchlistDeps {
|
||||||
|
watchlist: Arc::clone(&store) as _,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = remove::execute(
|
||||||
|
&deps,
|
||||||
|
RemoveFromWatchlistCommand {
|
||||||
|
user_id: Uuid::new_v4(),
|
||||||
|
movie_id: Uuid::new_v4(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(store.recorded_events().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_refused_remove_propagates_the_refusal() {
|
||||||
|
let deps = RemoveFromWatchlistDeps {
|
||||||
|
watchlist: Arc::new(RefusingWatchlistRepository),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = remove::execute(
|
||||||
|
&deps,
|
||||||
|
RemoveFromWatchlistCommand {
|
||||||
|
user_id: Uuid::new_v4(),
|
||||||
|
movie_id: Uuid::new_v4(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
result,
|
||||||
|
Err(domain::errors::DomainError::InfrastructureError(m)) if m == "refused"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ pub fn build_deps(
|
|||||||
goal_command: repos.goal_command.clone(),
|
goal_command: repos.goal_command.clone(),
|
||||||
goal_query: repos.goal_query.clone(),
|
goal_query: repos.goal_query.clone(),
|
||||||
stats: repos.stats.clone(),
|
stats: repos.stats.clone(),
|
||||||
event_publisher: services.event_publisher.clone(),
|
|
||||||
},
|
},
|
||||||
query: GoalQueryDeps {
|
query: GoalQueryDeps {
|
||||||
goal_query: repos.goal_query.clone(),
|
goal_query: repos.goal_query.clone(),
|
||||||
@@ -294,7 +293,6 @@ pub fn build_deps(
|
|||||||
},
|
},
|
||||||
update_profile_fields: UpdateProfileFieldsDeps {
|
update_profile_fields: UpdateProfileFieldsDeps {
|
||||||
profile_fields: repos.profile_fields.clone(),
|
profile_fields: repos.profile_fields.clone(),
|
||||||
event_publisher: services.event_publisher.clone(),
|
|
||||||
},
|
},
|
||||||
get_settings: GetSettingsDeps {
|
get_settings: GetSettingsDeps {
|
||||||
user_settings: repos.user_settings.clone(),
|
user_settings: repos.user_settings.clone(),
|
||||||
@@ -330,7 +328,6 @@ pub fn build_deps(
|
|||||||
},
|
},
|
||||||
remove_from_watchlist: RemoveFromWatchlistDeps {
|
remove_from_watchlist: RemoveFromWatchlistDeps {
|
||||||
watchlist: repos.watchlist.clone(),
|
watchlist: repos.watchlist.clone(),
|
||||||
event_publisher: services.event_publisher.clone(),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wrapup: WrapupGroup {
|
wrapup: WrapupGroup {
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ impl UserRepository for ErroringUserRepo {
|
|||||||
&self,
|
&self,
|
||||||
_: &UserId,
|
_: &UserId,
|
||||||
_: &domain::models::UserProfile,
|
_: &domain::models::UserProfile,
|
||||||
|
_: &[domain::events::DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
panic!()
|
panic!()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub trait UserRepository: Send + Sync {
|
|||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
profile: &crate::models::UserProfile,
|
profile: &crate::models::UserProfile,
|
||||||
|
events: &[crate::events::DomainEvent],
|
||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ pub trait UserProfileFieldsRepository: Send + Sync {
|
|||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
fields: Vec<crate::models::ProfileField>,
|
fields: Vec<crate::models::ProfileField>,
|
||||||
|
events: &[crate::events::DomainEvent],
|
||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,21 @@ use async_trait::async_trait;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
models::Goal,
|
models::Goal,
|
||||||
value_objects::{GoalId, UserId},
|
value_objects::{GoalId, UserId},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait GoalCommand: Send + Sync {
|
pub trait GoalCommand: Send + Sync {
|
||||||
async fn save(&self, goal: &Goal) -> Result<(), DomainError>;
|
async fn save(&self, goal: &Goal, events: &[DomainEvent]) -> Result<(), DomainError>;
|
||||||
async fn update(&self, goal: &Goal) -> Result<(), DomainError>;
|
async fn update(&self, goal: &Goal, events: &[DomainEvent]) -> Result<(), DomainError>;
|
||||||
async fn delete(&self, id: &GoalId, user_id: &UserId) -> Result<(), DomainError>;
|
async fn delete(
|
||||||
|
&self,
|
||||||
|
id: &GoalId,
|
||||||
|
user_id: &UserId,
|
||||||
|
events: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use async_trait::async_trait;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
models::{
|
models::{
|
||||||
WatchlistEntry, WatchlistWithMovie,
|
WatchlistEntry, WatchlistWithMovie,
|
||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
@@ -11,14 +12,21 @@ use crate::{
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait WatchlistRepository: Send + Sync {
|
pub trait WatchlistRepository: Send + Sync {
|
||||||
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError>;
|
async fn add(&self, entry: &WatchlistEntry, events: &[DomainEvent]) -> Result<(), DomainError>;
|
||||||
|
|
||||||
async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError>;
|
async fn remove(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
movie_id: &MovieId,
|
||||||
|
events: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
|
||||||
|
/// `events` are recorded only when an entry was actually removed.
|
||||||
async fn remove_if_present(
|
async fn remove_if_present(
|
||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
movie_id: &MovieId,
|
movie_id: &MovieId,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<bool, DomainError>;
|
) -> Result<bool, DomainError>;
|
||||||
|
|
||||||
async fn get_for_user(
|
async fn get_for_user(
|
||||||
|
|||||||
@@ -74,16 +74,17 @@ pub struct NoopGoalCommand;
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl crate::ports::GoalCommand for NoopGoalCommand {
|
impl crate::ports::GoalCommand for NoopGoalCommand {
|
||||||
async fn save(&self, _: &crate::models::Goal) -> Result<(), DomainError> {
|
async fn save(&self, _: &crate::models::Goal, _: &[DomainEvent]) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn update(&self, _: &crate::models::Goal) -> Result<(), DomainError> {
|
async fn update(&self, _: &crate::models::Goal, _: &[DomainEvent]) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
async fn delete(
|
async fn delete(
|
||||||
&self,
|
&self,
|
||||||
_: &crate::value_objects::GoalId,
|
_: &crate::value_objects::GoalId,
|
||||||
_: &UserId,
|
_: &UserId,
|
||||||
|
_: &[DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,13 +295,18 @@ pub struct PanicWatchlistRepository;
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl WatchlistRepository for PanicWatchlistRepository {
|
impl WatchlistRepository for PanicWatchlistRepository {
|
||||||
async fn add(&self, _: &WatchlistEntry) -> Result<(), DomainError> {
|
async fn add(&self, _: &WatchlistEntry, _: &[DomainEvent]) -> Result<(), DomainError> {
|
||||||
panic!("PanicWatchlistRepository called")
|
panic!("PanicWatchlistRepository called")
|
||||||
}
|
}
|
||||||
async fn remove(&self, _: &UserId, _: &MovieId) -> Result<(), DomainError> {
|
async fn remove(&self, _: &UserId, _: &MovieId, _: &[DomainEvent]) -> Result<(), DomainError> {
|
||||||
panic!("PanicWatchlistRepository called")
|
panic!("PanicWatchlistRepository called")
|
||||||
}
|
}
|
||||||
async fn remove_if_present(&self, _: &UserId, _: &MovieId) -> Result<bool, DomainError> {
|
async fn remove_if_present(
|
||||||
|
&self,
|
||||||
|
_: &UserId,
|
||||||
|
_: &MovieId,
|
||||||
|
_: &[DomainEvent],
|
||||||
|
) -> Result<bool, DomainError> {
|
||||||
panic!("PanicWatchlistRepository called")
|
panic!("PanicWatchlistRepository called")
|
||||||
}
|
}
|
||||||
async fn get_for_user(
|
async fn get_for_user(
|
||||||
@@ -357,6 +362,7 @@ impl UserProfileFieldsRepository for PanicProfileFieldsRepo {
|
|||||||
&self,
|
&self,
|
||||||
_: &UserId,
|
_: &UserId,
|
||||||
_: Vec<crate::models::ProfileField>,
|
_: Vec<crate::models::ProfileField>,
|
||||||
|
_: &[DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
panic!("PanicProfileFieldsRepo called")
|
panic!("PanicProfileFieldsRepo called")
|
||||||
}
|
}
|
||||||
@@ -645,6 +651,7 @@ impl UserRepository for PanicUserRepository {
|
|||||||
&self,
|
&self,
|
||||||
_: &UserId,
|
_: &UserId,
|
||||||
_: &crate::models::UserProfile,
|
_: &crate::models::UserProfile,
|
||||||
|
_: &[DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
panic!("PanicUserRepository called")
|
panic!("PanicUserRepository called")
|
||||||
}
|
}
|
||||||
@@ -675,16 +682,17 @@ pub struct PanicGoalCommand;
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl crate::ports::GoalCommand for PanicGoalCommand {
|
impl crate::ports::GoalCommand for PanicGoalCommand {
|
||||||
async fn save(&self, _: &crate::models::Goal) -> Result<(), DomainError> {
|
async fn save(&self, _: &crate::models::Goal, _: &[DomainEvent]) -> Result<(), DomainError> {
|
||||||
panic!("PanicGoalCommand called")
|
panic!("PanicGoalCommand called")
|
||||||
}
|
}
|
||||||
async fn update(&self, _: &crate::models::Goal) -> Result<(), DomainError> {
|
async fn update(&self, _: &crate::models::Goal, _: &[DomainEvent]) -> Result<(), DomainError> {
|
||||||
panic!("PanicGoalCommand called")
|
panic!("PanicGoalCommand called")
|
||||||
}
|
}
|
||||||
async fn delete(
|
async fn delete(
|
||||||
&self,
|
&self,
|
||||||
_: &crate::value_objects::GoalId,
|
_: &crate::value_objects::GoalId,
|
||||||
_: &crate::value_objects::UserId,
|
_: &crate::value_objects::UserId,
|
||||||
|
_: &[DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
panic!("PanicGoalCommand called")
|
panic!("PanicGoalCommand called")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,14 @@ use crate::{
|
|||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::{
|
models::{
|
||||||
Movie, Review, WatchlistEntry, WatchlistWithMovie,
|
Goal, Movie, ProfileField, Review, WatchlistEntry, WatchlistWithMovie,
|
||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
},
|
},
|
||||||
ports::{EventPublisher, MovieCommand, ReviewRepository, WatchlistRepository},
|
ports::{
|
||||||
value_objects::{MovieId, ReviewId, UserId},
|
EventPublisher, GoalCommand, MovieCommand, ReviewRepository, UserProfileFieldsRepository,
|
||||||
|
WatchlistRepository,
|
||||||
|
},
|
||||||
|
value_objects::{GoalId, MovieId, ReviewId, UserId},
|
||||||
};
|
};
|
||||||
|
|
||||||
fn refused() -> DomainError {
|
fn refused() -> DomainError {
|
||||||
@@ -54,13 +57,18 @@ pub struct RefusingWatchlistRepository;
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl WatchlistRepository for RefusingWatchlistRepository {
|
impl WatchlistRepository for RefusingWatchlistRepository {
|
||||||
async fn add(&self, _: &WatchlistEntry) -> Result<(), DomainError> {
|
async fn add(&self, _: &WatchlistEntry, _: &[DomainEvent]) -> Result<(), DomainError> {
|
||||||
Err(refused())
|
Err(refused())
|
||||||
}
|
}
|
||||||
async fn remove(&self, _: &UserId, _: &MovieId) -> Result<(), DomainError> {
|
async fn remove(&self, _: &UserId, _: &MovieId, _: &[DomainEvent]) -> Result<(), DomainError> {
|
||||||
Err(refused())
|
Err(refused())
|
||||||
}
|
}
|
||||||
async fn remove_if_present(&self, _: &UserId, _: &MovieId) -> Result<bool, DomainError> {
|
async fn remove_if_present(
|
||||||
|
&self,
|
||||||
|
_: &UserId,
|
||||||
|
_: &MovieId,
|
||||||
|
_: &[DomainEvent],
|
||||||
|
) -> Result<bool, DomainError> {
|
||||||
Err(refused())
|
Err(refused())
|
||||||
}
|
}
|
||||||
async fn get_for_user(
|
async fn get_for_user(
|
||||||
@@ -75,6 +83,38 @@ impl WatchlistRepository for RefusingWatchlistRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct RefusingGoalCommand;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl GoalCommand for RefusingGoalCommand {
|
||||||
|
async fn save(&self, _: &Goal, _: &[DomainEvent]) -> Result<(), DomainError> {
|
||||||
|
Err(refused())
|
||||||
|
}
|
||||||
|
async fn update(&self, _: &Goal, _: &[DomainEvent]) -> Result<(), DomainError> {
|
||||||
|
Err(refused())
|
||||||
|
}
|
||||||
|
async fn delete(&self, _: &GoalId, _: &UserId, _: &[DomainEvent]) -> Result<(), DomainError> {
|
||||||
|
Err(refused())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RefusingProfileFieldsRepository;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserProfileFieldsRepository for RefusingProfileFieldsRepository {
|
||||||
|
async fn get_fields(&self, _: &UserId) -> Result<Vec<ProfileField>, DomainError> {
|
||||||
|
Err(refused())
|
||||||
|
}
|
||||||
|
async fn set_fields(
|
||||||
|
&self,
|
||||||
|
_: &UserId,
|
||||||
|
_: Vec<ProfileField>,
|
||||||
|
_: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
Err(refused())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct RefusingEventPublisher;
|
pub struct RefusingEventPublisher;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use async_trait::async_trait;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
models::Goal,
|
models::Goal,
|
||||||
ports::{GoalCommand, GoalQuery},
|
ports::{GoalCommand, GoalQuery},
|
||||||
value_objects::{GoalId, UserId},
|
value_objects::{GoalId, UserId},
|
||||||
@@ -12,7 +13,7 @@ use super::InMemoryStore;
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl GoalCommand for InMemoryStore {
|
impl GoalCommand for InMemoryStore {
|
||||||
/// `goals` is UNIQUE(user_id, year).
|
/// `goals` is UNIQUE(user_id, year).
|
||||||
async fn save(&self, goal: &Goal) -> Result<(), DomainError> {
|
async fn save(&self, goal: &Goal, events: &[DomainEvent]) -> Result<(), DomainError> {
|
||||||
let mut goals = self.goals.write().unwrap();
|
let mut goals = self.goals.write().unwrap();
|
||||||
let duplicate = goals.values().any(|g| {
|
let duplicate = goals.values().any(|g| {
|
||||||
g.id() == goal.id() || (g.user_id() == goal.user_id() && g.year() == goal.year())
|
g.id() == goal.id() || (g.user_id() == goal.user_id() && g.year() == goal.year())
|
||||||
@@ -23,14 +24,18 @@ impl GoalCommand for InMemoryStore {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
goals.insert(goal.id().value(), goal.clone());
|
goals.insert(goal.id().value(), goal.clone());
|
||||||
|
drop(goals);
|
||||||
|
self.record_events(events);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn update(&self, goal: &Goal) -> Result<(), DomainError> {
|
async fn update(&self, goal: &Goal, events: &[DomainEvent]) -> Result<(), DomainError> {
|
||||||
let mut store = self.goals.write().unwrap();
|
let mut store = self.goals.write().unwrap();
|
||||||
match store.entry(goal.id().value()) {
|
match store.entry(goal.id().value()) {
|
||||||
std::collections::hash_map::Entry::Occupied(mut e) => {
|
std::collections::hash_map::Entry::Occupied(mut e) => {
|
||||||
e.insert(goal.clone());
|
e.insert(goal.clone());
|
||||||
|
drop(store);
|
||||||
|
self.record_events(events);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
std::collections::hash_map::Entry::Vacant(_) => {
|
std::collections::hash_map::Entry::Vacant(_) => {
|
||||||
@@ -39,8 +44,14 @@ impl GoalCommand for InMemoryStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, id: &GoalId, _user_id: &UserId) -> Result<(), DomainError> {
|
async fn delete(
|
||||||
|
&self,
|
||||||
|
id: &GoalId,
|
||||||
|
_user_id: &UserId,
|
||||||
|
events: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
self.goals.write().unwrap().remove(&id.value());
|
self.goals.write().unwrap().remove(&id.value());
|
||||||
|
self.record_events(events);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,11 +84,14 @@ impl UserRepository for InMemoryStore {
|
|||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
profile: &crate::models::UserProfile,
|
profile: &crate::models::UserProfile,
|
||||||
|
events: &[crate::events::DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let mut store = self.users.write().unwrap();
|
let mut store = self.users.write().unwrap();
|
||||||
if let Some(user) = store.get_mut(&user_id.value()) {
|
if let Some(user) = store.get_mut(&user_id.value()) {
|
||||||
user.update_profile(profile.clone());
|
user.update_profile(profile.clone());
|
||||||
}
|
}
|
||||||
|
drop(store);
|
||||||
|
self.record_events(events);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,11 +107,13 @@ impl UserProfileFieldsRepository for InMemoryStore {
|
|||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
fields: Vec<ProfileField>,
|
fields: Vec<ProfileField>,
|
||||||
|
events: &[crate::events::DomainEvent],
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
self.profile_fields
|
self.profile_fields
|
||||||
.write()
|
.write()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.insert(user_id.value(), fields);
|
.insert(user_id.value(), fields);
|
||||||
|
self.record_events(events);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
models::{
|
models::{
|
||||||
RemoteWatchlistEntry, WatchlistEntry, WatchlistWithMovie,
|
RemoteWatchlistEntry, WatchlistEntry, WatchlistWithMovie,
|
||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
@@ -16,23 +17,30 @@ use super::InMemoryStore;
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl WatchlistRepository for InMemoryStore {
|
impl WatchlistRepository for InMemoryStore {
|
||||||
/// `INSERT OR IGNORE` on UNIQUE(user_id, movie_id): adding twice is a no-op.
|
/// `INSERT OR IGNORE` on UNIQUE(user_id, movie_id): adding twice is a no-op.
|
||||||
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> {
|
async fn add(&self, entry: &WatchlistEntry, events: &[DomainEvent]) -> Result<(), DomainError> {
|
||||||
let key = (entry.user_id.value(), entry.movie_id.value());
|
let key = (entry.user_id.value(), entry.movie_id.value());
|
||||||
self.watchlist
|
self.watchlist
|
||||||
.write()
|
.write()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.entry(key)
|
.entry(key)
|
||||||
.or_insert_with(|| entry.clone());
|
.or_insert_with(|| entry.clone());
|
||||||
|
self.record_events(events);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> {
|
async fn remove(
|
||||||
|
&self,
|
||||||
|
user_id: &UserId,
|
||||||
|
movie_id: &MovieId,
|
||||||
|
events: &[DomainEvent],
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
let key = (user_id.value(), movie_id.value());
|
let key = (user_id.value(), movie_id.value());
|
||||||
self.watchlist
|
self.watchlist
|
||||||
.write()
|
.write()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.remove(&key)
|
.remove(&key)
|
||||||
.ok_or_else(|| DomainError::NotFound("watchlist entry".into()))?;
|
.ok_or_else(|| DomainError::NotFound("watchlist entry".into()))?;
|
||||||
|
self.record_events(events);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,9 +48,14 @@ impl WatchlistRepository for InMemoryStore {
|
|||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
movie_id: &MovieId,
|
movie_id: &MovieId,
|
||||||
|
events: &[DomainEvent],
|
||||||
) -> Result<bool, DomainError> {
|
) -> Result<bool, DomainError> {
|
||||||
let key = (user_id.value(), movie_id.value());
|
let key = (user_id.value(), movie_id.value());
|
||||||
Ok(self.watchlist.write().unwrap().remove(&key).is_some())
|
let removed = self.watchlist.write().unwrap().remove(&key).is_some();
|
||||||
|
if removed {
|
||||||
|
self.record_events(events);
|
||||||
|
}
|
||||||
|
Ok(removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_for_user(
|
async fn get_for_user(
|
||||||
|
|||||||
@@ -73,10 +73,13 @@ async fn seed_user_with_dependents(store: &Arc<InMemoryStore>) -> (UserId, Movie
|
|||||||
store.upsert_movie(&movie).await.unwrap();
|
store.upsert_movie(&movie).await.unwrap();
|
||||||
|
|
||||||
store
|
store
|
||||||
.add(&WatchlistEntry::new(user_id.clone(), movie.id().clone()))
|
.add(
|
||||||
|
&WatchlistEntry::new(user_id.clone(), movie.id().clone()),
|
||||||
|
&[],
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
GoalCommand::save(&**store, &test_goal(&user_id, 2024, 50))
|
GoalCommand::save(&**store, &test_goal(&user_id, 2024, 50), &[])
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
UserSettingsRepository::save(&**store, &UserSettings::new(user_id.clone()))
|
UserSettingsRepository::save(&**store, &UserSettings::new(user_id.clone()))
|
||||||
@@ -89,6 +92,7 @@ async fn seed_user_with_dependents(store: &Arc<InMemoryStore>) -> (UserId, Movie
|
|||||||
name: "Site".into(),
|
name: "Site".into(),
|
||||||
value: "https://example.com".into(),
|
value: "https://example.com".into(),
|
||||||
}],
|
}],
|
||||||
|
&[],
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -197,10 +201,10 @@ async fn deleting_a_user_leaves_other_users_rows_alone() {
|
|||||||
let bystander = test_user("carol");
|
let bystander = test_user("carol");
|
||||||
UserRepository::save(&*store, &bystander).await.unwrap();
|
UserRepository::save(&*store, &bystander).await.unwrap();
|
||||||
store
|
store
|
||||||
.add(&WatchlistEntry::new(
|
.add(
|
||||||
bystander.id().clone(),
|
&WatchlistEntry::new(bystander.id().clone(), movie.id().clone()),
|
||||||
movie.id().clone(),
|
&[],
|
||||||
))
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -219,7 +223,10 @@ async fn seed_movie_with_dependents(
|
|||||||
let movie = test_movie("Heat");
|
let movie = test_movie("Heat");
|
||||||
store.upsert_movie(&movie).await.unwrap();
|
store.upsert_movie(&movie).await.unwrap();
|
||||||
store
|
store
|
||||||
.add(&WatchlistEntry::new(user.id().clone(), movie.id().clone()))
|
.add(
|
||||||
|
&WatchlistEntry::new(user.id().clone(), movie.id().clone()),
|
||||||
|
&[],
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store.upsert(&movie_profile(movie.id())).await.unwrap();
|
store.upsert(&movie_profile(movie.id())).await.unwrap();
|
||||||
@@ -327,11 +334,11 @@ async fn saving_a_user_with_a_taken_email_is_a_validation_error() {
|
|||||||
async fn saving_a_second_goal_for_the_same_user_and_year_is_an_infrastructure_error() {
|
async fn saving_a_second_goal_for_the_same_user_and_year_is_an_infrastructure_error() {
|
||||||
let store = InMemoryStore::new();
|
let store = InMemoryStore::new();
|
||||||
let user_id = UserId::generate();
|
let user_id = UserId::generate();
|
||||||
GoalCommand::save(&*store, &test_goal(&user_id, 2024, 10))
|
GoalCommand::save(&*store, &test_goal(&user_id, 2024, 10), &[])
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let result = GoalCommand::save(&*store, &test_goal(&user_id, 2024, 20)).await;
|
let result = GoalCommand::save(&*store, &test_goal(&user_id, 2024, 20), &[]).await;
|
||||||
|
|
||||||
assert!(matches!(result, Err(DomainError::InfrastructureError(_))));
|
assert!(matches!(result, Err(DomainError::InfrastructureError(_))));
|
||||||
assert_eq!(store.goal_count(), 1);
|
assert_eq!(store.goal_count(), 1);
|
||||||
@@ -344,11 +351,11 @@ async fn adding_the_same_movie_to_a_watchlist_twice_keeps_one_entry() {
|
|||||||
let movie_id = MovieId::generate();
|
let movie_id = MovieId::generate();
|
||||||
|
|
||||||
store
|
store
|
||||||
.add(&WatchlistEntry::new(user_id.clone(), movie_id.clone()))
|
.add(&WatchlistEntry::new(user_id.clone(), movie_id.clone()), &[])
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
store
|
store
|
||||||
.add(&WatchlistEntry::new(user_id, movie_id))
|
.add(&WatchlistEntry::new(user_id, movie_id), &[])
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user