refactor/tighten-architecture #26
@@ -24,7 +24,7 @@ use domain::{
|
||||
PosterFetcherClient, ReviewRepository, SearchCommand, SearchPort, StatsRepository,
|
||||
UserRepository, WatchlistRepository,
|
||||
},
|
||||
testing::InMemoryUserRepository,
|
||||
testing::InMemoryStore,
|
||||
value_objects::{
|
||||
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl, ReleaseYear,
|
||||
ReviewId, UserId,
|
||||
@@ -1131,11 +1131,11 @@ fn admin_id() -> UserId {
|
||||
#[tokio::test]
|
||||
async fn admin_api_user_allows_an_admin_user() {
|
||||
let id = admin_id();
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
user_repo.store.lock().unwrap().insert(
|
||||
id.value(),
|
||||
domain::testing::test_user_with_role(id.clone(), domain::models::UserRole::Admin),
|
||||
);
|
||||
let user_repo = InMemoryStore::new();
|
||||
user_repo.seed_user(domain::testing::test_user_with_role(
|
||||
id.clone(),
|
||||
domain::models::UserRole::Admin,
|
||||
));
|
||||
let app = router_admin_api(test_app_state_with_user(
|
||||
user_repo as _,
|
||||
Arc::new(AcceptingAuth(id)),
|
||||
@@ -1156,11 +1156,11 @@ async fn admin_api_user_allows_an_admin_user() {
|
||||
#[tokio::test]
|
||||
async fn admin_api_user_rejects_a_non_admin_user() {
|
||||
let id = admin_id();
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
user_repo.store.lock().unwrap().insert(
|
||||
id.value(),
|
||||
domain::testing::test_user_with_role(id.clone(), domain::models::UserRole::Standard),
|
||||
);
|
||||
let user_repo = InMemoryStore::new();
|
||||
user_repo.seed_user(domain::testing::test_user_with_role(
|
||||
id.clone(),
|
||||
domain::models::UserRole::Standard,
|
||||
));
|
||||
let app = router_admin_api(test_app_state_with_user(
|
||||
user_repo as _,
|
||||
Arc::new(AcceptingAuth(id)),
|
||||
@@ -1181,7 +1181,7 @@ async fn admin_api_user_rejects_a_non_admin_user() {
|
||||
#[tokio::test]
|
||||
async fn admin_api_user_rejects_an_unknown_user() {
|
||||
let id = admin_id();
|
||||
let user_repo = InMemoryUserRepository::new(); // empty — id is not in the store
|
||||
let user_repo = InMemoryStore::new(); // empty — id is not in the store
|
||||
let app = router_admin_api(test_app_state_with_user(
|
||||
user_repo as _,
|
||||
Arc::new(AcceptingAuth(id)),
|
||||
@@ -1222,11 +1222,11 @@ async fn admin_api_user_surfaces_a_repository_error_as_500() {
|
||||
#[tokio::test]
|
||||
async fn admin_user_allows_an_admin_user() {
|
||||
let id = admin_id();
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
user_repo.store.lock().unwrap().insert(
|
||||
id.value(),
|
||||
domain::testing::test_user_with_role(id.clone(), domain::models::UserRole::Admin),
|
||||
);
|
||||
let user_repo = InMemoryStore::new();
|
||||
user_repo.seed_user(domain::testing::test_user_with_role(
|
||||
id.clone(),
|
||||
domain::models::UserRole::Admin,
|
||||
));
|
||||
let app = router_admin_cookie(test_app_state_with_user(
|
||||
user_repo as _,
|
||||
Arc::new(AcceptingAuth(id)),
|
||||
@@ -1247,11 +1247,11 @@ async fn admin_user_allows_an_admin_user() {
|
||||
#[tokio::test]
|
||||
async fn admin_user_rejects_a_non_admin_user() {
|
||||
let id = admin_id();
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
user_repo.store.lock().unwrap().insert(
|
||||
id.value(),
|
||||
domain::testing::test_user_with_role(id.clone(), domain::models::UserRole::Standard),
|
||||
);
|
||||
let user_repo = InMemoryStore::new();
|
||||
user_repo.seed_user(domain::testing::test_user_with_role(
|
||||
id.clone(),
|
||||
domain::models::UserRole::Standard,
|
||||
));
|
||||
let app = router_admin_cookie(test_app_state_with_user(
|
||||
user_repo as _,
|
||||
Arc::new(AcceptingAuth(id)),
|
||||
@@ -1272,7 +1272,7 @@ async fn admin_user_rejects_a_non_admin_user() {
|
||||
#[tokio::test]
|
||||
async fn admin_user_rejects_an_unknown_user() {
|
||||
let id = admin_id();
|
||||
let user_repo = InMemoryUserRepository::new(); // empty — id is not in the store
|
||||
let user_repo = InMemoryStore::new(); // empty — id is not in the store
|
||||
let app = router_admin_cookie(test_app_state_with_user(
|
||||
user_repo as _,
|
||||
Arc::new(AcceptingAuth(id)),
|
||||
|
||||
317
crates/adapters/sqlite/tests/cascade_test.rs
Normal file
317
crates/adapters/sqlite/tests/cascade_test.rs
Normal file
@@ -0,0 +1,317 @@
|
||||
//! Real-database counterpart of the `InMemoryStore` cascade tests in the domain
|
||||
//! crate. Both iterate the same table lists (`domain::testing::*_CASCADE_TABLES`),
|
||||
//! so the fake and the schema cannot drift apart unnoticed.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::str::FromStr;
|
||||
|
||||
use chrono::Utc;
|
||||
use domain::models::{
|
||||
CastMember, CrewMember, Genre, Keyword, Movie, MovieProfile, ProfileField, UserSettings,
|
||||
WatchEvent, WatchEventSource, WatchlistEntry, WebhookToken,
|
||||
};
|
||||
use domain::ports::{
|
||||
FollowCommand, UserProfileFieldsRepository, WatchEventCommand, WebhookTokenRepository,
|
||||
};
|
||||
use domain::testing::{
|
||||
MOVIE_CASCADE_TABLES, USER_CASCADE_TABLES, USER_CASCADE_TABLES_OUTSIDE_STORE, test_goal,
|
||||
test_movie, test_review, test_user, test_watched_at,
|
||||
};
|
||||
use domain::value_objects::{FollowStatus, MovieId, UserId};
|
||||
use sqlite::{
|
||||
SqliteProfileFieldsRepository, SqliteSocialRepository, SqliteWatchEventRepository,
|
||||
SqliteWebhookTokenRepository,
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
|
||||
const REMOTE_ACTOR: &str = "https://remote.example/users/ann";
|
||||
|
||||
/// Same connect options `sqlite::wire` builds from a `sqlite:` URL (sqlx turns
|
||||
/// `foreign_keys` on by default), against a private in-memory database.
|
||||
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();
|
||||
let (foreign_keys,): (i64,) = sqlx::query_as("PRAGMA foreign_keys")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
foreign_keys, 1,
|
||||
"tests must run with FKs enforced like production"
|
||||
);
|
||||
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()
|
||||
}
|
||||
|
||||
async fn tables_cascading_from(pool: &SqlitePool, parent: &str) -> BTreeSet<String> {
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT m.name FROM sqlite_master m, pragma_foreign_key_list(m.name) f
|
||||
WHERE m.type = 'table' AND f.\"table\" = ? AND f.on_delete = 'CASCADE'",
|
||||
)
|
||||
.bind(parent)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn names(lists: &[&[&str]]) -> BTreeSet<String> {
|
||||
lists
|
||||
.iter()
|
||||
.flat_map(|l| l.iter())
|
||||
.map(|t| t.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn movie_profile(movie_id: &MovieId) -> MovieProfile {
|
||||
MovieProfile {
|
||||
movie_id: movie_id.clone(),
|
||||
tmdb_id: 603,
|
||||
imdb_id: None,
|
||||
overview: None,
|
||||
tagline: None,
|
||||
runtime_minutes: None,
|
||||
budget_usd: None,
|
||||
revenue_usd: None,
|
||||
vote_average: None,
|
||||
vote_count: None,
|
||||
original_language: None,
|
||||
collection_name: None,
|
||||
genres: vec![Genre {
|
||||
tmdb_id: 1,
|
||||
name: "Sci-Fi".into(),
|
||||
}],
|
||||
keywords: vec![Keyword {
|
||||
tmdb_id: 2,
|
||||
name: "simulation".into(),
|
||||
}],
|
||||
cast: vec![CastMember {
|
||||
tmdb_person_id: 3,
|
||||
name: "Actor".into(),
|
||||
character: "Role".into(),
|
||||
billing_order: 0,
|
||||
profile_path: None,
|
||||
}],
|
||||
crew: vec![CrewMember {
|
||||
tmdb_person_id: 4,
|
||||
name: "Director".into(),
|
||||
job: "Director".into(),
|
||||
department: "Directing".into(),
|
||||
profile_path: None,
|
||||
}],
|
||||
enriched_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn seed_user_with_dependents(
|
||||
pool: &SqlitePool,
|
||||
parts: &sqlite::SqliteWireOutput,
|
||||
) -> (UserId, Movie) {
|
||||
let user = test_user("alice");
|
||||
let user_id = user.id().clone();
|
||||
let movie = test_movie("Heat");
|
||||
parts.user.save(&user).await.unwrap();
|
||||
parts.movie_command.upsert_movie(&movie).await.unwrap();
|
||||
|
||||
parts
|
||||
.watchlist
|
||||
.add(&WatchlistEntry::new(user_id.clone(), movie.id().clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
parts
|
||||
.goal_command
|
||||
.save(&test_goal(&user_id, 2024, 50))
|
||||
.await
|
||||
.unwrap();
|
||||
parts
|
||||
.user_settings
|
||||
.save(&UserSettings::new(user_id.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
SqliteProfileFieldsRepository::new(pool.clone())
|
||||
.set_fields(
|
||||
&user_id,
|
||||
vec![ProfileField {
|
||||
name: "Site".into(),
|
||||
value: "https://example.com".into(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
SqliteWebhookTokenRepository::new(pool.clone())
|
||||
.save(&WebhookToken::new(
|
||||
user_id.clone(),
|
||||
"hash".into(),
|
||||
WatchEventSource::Jellyfin,
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
SqliteWatchEventRepository::new(pool.clone())
|
||||
.save(&WatchEvent::new(
|
||||
user_id.clone(),
|
||||
"Heat".into(),
|
||||
Some(1995),
|
||||
None,
|
||||
WatchEventSource::Jellyfin,
|
||||
test_watched_at(),
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
let social = SqliteSocialRepository::new(
|
||||
pool.clone(),
|
||||
domain::value_objects::InstanceIdentity::new("https://md.example"),
|
||||
);
|
||||
social
|
||||
.add_follow(user_id.value(), REMOTE_ACTOR, FollowStatus::Accepted)
|
||||
.await
|
||||
.unwrap();
|
||||
social
|
||||
.add_follower(user_id.value(), REMOTE_ACTOR, FollowStatus::Accepted)
|
||||
.await
|
||||
.unwrap();
|
||||
// No repository in this crate owns these two tables (federation crate).
|
||||
sqlx::query("INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) VALUES (?, ?, 'now')")
|
||||
.bind(user_id.value().to_string())
|
||||
.bind(REMOTE_ACTOR)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
sqlx::query("INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at) VALUES (?, 'pub', 'priv', 'now')")
|
||||
.bind(user_id.value().to_string())
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
parts
|
||||
.review
|
||||
.save_review(&test_review(movie.id(), &user_id, 4))
|
||||
.await
|
||||
.unwrap();
|
||||
(user_id, movie)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_cascade_lists_match_the_foreign_keys_declared_by_the_migrations() {
|
||||
let pool = migrated_pool().await;
|
||||
|
||||
assert_eq!(
|
||||
tables_cascading_from(&pool, "users").await,
|
||||
names(&[USER_CASCADE_TABLES, USER_CASCADE_TABLES_OUTSIDE_STORE]),
|
||||
);
|
||||
assert_eq!(
|
||||
tables_cascading_from(&pool, "movies").await,
|
||||
names(&[MOVIE_CASCADE_TABLES]),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_user_cascades_to_every_table_that_hangs_off_a_user() {
|
||||
let pool = migrated_pool().await;
|
||||
let parts = sqlite::wire_pool(pool.clone()).await.unwrap();
|
||||
let (user_id, _) = seed_user_with_dependents(&pool, &parts).await;
|
||||
for table in USER_CASCADE_TABLES
|
||||
.iter()
|
||||
.chain(USER_CASCADE_TABLES_OUTSIDE_STORE)
|
||||
{
|
||||
assert!(count(&pool, table).await > 0, "{table} should be seeded");
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM users WHERE id = ?")
|
||||
.bind(user_id.value().to_string())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for table in USER_CASCADE_TABLES
|
||||
.iter()
|
||||
.chain(USER_CASCADE_TABLES_OUTSIDE_STORE)
|
||||
{
|
||||
assert_eq!(count(&pool, table).await, 0, "{table} should cascade");
|
||||
}
|
||||
assert_eq!(count(&pool, "reviews").await, 1, "reviews have no user FK");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_movie_cascades_to_watchlist_entries_and_every_enrichment_table() {
|
||||
let pool = migrated_pool().await;
|
||||
let parts = sqlite::wire_pool(pool.clone()).await.unwrap();
|
||||
let user = test_user("alice");
|
||||
let movie = test_movie("Heat");
|
||||
parts.user.save(&user).await.unwrap();
|
||||
parts.movie_command.upsert_movie(&movie).await.unwrap();
|
||||
parts
|
||||
.watchlist
|
||||
.add(&WatchlistEntry::new(user.id().clone(), movie.id().clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
parts
|
||||
.movie_profile
|
||||
.upsert(&movie_profile(movie.id()))
|
||||
.await
|
||||
.unwrap();
|
||||
SqliteWatchEventRepository::new(pool.clone())
|
||||
.save(&WatchEvent::new(
|
||||
user.id().clone(),
|
||||
"Heat".into(),
|
||||
Some(1995),
|
||||
None,
|
||||
WatchEventSource::Plex,
|
||||
test_watched_at(),
|
||||
Some(movie.id().clone()),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
for table in MOVIE_CASCADE_TABLES {
|
||||
assert!(count(&pool, table).await > 0, "{table} should be seeded");
|
||||
}
|
||||
|
||||
parts.movie_command.delete_movie(movie.id()).await.unwrap();
|
||||
|
||||
for table in MOVIE_CASCADE_TABLES {
|
||||
assert_eq!(count(&pool, table).await, 0, "{table} should cascade");
|
||||
}
|
||||
let (movie_id,): (Option<String>,) = sqlx::query_as("SELECT movie_id FROM watch_events")
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(movie_id, None, "watch events are detached, not deleted");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_movie_that_has_reviews_is_refused_because_reviews_do_not_cascade() {
|
||||
let pool = migrated_pool().await;
|
||||
let parts = sqlite::wire_pool(pool.clone()).await.unwrap();
|
||||
let user = test_user("alice");
|
||||
let movie = test_movie("Heat");
|
||||
parts.user.save(&user).await.unwrap();
|
||||
parts.movie_command.upsert_movie(&movie).await.unwrap();
|
||||
parts
|
||||
.review
|
||||
.save_review(&test_review(movie.id(), user.id(), 5))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = parts.movie_command.delete_movie(movie.id()).await;
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(domain::errors::DomainError::InfrastructureError(_))
|
||||
));
|
||||
assert_eq!(count(&pool, "movies").await, 1);
|
||||
assert_eq!(count(&pool, "reviews").await, 1);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::test_support::{login_command, register_command};
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::InMemoryUserRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
|
||||
use crate::{
|
||||
auth::{
|
||||
@@ -31,7 +31,7 @@ async fn setup_user(b: &TestContextBuilder, email: &str, password: &str) {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_login_valid_credentials_returns_token() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let users = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
setup_user(&b, "carol@example.com", "secret123").await;
|
||||
|
||||
@@ -53,7 +53,7 @@ async fn test_login_valid_credentials_returns_token() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_login_wrong_password_fails() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let users = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
setup_user(&b, "dave@example.com", "correct_password").await;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::test_support::{login_command, register_command};
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::InMemoryUserRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
|
||||
use crate::{
|
||||
auth::{
|
||||
@@ -13,7 +13,7 @@ use crate::{
|
||||
|
||||
#[tokio::test]
|
||||
async fn logout_revokes_refresh_token() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let users = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
|
||||
let reg_deps = RegisterDeps {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::test_support::{login_command, register_command};
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::InMemoryUserRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
|
||||
use crate::{
|
||||
auth::{
|
||||
@@ -38,7 +38,7 @@ async fn login_user(b: &TestContextBuilder) -> login::LoginResult {
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_returns_new_tokens() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let users = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
let login_result = login_user(&b).await;
|
||||
|
||||
@@ -58,7 +58,7 @@ async fn refresh_returns_new_tokens() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_rotates_token_old_one_invalid() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let users = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
let login_result = login_user(&b).await;
|
||||
let old_token = login_result.refresh_token.clone();
|
||||
@@ -76,7 +76,7 @@ async fn refresh_rotates_token_old_one_invalid() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_with_new_token_works() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let users = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
let login_result = login_user(&b).await;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::test_support::register_command;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::UserRepository;
|
||||
use domain::testing::InMemoryUserRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
use domain::value_objects::Email;
|
||||
|
||||
use crate::{
|
||||
@@ -16,7 +16,7 @@ fn cmd(email: &str) -> RegisterCommand {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_creates_user() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let users = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
let deps = RegisterDeps {
|
||||
user: b.user_repo.clone(),
|
||||
@@ -36,7 +36,7 @@ async fn test_register_creates_user() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_register_duplicate_email_fails() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let users = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
let deps = RegisterDeps {
|
||||
user: b.user_repo.clone(),
|
||||
|
||||
@@ -3,9 +3,7 @@ use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
ports::{MovieCommand, MovieQuery, ReviewRepository},
|
||||
testing::{
|
||||
FakeDiaryQuery, InMemoryMovieRepository, InMemoryReviewRepository, NoopEventPublisher,
|
||||
},
|
||||
testing::{FakeDiaryQuery, InMemoryStore, NoopEventPublisher},
|
||||
value_objects::{MovieId, UserId},
|
||||
};
|
||||
|
||||
@@ -15,8 +13,8 @@ use crate::{
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_review_removes_it() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let movies = InMemoryStore::new();
|
||||
let reviews = Arc::clone(&movies);
|
||||
let diary = FakeDiaryQuery::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
|
||||
@@ -45,7 +43,7 @@ async fn test_delete_review_removes_it() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(reviews.count(), 0, "review should be deleted");
|
||||
assert_eq!(reviews.review_count(), 0, "review should be deleted");
|
||||
assert!(
|
||||
movies.get_movie_by_id(movie.id()).await.unwrap().is_none(),
|
||||
"movie should be deleted when no reviews remain"
|
||||
@@ -54,9 +52,9 @@ async fn test_delete_review_removes_it() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_review_wrong_user_is_unauthorized() {
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let reviews = InMemoryStore::new();
|
||||
let diary = FakeDiaryQuery::new();
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let movies = Arc::clone(&reviews);
|
||||
let events = NoopEventPublisher::new();
|
||||
|
||||
let movie_id = MovieId::from_uuid(uuid::Uuid::new_v4());
|
||||
@@ -83,5 +81,5 @@ async fn test_delete_review_wrong_user_is_unauthorized() {
|
||||
.await;
|
||||
|
||||
assert!(result.is_err(), "wrong user should not be able to delete");
|
||||
assert_eq!(reviews.count(), 1, "review should still exist");
|
||||
assert_eq!(reviews.review_count(), 1, "review should still exist");
|
||||
}
|
||||
|
||||
@@ -4,19 +4,19 @@ use std::sync::Arc;
|
||||
use domain::{
|
||||
models::ReviewEdit,
|
||||
ports::ReviewRepository,
|
||||
testing::{InMemoryReviewRepository, NoopEventPublisher, test_review},
|
||||
testing::{InMemoryStore, NoopEventPublisher, test_review},
|
||||
value_objects::{Comment, MovieId, ReviewId, UserId, WatchMedium},
|
||||
};
|
||||
|
||||
use crate::diary::{commands::EditReviewCommand, deps::EditReviewDeps, edit_review};
|
||||
|
||||
async fn setup() -> (
|
||||
Arc<InMemoryReviewRepository>,
|
||||
Arc<InMemoryStore>,
|
||||
Arc<NoopEventPublisher>,
|
||||
ReviewId,
|
||||
UserId,
|
||||
) {
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let reviews = InMemoryStore::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let user_id = UserId::generate();
|
||||
let review = test_review(&MovieId::generate(), &user_id, 3).apply_edit(ReviewEdit {
|
||||
@@ -29,10 +29,7 @@ async fn setup() -> (
|
||||
(reviews, events, review_id, user_id)
|
||||
}
|
||||
|
||||
fn deps(
|
||||
reviews: &Arc<InMemoryReviewRepository>,
|
||||
events: &Arc<NoopEventPublisher>,
|
||||
) -> EditReviewDeps {
|
||||
fn deps(reviews: &Arc<InMemoryStore>, events: &Arc<NoopEventPublisher>) -> EditReviewDeps {
|
||||
EditReviewDeps {
|
||||
review: Arc::clone(reviews) as _,
|
||||
event_publisher: Arc::clone(events) as _,
|
||||
@@ -62,7 +59,7 @@ async fn edit_own_review_updates_rating() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_nonexistent_review_returns_not_found() {
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let reviews = InMemoryStore::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
|
||||
let err = edit_review::execute(&deps(&reviews, &events), edit_review_command(5))
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::errors::DomainError;
|
||||
use domain::testing::InMemorySocialRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
use domain::value_objects::{FollowRelation, SocialActor, SocialIdentity, UserId};
|
||||
|
||||
use crate::{
|
||||
@@ -13,7 +13,7 @@ use crate::{
|
||||
fn default_deps() -> GetActivityFeedDeps {
|
||||
GetActivityFeedDeps {
|
||||
diary: domain::testing::FakeDiaryQuery::new() as _,
|
||||
social_query: InMemorySocialRepository::new() as _,
|
||||
social_query: InMemoryStore::new() as _,
|
||||
config: TestContextBuilder::new().config,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use uuid::Uuid;
|
||||
|
||||
use domain::{
|
||||
ports::MovieCommand,
|
||||
testing::{FakeDiaryQuery, InMemoryMovieProfileRepository, InMemoryMovieRepository},
|
||||
testing::{FakeDiaryQuery, InMemoryStore},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@@ -16,9 +16,9 @@ use crate::{
|
||||
#[tokio::test]
|
||||
async fn fails_when_movie_not_found() {
|
||||
let deps = GetMovieSocialPageDeps {
|
||||
movie_query: InMemoryMovieRepository::new(),
|
||||
movie_query: InMemoryStore::new(),
|
||||
diary: FakeDiaryQuery::new() as _,
|
||||
movie_profile: InMemoryMovieProfileRepository::new(),
|
||||
movie_profile: InMemoryStore::new(),
|
||||
};
|
||||
|
||||
let result = get_movie_social_page::execute(
|
||||
@@ -36,7 +36,7 @@ async fn fails_when_movie_not_found() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_movie_social_page() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let movies = InMemoryStore::new();
|
||||
|
||||
let movie = test_movie_with_year("Social Movie", 2024);
|
||||
let movie_uuid = movie.id().value();
|
||||
@@ -45,7 +45,7 @@ async fn returns_movie_social_page() {
|
||||
let deps = GetMovieSocialPageDeps {
|
||||
movie_query: Arc::clone(&movies) as _,
|
||||
diary: FakeDiaryQuery::new() as _,
|
||||
movie_profile: InMemoryMovieProfileRepository::new(),
|
||||
movie_profile: InMemoryStore::new(),
|
||||
};
|
||||
|
||||
let result = get_movie_social_page::execute(
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
use domain::ports::MovieCommand;
|
||||
|
||||
use domain::testing::{InMemoryMovieRepository, InMemoryReviewRepository, NoopEventPublisher};
|
||||
use domain::testing::{InMemoryStore, NoopEventPublisher};
|
||||
|
||||
use crate::{
|
||||
diary::commands::LogReviewCommand, diary::log_review,
|
||||
@@ -12,8 +12,8 @@ use crate::{
|
||||
};
|
||||
|
||||
fn build_logger(
|
||||
movies: &Arc<InMemoryMovieRepository>,
|
||||
reviews: &Arc<InMemoryReviewRepository>,
|
||||
movies: &Arc<InMemoryStore>,
|
||||
reviews: &Arc<InMemoryStore>,
|
||||
events: &Arc<NoopEventPublisher>,
|
||||
) -> Arc<dyn crate::ports::ReviewLogger> {
|
||||
Arc::new(DefaultReviewLogger::new(
|
||||
@@ -28,8 +28,8 @@ fn build_logger(
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_log_review_creates_movie_and_review() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let movies = InMemoryStore::new();
|
||||
let reviews = Arc::clone(&movies);
|
||||
let events = NoopEventPublisher::new();
|
||||
let logger = build_logger(&movies, &reviews, &events);
|
||||
|
||||
@@ -38,14 +38,14 @@ async fn test_log_review_creates_movie_and_review() {
|
||||
|
||||
log_review::execute(&logger, cmd).await.unwrap();
|
||||
|
||||
assert_eq!(reviews.count(), 1, "review should be saved");
|
||||
assert_eq!(reviews.review_count(), 1, "review should be saved");
|
||||
assert!(!events.published().is_empty(), "events should be published");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_log_review_reuses_existing_movie() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let movies = InMemoryStore::new();
|
||||
let reviews = Arc::clone(&movies);
|
||||
|
||||
let existing_movie = test_movie_with_year("Alien", 1979);
|
||||
let movie_uuid = existing_movie.id().value();
|
||||
@@ -61,14 +61,14 @@ async fn test_log_review_reuses_existing_movie() {
|
||||
|
||||
log_review::execute(&logger, cmd).await.unwrap();
|
||||
|
||||
assert_eq!(movies.count(), 1, "no duplicate movie");
|
||||
assert_eq!(reviews.count(), 1);
|
||||
assert_eq!(movies.movie_count(), 1, "no duplicate movie");
|
||||
assert_eq!(reviews.review_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_log_review_with_invalid_rating_fails() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let movies = InMemoryStore::new();
|
||||
let reviews = Arc::clone(&movies);
|
||||
let events = NoopEventPublisher::new();
|
||||
let logger = build_logger(&movies, &reviews, &events);
|
||||
|
||||
|
||||
@@ -8,10 +8,7 @@ use domain::{
|
||||
models::WatchlistEntry,
|
||||
models::{MetadataSearchCriteria, Movie},
|
||||
ports::{MetadataClient, MovieCommand, WatchlistRepository},
|
||||
testing::{
|
||||
FakeMetadataClient, InMemoryMovieRepository, InMemoryReviewRepository,
|
||||
InMemoryWatchlistRepository, NoopEventPublisher,
|
||||
},
|
||||
testing::{FakeMetadataClient, InMemoryStore, NoopEventPublisher},
|
||||
value_objects::{ExternalMetadataId, MovieId, PosterUrl, UserId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
@@ -21,9 +18,9 @@ use crate::diary::review_logger::DefaultReviewLogger;
|
||||
use crate::ports::ReviewLogger;
|
||||
|
||||
fn make_logger(
|
||||
movies: &Arc<InMemoryMovieRepository>,
|
||||
reviews: &Arc<InMemoryReviewRepository>,
|
||||
watchlist: &Arc<InMemoryWatchlistRepository>,
|
||||
movies: &Arc<InMemoryStore>,
|
||||
reviews: &Arc<InMemoryStore>,
|
||||
watchlist: &Arc<InMemoryStore>,
|
||||
events: &Arc<NoopEventPublisher>,
|
||||
) -> DefaultReviewLogger {
|
||||
DefaultReviewLogger::new(
|
||||
@@ -38,9 +35,9 @@ fn make_logger(
|
||||
|
||||
#[tokio::test]
|
||||
async fn logs_review_with_manual_movie() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let movies = InMemoryStore::new();
|
||||
let reviews = Arc::clone(&movies);
|
||||
let watchlist = Arc::clone(&movies);
|
||||
let events = NoopEventPublisher::new();
|
||||
let logger = make_logger(&movies, &reviews, &watchlist, &events);
|
||||
|
||||
@@ -49,15 +46,15 @@ async fn logs_review_with_manual_movie() {
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
|
||||
assert_eq!(movies.count(), 1);
|
||||
assert_eq!(reviews.count(), 1);
|
||||
assert_eq!(movies.movie_count(), 1);
|
||||
assert_eq!(reviews.review_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removes_from_watchlist_on_review() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let movies = InMemoryStore::new();
|
||||
let reviews = Arc::clone(&movies);
|
||||
let watchlist = Arc::clone(&movies);
|
||||
let events = NoopEventPublisher::new();
|
||||
let logger = make_logger(&movies, &reviews, &watchlist, &events);
|
||||
|
||||
@@ -72,7 +69,7 @@ async fn removes_from_watchlist_on_review() {
|
||||
// Add to watchlist
|
||||
let entry = WatchlistEntry::new(user_id.clone(), MovieId::from_uuid(movie_id));
|
||||
watchlist.add(&entry).await.unwrap();
|
||||
assert_eq!(watchlist.count(), 1);
|
||||
assert_eq!(watchlist.watchlist_count(), 1);
|
||||
|
||||
// Log review for same movie
|
||||
let cmd = LogReviewCommand {
|
||||
@@ -82,14 +79,14 @@ async fn removes_from_watchlist_on_review() {
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
|
||||
assert_eq!(watchlist.count(), 0);
|
||||
assert_eq!(watchlist.watchlist_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logs_review_with_existing_movie_by_id() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let movies = InMemoryStore::new();
|
||||
let reviews = Arc::clone(&movies);
|
||||
let watchlist = Arc::clone(&movies);
|
||||
let events = NoopEventPublisher::new();
|
||||
let logger = make_logger(&movies, &reviews, &watchlist, &events);
|
||||
|
||||
@@ -104,15 +101,15 @@ async fn logs_review_with_existing_movie_by_id() {
|
||||
|
||||
logger.log_review(cmd).await.unwrap();
|
||||
|
||||
assert_eq!(movies.count(), 1);
|
||||
assert_eq!(reviews.count(), 1);
|
||||
assert_eq!(movies.movie_count(), 1);
|
||||
assert_eq!(reviews.review_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_movie_not_found_returns_error() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let movies = InMemoryStore::new();
|
||||
let reviews = Arc::clone(&movies);
|
||||
let watchlist = Arc::clone(&movies);
|
||||
let events = NoopEventPublisher::new();
|
||||
let logger = make_logger(&movies, &reviews, &watchlist, &events);
|
||||
|
||||
@@ -123,9 +120,9 @@ async fn existing_movie_not_found_returns_error() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_rating_returns_error() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let movies = InMemoryStore::new();
|
||||
let reviews = Arc::clone(&movies);
|
||||
let watchlist = Arc::clone(&movies);
|
||||
let events = NoopEventPublisher::new();
|
||||
let logger = make_logger(&movies, &reviews, &watchlist, &events);
|
||||
|
||||
@@ -137,15 +134,15 @@ async fn invalid_rating_returns_error() {
|
||||
let result = logger.log_review(cmd).await;
|
||||
assert!(result.is_err());
|
||||
// No repo calls should have happened
|
||||
assert_eq!(movies.count(), 0);
|
||||
assert_eq!(reviews.count(), 0);
|
||||
assert_eq!(movies.movie_count(), 0);
|
||||
assert_eq!(reviews.review_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watchlist_not_present_does_not_publish_removed() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let movies = InMemoryStore::new();
|
||||
let reviews = Arc::clone(&movies);
|
||||
let watchlist = Arc::clone(&movies);
|
||||
let events = NoopEventPublisher::new();
|
||||
let logger = make_logger(&movies, &reviews, &watchlist, &events);
|
||||
|
||||
@@ -187,9 +184,9 @@ impl MetadataClient for MetadataClientWithExternalId {
|
||||
|
||||
#[tokio::test]
|
||||
async fn publishes_movie_discovered_for_new_movie_with_external_id() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let reviews = InMemoryReviewRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let movies = InMemoryStore::new();
|
||||
let reviews = Arc::clone(&movies);
|
||||
let watchlist = Arc::clone(&movies);
|
||||
let events = NoopEventPublisher::new();
|
||||
|
||||
let logger = DefaultReviewLogger::new(
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::test_support::create_goal_command;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::events::DomainEvent;
|
||||
use domain::testing::{FakeStatsRepository, InMemoryGoalRepository, NoopEventPublisher};
|
||||
use domain::testing::{FakeStatsRepository, InMemoryStore, NoopEventPublisher};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::goals::create;
|
||||
@@ -11,7 +11,7 @@ use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn creates_goal_and_returns_progress() {
|
||||
let goals = InMemoryGoalRepository::new();
|
||||
let goals = InMemoryStore::new();
|
||||
let stats = FakeStatsRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let deps = GoalCommandDeps {
|
||||
@@ -28,12 +28,12 @@ async fn creates_goal_and_returns_progress() {
|
||||
assert_eq!(result.goal.year(), 2025);
|
||||
assert_eq!(result.goal.target_count(), 50);
|
||||
assert_eq!(result.current_count, 0);
|
||||
assert_eq!(goals.count(), 1);
|
||||
assert_eq!(goals.goal_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creates_goal_with_review_count() {
|
||||
let goals = InMemoryGoalRepository::new();
|
||||
let goals = InMemoryStore::new();
|
||||
let stats = FakeStatsRepository::new();
|
||||
stats.set_review_count(Uuid::nil(), 2025, 5);
|
||||
let events = NoopEventPublisher::new();
|
||||
@@ -49,7 +49,7 @@ async fn creates_goal_with_review_count() {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.current_count, 5);
|
||||
assert_eq!(goals.count(), 1);
|
||||
assert_eq!(goals.goal_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::test_support::create_goal_command;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::{FakeStatsRepository, InMemoryGoalRepository, NoopEventPublisher};
|
||||
use domain::testing::{FakeStatsRepository, InMemoryStore, NoopEventPublisher};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::goals::deps::GoalCommandDeps;
|
||||
@@ -10,7 +10,7 @@ use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn deletes_existing_goal() {
|
||||
let goals = InMemoryGoalRepository::new();
|
||||
let goals = InMemoryStore::new();
|
||||
let stats = FakeStatsRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let deps = GoalCommandDeps {
|
||||
@@ -23,7 +23,7 @@ async fn deletes_existing_goal() {
|
||||
create::execute(&deps, create_goal_command(2025, 10))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(goals.count(), 1);
|
||||
assert_eq!(goals.goal_count(), 1);
|
||||
|
||||
delete::execute(
|
||||
&deps,
|
||||
@@ -35,7 +35,7 @@ async fn deletes_existing_goal() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(goals.count(), 0);
|
||||
assert_eq!(goals.goal_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -10,7 +10,7 @@ use domain::{
|
||||
import::{ImportRow, ParsedFile, RowResult},
|
||||
},
|
||||
ports::{DocumentParser, MovieCommand},
|
||||
testing::{InMemoryImportSessionRepository, InMemoryMovieRepository},
|
||||
testing::InMemoryStore,
|
||||
value_objects::ExternalMetadataId,
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn applies_mapping_to_session() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
@@ -54,7 +54,7 @@ async fn applies_mapping_to_session() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_session_not_found() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new();
|
||||
|
||||
let deps = ApplyMappingDeps {
|
||||
@@ -105,8 +105,8 @@ impl DocumentParser for DuplicateTestParser {
|
||||
|
||||
#[tokio::test]
|
||||
async fn marks_duplicate_by_external_id() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let movies = InMemoryStore::new();
|
||||
let sessions = Arc::clone(&movies);
|
||||
|
||||
let ext_id = ExternalMetadataId::new("tt1234567".into()).unwrap();
|
||||
let movie = test_movie_with_external_id("Known Movie", 2020, ext_id);
|
||||
@@ -154,8 +154,8 @@ async fn marks_duplicate_by_external_id() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn marks_duplicate_by_title_and_year() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let movies = InMemoryStore::new();
|
||||
let sessions = Arc::clone(&movies);
|
||||
|
||||
let movie = test_movie_with_year("Duplicate Film", 2022);
|
||||
movies.upsert_movie(&movie).await.unwrap();
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
use chrono::Utc;
|
||||
use domain::models::ImportProfile;
|
||||
use domain::ports::{ImportProfileRepository, ImportSessionRepository};
|
||||
use domain::testing::{InMemoryImportProfileRepository, InMemoryImportSessionRepository};
|
||||
use domain::testing::InMemoryStore;
|
||||
use domain::value_objects::{ImportProfileId, UserId};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -13,8 +13,8 @@ use crate::import::deps::ApplyProfileDeps;
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_profile_not_found() {
|
||||
let profiles = InMemoryImportProfileRepository::new();
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let profiles = InMemoryStore::new();
|
||||
let sessions = Arc::clone(&profiles);
|
||||
|
||||
let deps = ApplyProfileDeps {
|
||||
import_profile: Arc::clone(&profiles) as _,
|
||||
@@ -32,8 +32,8 @@ async fn fails_when_profile_not_found() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_session_not_found() {
|
||||
let profiles = InMemoryImportProfileRepository::new();
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let profiles = InMemoryStore::new();
|
||||
let sessions = Arc::clone(&profiles);
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let profile = ImportProfile::new(
|
||||
@@ -62,8 +62,8 @@ async fn fails_when_session_not_found() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn applies_profile_mappings_to_session() {
|
||||
let profiles = InMemoryImportProfileRepository::new();
|
||||
let sessions = domain::testing::InMemoryImportSessionRepository::new();
|
||||
let profiles = InMemoryStore::new();
|
||||
let sessions = domain::testing::InMemoryStore::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let profile = ImportProfile::new(
|
||||
@@ -97,11 +97,11 @@ async fn applies_profile_mappings_to_session() {
|
||||
.unwrap();
|
||||
|
||||
// Verify the session got updated with field_mappings and row_results cleared
|
||||
let updated = sessions
|
||||
.get(&session_id, &UserId::from_uuid(user_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let updated =
|
||||
ImportSessionRepository::get(&*sessions, &session_id, &UserId::from_uuid(user_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(updated.field_mappings.is_some());
|
||||
assert_eq!(updated.field_mappings.unwrap().len(), 1);
|
||||
assert!(updated.row_results.is_none());
|
||||
|
||||
@@ -7,7 +7,7 @@ use uuid::Uuid;
|
||||
use domain::models::import::{DomainField, Transform};
|
||||
use domain::models::{FieldMapping, ImportProfile};
|
||||
use domain::ports::{ImportProfileRepository, ImportSessionRepository};
|
||||
use domain::testing::{InMemoryImportProfileRepository, InMemoryImportSessionRepository};
|
||||
use domain::testing::InMemoryStore;
|
||||
use domain::value_objects::{ImportProfileId, UserId};
|
||||
|
||||
use crate::import::deps::{ApplyProfileAndMapDeps, CreateSessionDeps};
|
||||
@@ -16,8 +16,8 @@ use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_profile_not_found() {
|
||||
let profiles = InMemoryImportProfileRepository::new();
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let profiles = InMemoryStore::new();
|
||||
let sessions = Arc::clone(&profiles);
|
||||
let b = TestContextBuilder::new();
|
||||
|
||||
let deps = ApplyProfileAndMapDeps {
|
||||
@@ -42,8 +42,8 @@ async fn fails_when_profile_not_found() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn applies_profile_then_regenerates_mapping() {
|
||||
let profiles = InMemoryImportProfileRepository::new();
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let profiles = InMemoryStore::new();
|
||||
let sessions = Arc::clone(&profiles);
|
||||
let b = TestContextBuilder::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
@@ -92,11 +92,11 @@ async fn applies_profile_then_regenerates_mapping() {
|
||||
|
||||
assert!(!rows.is_empty());
|
||||
|
||||
let updated = sessions
|
||||
.get(&created.session_id, &UserId::from_uuid(user_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let updated =
|
||||
ImportSessionRepository::get(&*sessions, &created.session_id, &UserId::from_uuid(user_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(updated.row_results.is_some());
|
||||
assert!(updated.field_mappings.is_some());
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::InMemoryImportSessionRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
|
||||
use crate::import::cleanup;
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_zero_when_nothing_expired() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
|
||||
let result = cleanup::execute(Arc::clone(&sessions) as _).await.unwrap();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::testing::InMemoryImportSessionRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
|
||||
use crate::import::create_session;
|
||||
use crate::import::deps::CreateSessionDeps;
|
||||
@@ -11,7 +11,7 @@ use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn creates_session_with_parsed_file() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new();
|
||||
|
||||
let deps = CreateSessionDeps {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::InMemoryImportProfileRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::import::{
|
||||
@@ -9,7 +9,7 @@ use crate::import::{
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_profile_not_found() {
|
||||
let profiles = InMemoryImportProfileRepository::new();
|
||||
let profiles = InMemoryStore::new();
|
||||
let deps = DeleteImportProfileDeps {
|
||||
import_profile: Arc::clone(&profiles) as _,
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
|
||||
use domain::models::{AnnotatedRow, ImportSession, import::RowResult};
|
||||
use domain::ports::ImportSessionRepository;
|
||||
use domain::testing::InMemoryImportSessionRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
use domain::value_objects::UserId;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -45,7 +45,7 @@ fn make_session_with_rows(user_id: UserId) -> ImportSession {
|
||||
|
||||
#[tokio::test]
|
||||
async fn imports_confirmed_rows() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let session = make_session_with_rows(UserId::from_uuid(uid));
|
||||
@@ -74,7 +74,7 @@ async fn imports_confirmed_rows() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn skips_unconfirmed_rows() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let session = make_session_with_rows(UserId::from_uuid(uid));
|
||||
@@ -96,7 +96,7 @@ async fn skips_unconfirmed_rows() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_session_not_found() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
|
||||
let deps = ExecuteImportDeps {
|
||||
import_session: Arc::clone(&sessions) as _,
|
||||
@@ -117,7 +117,7 @@ async fn fails_when_session_not_found() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn handles_datetime_format() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let mut session = ImportSession::new(UserId::from_uuid(uid));
|
||||
@@ -151,7 +151,7 @@ async fn handles_datetime_format() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_on_invalid_rating() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let mut session = ImportSession::new(UserId::from_uuid(uid));
|
||||
@@ -185,7 +185,7 @@ async fn fails_on_invalid_rating() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_on_missing_watched_at() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let mut session = ImportSession::new(UserId::from_uuid(uid));
|
||||
@@ -219,7 +219,7 @@ async fn fails_on_missing_watched_at() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn imports_row_with_external_metadata_id() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let mut session = ImportSession::new(UserId::from_uuid(uid));
|
||||
@@ -253,7 +253,7 @@ async fn imports_row_with_external_metadata_id() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn imports_row_with_director_and_comment() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let mut session = ImportSession::new(UserId::from_uuid(uid));
|
||||
@@ -287,7 +287,7 @@ async fn imports_row_with_director_and_comment() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn handles_space_separated_datetime_format() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let mut session = ImportSession::new(UserId::from_uuid(uid));
|
||||
@@ -321,7 +321,7 @@ async fn handles_space_separated_datetime_format() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn reports_invalid_row_result_errors() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let mut session = ImportSession::new(UserId::from_uuid(uid));
|
||||
@@ -352,7 +352,7 @@ async fn reports_invalid_row_result_errors() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_on_missing_rating() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let mut session = ImportSession::new(UserId::from_uuid(uid));
|
||||
@@ -387,7 +387,7 @@ async fn fails_on_missing_rating() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_on_unparseable_date() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let mut session = ImportSession::new(UserId::from_uuid(uid));
|
||||
@@ -422,7 +422,7 @@ async fn fails_on_unparseable_date() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn imports_row_without_release_year() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let mut session = ImportSession::new(UserId::from_uuid(uid));
|
||||
@@ -456,13 +456,13 @@ async fn imports_row_without_release_year() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn deletes_session_after_import() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let session = make_session_with_rows(UserId::from_uuid(uid));
|
||||
let sid = session.id.clone();
|
||||
sessions.create(&session).await.unwrap();
|
||||
assert_eq!(sessions.count(), 1);
|
||||
assert_eq!(sessions.import_session_count(), 1);
|
||||
|
||||
let deps = ExecuteImportDeps {
|
||||
import_session: Arc::clone(&sessions) as _,
|
||||
@@ -474,7 +474,7 @@ async fn deletes_session_after_import() {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sessions.count(),
|
||||
sessions.import_session_count(),
|
||||
0,
|
||||
"session should be deleted after import"
|
||||
);
|
||||
@@ -482,7 +482,7 @@ async fn deletes_session_after_import() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn imports_more_rows_than_concurrency_limit() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let rows: Vec<_> = (0..15)
|
||||
|
||||
@@ -5,7 +5,7 @@ use uuid::Uuid;
|
||||
use domain::models::ImportSession;
|
||||
use domain::models::import::ParsedFile;
|
||||
use domain::ports::ImportSessionRepository;
|
||||
use domain::testing::InMemoryImportSessionRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
use domain::value_objects::{ImportSessionId, UserId};
|
||||
|
||||
use crate::import::deps::GetMappingStageDeps;
|
||||
@@ -13,7 +13,7 @@ use crate::import::get_mapping_stage::{self, SAMPLE_ROW_LIMIT};
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_mapping_stage_is_not_found_when_file_not_parsed() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
let session = ImportSession::new(UserId::from_uuid(user_id));
|
||||
let session_id = session.id.clone();
|
||||
@@ -30,7 +30,7 @@ async fn get_mapping_stage_is_not_found_when_file_not_parsed() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_mapping_stage_is_not_found_when_session_missing() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let deps = GetMappingStageDeps {
|
||||
import_session: Arc::clone(&sessions) as _,
|
||||
};
|
||||
@@ -43,7 +43,7 @@ async fn get_mapping_stage_is_not_found_when_session_missing() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_mapping_stage_returns_columns_and_capped_sample_rows() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
let mut session = ImportSession::new(UserId::from_uuid(user_id));
|
||||
session.parsed_file = Some(ParsedFile {
|
||||
|
||||
@@ -5,7 +5,7 @@ use uuid::Uuid;
|
||||
use domain::models::import::{ImportRow, ParsedFile, RowResult};
|
||||
use domain::models::{AnnotatedRow, ImportSession};
|
||||
use domain::ports::ImportSessionRepository;
|
||||
use domain::testing::InMemoryImportSessionRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
use domain::value_objects::{ImportSessionId, UserId};
|
||||
|
||||
use crate::import::deps::GetPreviewStageDeps;
|
||||
@@ -13,7 +13,7 @@ use crate::import::get_preview_stage::{self, PreviewStage};
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_preview_stage_reports_not_yet_mapped_when_row_results_absent() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
let session = ImportSession::new(UserId::from_uuid(user_id));
|
||||
let session_id = session.id.clone();
|
||||
@@ -32,7 +32,7 @@ async fn get_preview_stage_reports_not_yet_mapped_when_row_results_absent() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_preview_stage_returns_rows_once_mapped() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
let mut session = ImportSession::new(UserId::from_uuid(user_id));
|
||||
session.parsed_file = Some(ParsedFile {
|
||||
@@ -68,7 +68,7 @@ async fn get_preview_stage_returns_rows_once_mapped() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_preview_stage_is_not_found_when_session_missing() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let deps = GetPreviewStageDeps {
|
||||
import_session: Arc::clone(&sessions) as _,
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ use uuid::Uuid;
|
||||
use domain::models::ImportSession;
|
||||
use domain::models::import::ParsedFile;
|
||||
use domain::ports::ImportSessionRepository;
|
||||
use domain::testing::InMemoryImportSessionRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
use domain::value_objects::{ImportSessionId, UserId};
|
||||
|
||||
use crate::import::deps::GetSessionStateDeps;
|
||||
@@ -13,7 +13,7 @@ use crate::import::get_session_state;
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_session_state_is_not_found_when_session_missing() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let deps = GetSessionStateDeps {
|
||||
import_session: Arc::clone(&sessions) as _,
|
||||
};
|
||||
@@ -26,7 +26,7 @@ async fn get_session_state_is_not_found_when_session_missing() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_session_state_reports_columns_row_count_and_mapping_status() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
let mut session = ImportSession::new(UserId::from_uuid(user_id));
|
||||
session.parsed_file = Some(ParsedFile {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::InMemoryImportProfileRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
use domain::value_objects::UserId;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::import::{deps::ListImportProfilesDeps, list_profiles};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_when_no_profiles() {
|
||||
let profiles = InMemoryImportProfileRepository::new();
|
||||
let profiles = InMemoryStore::new();
|
||||
let deps = ListImportProfilesDeps {
|
||||
import_profile: Arc::clone(&profiles) as _,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use domain::models::ImportSession;
|
||||
use domain::ports::ImportSessionRepository;
|
||||
use domain::testing::{InMemoryImportProfileRepository, InMemoryImportSessionRepository};
|
||||
use domain::testing::InMemoryStore;
|
||||
use domain::value_objects::UserId;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -11,8 +11,8 @@ use crate::import::{commands::SaveImportProfileCommand, save_profile};
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_session_not_found() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let profiles = InMemoryImportProfileRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let profiles = Arc::clone(&sessions);
|
||||
|
||||
let deps = SaveProfileDeps {
|
||||
import_session: Arc::clone(&sessions) as _,
|
||||
@@ -34,8 +34,8 @@ async fn fails_when_session_not_found() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn saves_profile_from_session() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let profiles = InMemoryImportProfileRepository::new();
|
||||
let sessions = InMemoryStore::new();
|
||||
let profiles = Arc::clone(&sessions);
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let mut session = ImportSession::new(UserId::from_uuid(user_id));
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use domain::testing::InMemoryWatchEventRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
|
||||
use crate::integrations::cleanup;
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_zero_when_nothing_to_clean() {
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let watch_events = InMemoryStore::new();
|
||||
|
||||
let count = cleanup::execute(watch_events).await.unwrap();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
|
||||
use domain::models::{WatchEvent, WatchEventSource};
|
||||
use domain::ports::{MovieCommand, WatchEventCommand};
|
||||
use domain::testing::{InMemoryWatchEventRepository, NoopEventPublisher};
|
||||
use domain::testing::{InMemoryStore, NoopEventPublisher};
|
||||
use domain::value_objects::UserId;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -17,7 +17,7 @@ fn noop_logger() -> Arc<dyn crate::ports::ReviewLogger> {
|
||||
}
|
||||
|
||||
fn deps(
|
||||
watch_events: &Arc<InMemoryWatchEventRepository>,
|
||||
watch_events: &Arc<InMemoryStore>,
|
||||
review_logger: Arc<dyn crate::ports::ReviewLogger>,
|
||||
) -> ConfirmWatchEventsDeps {
|
||||
ConfirmWatchEventsDeps {
|
||||
@@ -29,7 +29,7 @@ fn deps(
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirms_watch_event_via_review_logger() {
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let watch_events = InMemoryStore::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let event = WatchEvent::new(
|
||||
@@ -56,7 +56,7 @@ async fn confirms_watch_event_via_review_logger() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_confirmations_returns_zero() {
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let watch_events = InMemoryStore::new();
|
||||
|
||||
let result = confirm::execute(
|
||||
&deps(&watch_events, noop_logger()),
|
||||
@@ -73,7 +73,7 @@ async fn empty_confirmations_returns_zero() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirms_event_with_external_metadata_id_and_no_movie_id() {
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let watch_events = InMemoryStore::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let event = WatchEvent::new(
|
||||
@@ -107,7 +107,7 @@ async fn confirms_event_with_external_metadata_id_and_no_movie_id() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_other_users_event() {
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let watch_events = InMemoryStore::new();
|
||||
let owner = Uuid::new_v4();
|
||||
let intruder = Uuid::new_v4();
|
||||
|
||||
@@ -134,7 +134,7 @@ async fn rejects_other_users_event() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_event_not_found() {
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let watch_events = InMemoryStore::new();
|
||||
|
||||
let result = confirm::execute(
|
||||
&deps(&watch_events, noop_logger()),
|
||||
@@ -147,7 +147,7 @@ async fn fails_when_event_not_found() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirms_event_with_movie_id() {
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let watch_events = InMemoryStore::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let uid = Uuid::new_v4();
|
||||
let movie_uuid = Uuid::new_v4();
|
||||
@@ -165,7 +165,7 @@ async fn confirms_event_with_movie_id() {
|
||||
watch_events.save(&event).await.unwrap();
|
||||
|
||||
// Also seed movie repo so review_logger can find it
|
||||
let movies = domain::testing::InMemoryMovieRepository::new();
|
||||
let movies = domain::testing::InMemoryStore::new();
|
||||
let movie = domain::models::Movie::from_persistence(
|
||||
domain::value_objects::MovieId::from_uuid(movie_uuid),
|
||||
None,
|
||||
@@ -177,8 +177,8 @@ async fn confirms_event_with_movie_id() {
|
||||
movies.upsert_movie(&movie).await.unwrap();
|
||||
|
||||
// Build a real review logger
|
||||
let reviews = domain::testing::InMemoryReviewRepository::new();
|
||||
let watchlist = domain::testing::InMemoryWatchlistRepository::new();
|
||||
let reviews = domain::testing::InMemoryStore::new();
|
||||
let watchlist = domain::testing::InMemoryStore::new();
|
||||
let review_logger: Arc<dyn crate::ports::ReviewLogger> =
|
||||
Arc::new(crate::diary::review_logger::DefaultReviewLogger::new(
|
||||
Arc::clone(&movies) as _,
|
||||
@@ -201,7 +201,7 @@ async fn confirms_event_with_movie_id() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirms_event_without_movie_id_and_without_external_metadata_id() {
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let watch_events = InMemoryStore::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let event = WatchEvent::new(
|
||||
@@ -235,7 +235,7 @@ async fn confirms_event_without_movie_id_and_without_external_metadata_id() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirms_multiple_events() {
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let watch_events = InMemoryStore::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let event1 = WatchEvent::new(
|
||||
@@ -289,7 +289,7 @@ async fn confirms_multiple_events() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirms_event_without_year() {
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let watch_events = InMemoryStore::new();
|
||||
let uid = Uuid::new_v4();
|
||||
|
||||
let event = WatchEvent::new(
|
||||
|
||||
@@ -3,14 +3,14 @@ use std::sync::Arc;
|
||||
|
||||
use domain::models::{WatchEvent, WatchEventSource};
|
||||
use domain::ports::WatchEventCommand;
|
||||
use domain::testing::InMemoryWatchEventRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
use domain::value_objects::UserId;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::integrations::deps::DismissWatchEventsDeps;
|
||||
use crate::integrations::dismiss;
|
||||
|
||||
fn deps(watch_events: &Arc<InMemoryWatchEventRepository>) -> DismissWatchEventsDeps {
|
||||
fn deps(watch_events: &Arc<InMemoryStore>) -> DismissWatchEventsDeps {
|
||||
DismissWatchEventsDeps {
|
||||
watch_event_command: Arc::clone(watch_events) as _,
|
||||
watch_event_query: Arc::clone(watch_events) as _,
|
||||
@@ -19,7 +19,7 @@ fn deps(watch_events: &Arc<InMemoryWatchEventRepository>) -> DismissWatchEventsD
|
||||
|
||||
#[tokio::test]
|
||||
async fn dismisses_empty_list_returns_zero() {
|
||||
let events = InMemoryWatchEventRepository::new();
|
||||
let events = InMemoryStore::new();
|
||||
|
||||
let result = dismiss::execute(
|
||||
&deps(&events),
|
||||
@@ -33,7 +33,7 @@ async fn dismisses_empty_list_returns_zero() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_event_not_found() {
|
||||
let events = InMemoryWatchEventRepository::new();
|
||||
let events = InMemoryStore::new();
|
||||
|
||||
let result = dismiss::execute(
|
||||
&deps(&events),
|
||||
@@ -46,7 +46,7 @@ async fn fails_when_event_not_found() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn dismisses_existing_events() {
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let watch_events = InMemoryStore::new();
|
||||
let uid = Uuid::new_v4();
|
||||
let user_id = UserId::from_uuid(uid);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::test_support::generate_webhook_token_command;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::WebhookTokenRepository;
|
||||
use domain::testing::InMemoryWebhookTokenRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::integrations::deps::GenerateWebhookTokenDeps;
|
||||
@@ -10,7 +10,7 @@ use crate::integrations::generate_token;
|
||||
|
||||
#[tokio::test]
|
||||
async fn generates_token_and_saves() {
|
||||
let tokens: Arc<dyn WebhookTokenRepository> = InMemoryWebhookTokenRepository::new();
|
||||
let tokens: Arc<dyn WebhookTokenRepository> = InMemoryStore::new();
|
||||
|
||||
let user_id = Uuid::new_v4();
|
||||
let result = generate_token::execute(
|
||||
|
||||
@@ -3,14 +3,14 @@ use std::sync::Arc;
|
||||
use chrono::Utc;
|
||||
use domain::models::{WatchEvent, WatchEventSource};
|
||||
use domain::ports::WatchEventCommand;
|
||||
use domain::testing::InMemoryWatchEventRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
use domain::value_objects::UserId;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::integrations::deps::GetWatchQueueDeps;
|
||||
use crate::integrations::{get_queue, queries::GetWatchQueueQuery};
|
||||
|
||||
fn deps(events: &Arc<InMemoryWatchEventRepository>) -> GetWatchQueueDeps {
|
||||
fn deps(events: &Arc<InMemoryStore>) -> GetWatchQueueDeps {
|
||||
GetWatchQueueDeps {
|
||||
watch_event_query: Arc::clone(events) as _,
|
||||
}
|
||||
@@ -18,7 +18,7 @@ fn deps(events: &Arc<InMemoryWatchEventRepository>) -> GetWatchQueueDeps {
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_when_no_events() {
|
||||
let events = InMemoryWatchEventRepository::new();
|
||||
let events = InMemoryStore::new();
|
||||
|
||||
let result = get_queue::execute(
|
||||
&deps(&events),
|
||||
@@ -34,7 +34,7 @@ async fn returns_empty_when_no_events() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_pending_events() {
|
||||
let events = InMemoryWatchEventRepository::new();
|
||||
let events = InMemoryStore::new();
|
||||
|
||||
let user_id = Uuid::new_v4();
|
||||
let event = WatchEvent::new(
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
|
||||
use domain::models::WatchEventSource;
|
||||
use domain::ports::WebhookTokenRepository;
|
||||
use domain::testing::InMemoryWebhookTokenRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::integrations::deps::{GenerateWebhookTokenDeps, GetWebhookTokensDeps};
|
||||
@@ -26,7 +26,7 @@ fn get_deps(tokens: &Arc<dyn WebhookTokenRepository>) -> GetWebhookTokensDeps {
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_when_no_tokens() {
|
||||
let tokens: Arc<dyn WebhookTokenRepository> = InMemoryWebhookTokenRepository::new();
|
||||
let tokens: Arc<dyn WebhookTokenRepository> = InMemoryStore::new();
|
||||
|
||||
let result = get_tokens::execute(
|
||||
&get_deps(&tokens),
|
||||
@@ -42,7 +42,7 @@ async fn returns_empty_when_no_tokens() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_tokens_after_generate() {
|
||||
let tokens: Arc<dyn WebhookTokenRepository> = InMemoryWebhookTokenRepository::new();
|
||||
let tokens: Arc<dyn WebhookTokenRepository> = InMemoryStore::new();
|
||||
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
|
||||
@@ -2,10 +2,8 @@ use crate::test_support::generate_webhook_token_command;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::models::WatchEventSource;
|
||||
use domain::ports::{EventPublisher, WebhookTokenRepository};
|
||||
use domain::testing::{
|
||||
InMemoryWatchEventRepository, InMemoryWebhookTokenRepository, NoopEventPublisher,
|
||||
};
|
||||
use domain::ports::EventPublisher;
|
||||
use domain::testing::{InMemoryStore, NoopEventPublisher};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::integrations::commands::IngestWatchEventCommand;
|
||||
@@ -30,14 +28,14 @@ impl domain::ports::MediaServerParser for FakeParser {
|
||||
|
||||
#[tokio::test]
|
||||
async fn ingests_watch_event() {
|
||||
let tokens: Arc<dyn WebhookTokenRepository> = InMemoryWebhookTokenRepository::new();
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let tokens = InMemoryStore::new();
|
||||
let watch_events = Arc::clone(&tokens);
|
||||
let event_publisher: Arc<dyn EventPublisher> = NoopEventPublisher::new();
|
||||
|
||||
let user_id = Uuid::new_v4();
|
||||
let generated = generate_token::execute(
|
||||
&GenerateWebhookTokenDeps {
|
||||
webhook_token: Arc::clone(&tokens),
|
||||
webhook_token: Arc::clone(&tokens) as _,
|
||||
},
|
||||
generate_webhook_token_command(user_id),
|
||||
)
|
||||
@@ -45,7 +43,7 @@ async fn ingests_watch_event() {
|
||||
.unwrap();
|
||||
|
||||
let deps = IngestWatchEventDeps {
|
||||
webhook_token: Arc::clone(&tokens),
|
||||
webhook_token: Arc::clone(&tokens) as _,
|
||||
watch_event_command: Arc::clone(&watch_events) as _,
|
||||
watch_event_query: Arc::clone(&watch_events) as _,
|
||||
event_publisher: Arc::clone(&event_publisher),
|
||||
@@ -67,12 +65,12 @@ async fn ingests_watch_event() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_token() {
|
||||
let tokens: Arc<dyn WebhookTokenRepository> = InMemoryWebhookTokenRepository::new();
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let tokens = InMemoryStore::new();
|
||||
let watch_events = Arc::clone(&tokens);
|
||||
let event_publisher: Arc<dyn EventPublisher> = NoopEventPublisher::new();
|
||||
|
||||
let deps = IngestWatchEventDeps {
|
||||
webhook_token: Arc::clone(&tokens),
|
||||
webhook_token: Arc::clone(&tokens) as _,
|
||||
watch_event_command: Arc::clone(&watch_events) as _,
|
||||
watch_event_query: Arc::clone(&watch_events) as _,
|
||||
event_publisher: Arc::clone(&event_publisher),
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::test_support::generate_webhook_token_command;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::WebhookTokenRepository;
|
||||
use domain::testing::InMemoryWebhookTokenRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::integrations::deps::{
|
||||
@@ -15,7 +15,7 @@ use crate::integrations::{
|
||||
|
||||
#[tokio::test]
|
||||
async fn revokes_existing_token() {
|
||||
let tokens: Arc<dyn WebhookTokenRepository> = InMemoryWebhookTokenRepository::new();
|
||||
let tokens: Arc<dyn WebhookTokenRepository> = InMemoryStore::new();
|
||||
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
|
||||
@@ -5,10 +5,7 @@ use chrono::Utc;
|
||||
use domain::{
|
||||
models::MovieProfile,
|
||||
ports::MovieCommand,
|
||||
testing::{
|
||||
FakeSearchCommand, InMemoryMovieProfileRepository, InMemoryMovieRepository,
|
||||
PanicPersonCommand,
|
||||
},
|
||||
testing::{FakeSearchCommand, InMemoryStore, PanicPersonCommand},
|
||||
value_objects::MovieId,
|
||||
};
|
||||
|
||||
@@ -16,8 +13,8 @@ use crate::movies::{commands::EnrichMovieCommand, deps::EnrichMovieDeps, enrich_
|
||||
|
||||
#[tokio::test]
|
||||
async fn stores_profile_and_indexes() {
|
||||
let movie_repo = InMemoryMovieRepository::new();
|
||||
let profile_repo = InMemoryMovieProfileRepository::new();
|
||||
let movie_repo = InMemoryStore::new();
|
||||
let profile_repo = Arc::clone(&movie_repo);
|
||||
// PanicPersonCommand is safe here — empty cast/crew means upsert_batch is never called
|
||||
|
||||
let movie = test_movie_with_year("Test", 2024);
|
||||
@@ -61,7 +58,7 @@ async fn stores_profile_and_indexes() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(profile_repo.count(), 1);
|
||||
assert_eq!(profile_repo.movie_profile_count(), 1);
|
||||
}
|
||||
|
||||
struct NoopPersonCommand;
|
||||
@@ -91,8 +88,8 @@ impl domain::ports::PersonCommand for NoopPersonCommand {
|
||||
|
||||
#[tokio::test]
|
||||
async fn extracts_and_indexes_persons() {
|
||||
let movie_repo = InMemoryMovieRepository::new();
|
||||
let profile_repo = InMemoryMovieProfileRepository::new();
|
||||
let movie_repo = InMemoryStore::new();
|
||||
let profile_repo = Arc::clone(&movie_repo);
|
||||
|
||||
let movie = test_movie_with_year("Cast Movie", 2024);
|
||||
let movie_id = MovieId::from_uuid(movie.id().value());
|
||||
@@ -147,5 +144,5 @@ async fn extracts_and_indexes_persons() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(profile_repo.count(), 1);
|
||||
assert_eq!(profile_repo.movie_profile_count(), 1);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use uuid::Uuid;
|
||||
use domain::{
|
||||
models::{CastMember, CrewMember, MovieProfile},
|
||||
ports::MovieProfileRepository,
|
||||
testing::InMemoryMovieProfileRepository,
|
||||
testing::InMemoryStore,
|
||||
value_objects::MovieId,
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::movies::get_movie_profile::{self, GetMovieProfileQuery};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_none_when_no_profile() {
|
||||
let movie_profile = InMemoryMovieProfileRepository::new();
|
||||
let movie_profile = InMemoryStore::new();
|
||||
let deps = GetMovieProfileDeps { movie_profile };
|
||||
|
||||
let result = get_movie_profile::execute(
|
||||
@@ -30,7 +30,7 @@ async fn returns_none_when_no_profile() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_profile_with_cast_and_crew() {
|
||||
let profile_repo = InMemoryMovieProfileRepository::new();
|
||||
let profile_repo = InMemoryStore::new();
|
||||
let movie_id = MovieId::generate();
|
||||
|
||||
let profile = MovieProfile {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use domain::testing::InMemoryMovieRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
|
||||
use crate::movies::{deps::GetMoviesDeps, get_movies, queries::GetMoviesQuery};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_when_no_movies() {
|
||||
let movie = InMemoryMovieRepository::new();
|
||||
let movie = InMemoryStore::new();
|
||||
let deps = GetMoviesDeps { movie };
|
||||
|
||||
let result = get_movies::execute(
|
||||
|
||||
@@ -6,7 +6,7 @@ use domain::{
|
||||
errors::DomainError,
|
||||
models::MovieProfile,
|
||||
ports::{MovieEnrichmentClient, MovieProfileRepository},
|
||||
testing::{FakeMovieEnrichmentClient, InMemoryMovieProfileRepository},
|
||||
testing::{FakeMovieEnrichmentClient, InMemoryStore},
|
||||
value_objects::MovieId,
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::movies::request_enrichment;
|
||||
#[tokio::test]
|
||||
async fn returns_profile_when_none_cached() {
|
||||
let enrichment = FakeMovieEnrichmentClient;
|
||||
let profile_repo = InMemoryMovieProfileRepository::new();
|
||||
let profile_repo = InMemoryStore::new();
|
||||
let movie_id = MovieId::generate();
|
||||
|
||||
let result = request_enrichment::fetch_if_stale(
|
||||
@@ -34,7 +34,7 @@ async fn returns_profile_when_none_cached() {
|
||||
#[tokio::test]
|
||||
async fn returns_none_when_profile_is_fresh() {
|
||||
let enrichment = FakeMovieEnrichmentClient;
|
||||
let profile_repo = InMemoryMovieProfileRepository::new();
|
||||
let profile_repo = InMemoryStore::new();
|
||||
let movie_id = MovieId::generate();
|
||||
|
||||
// Seed a fresh profile (enriched_at = now)
|
||||
@@ -87,7 +87,7 @@ impl MovieEnrichmentClient for NotFoundEnrichmentClient {
|
||||
#[tokio::test]
|
||||
async fn returns_none_on_not_found_from_client() {
|
||||
let enrichment = NotFoundEnrichmentClient;
|
||||
let profile_repo = InMemoryMovieProfileRepository::new();
|
||||
let profile_repo = InMemoryStore::new();
|
||||
let movie_id = MovieId::generate();
|
||||
|
||||
let result = request_enrichment::fetch_if_stale(
|
||||
|
||||
@@ -8,10 +8,7 @@ use domain::{
|
||||
errors::DomainError,
|
||||
models::Movie,
|
||||
ports::{MetadataClient, MovieCommand, MovieQuery},
|
||||
testing::{
|
||||
FakeSearchCommand, InMemoryMovieProfileRepository, InMemoryMovieRepository,
|
||||
NoopEventPublisher, NoopObjectStorage,
|
||||
},
|
||||
testing::{FakeSearchCommand, InMemoryStore, NoopEventPublisher, NoopObjectStorage},
|
||||
value_objects::{ExternalMetadataId, PosterUrl},
|
||||
};
|
||||
|
||||
@@ -19,9 +16,9 @@ use crate::movies::{deps::SyncPosterDeps, sync_poster};
|
||||
|
||||
fn default_deps() -> SyncPosterDeps {
|
||||
SyncPosterDeps {
|
||||
movie_command: InMemoryMovieRepository::new(),
|
||||
movie_query: InMemoryMovieRepository::new(),
|
||||
movie_profile: InMemoryMovieProfileRepository::new(),
|
||||
movie_command: InMemoryStore::new(),
|
||||
movie_query: InMemoryStore::new(),
|
||||
movie_profile: InMemoryStore::new(),
|
||||
metadata: Arc::new(domain::testing::FakeMetadataClient),
|
||||
poster_fetcher: Arc::new(domain::testing::FakePosterFetcher),
|
||||
object_storage: Arc::new(NoopObjectStorage),
|
||||
@@ -41,7 +38,7 @@ async fn fails_when_movie_not_found() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_no_external_id() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let movies = InMemoryStore::new();
|
||||
let movie = test_movie_with_year("Test", 2024);
|
||||
let movie_id = movie.id().value();
|
||||
movies.upsert_movie(&movie).await.unwrap();
|
||||
@@ -79,7 +76,7 @@ impl MetadataClient for FakeMetaWithPoster {
|
||||
|
||||
#[tokio::test]
|
||||
async fn syncs_poster_for_movie_with_external_id() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let movies = InMemoryStore::new();
|
||||
let ext_id = ExternalMetadataId::new("tmdb:999".into()).unwrap();
|
||||
let movie = test_movie_with_external_id("Poster Movie", 2024, ext_id);
|
||||
let movie_id = movie.id().value();
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
events::DomainEvent,
|
||||
testing::{InMemorySocialRepository, NoopEventPublisher},
|
||||
testing::{InMemoryStore, NoopEventPublisher},
|
||||
value_objects::{FollowStatus, FollowTarget, SocialIdentity, UserId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
@@ -14,11 +14,11 @@ use crate::social::{
|
||||
};
|
||||
|
||||
fn make_cmd_deps() -> (
|
||||
Arc<InMemorySocialRepository>,
|
||||
Arc<InMemoryStore>,
|
||||
Arc<NoopEventPublisher>,
|
||||
SocialCommandDeps,
|
||||
) {
|
||||
let social = InMemorySocialRepository::new();
|
||||
let social = InMemoryStore::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let deps = SocialCommandDeps {
|
||||
social_command: Arc::clone(&social) as _,
|
||||
@@ -359,7 +359,7 @@ async fn get_following_returns_accepted_targets() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_accepted_followers() {
|
||||
let social = InMemorySocialRepository::new();
|
||||
let social = InMemoryStore::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let cmd_deps = SocialCommandDeps {
|
||||
social_command: Arc::clone(&social) as _,
|
||||
@@ -403,7 +403,7 @@ async fn returns_accepted_followers() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_only_pending_followers() {
|
||||
let social = InMemorySocialRepository::new();
|
||||
let social = InMemoryStore::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let cmd_deps = SocialCommandDeps {
|
||||
social_command: Arc::clone(&social) as _,
|
||||
|
||||
@@ -7,7 +7,7 @@ use domain::{
|
||||
ports::{
|
||||
BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, SocialCommand, UserRepository,
|
||||
},
|
||||
testing::InMemoryUserRepository,
|
||||
testing::InMemoryStore,
|
||||
value_objects::{
|
||||
FollowRelation, FollowStatus, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity,
|
||||
UserId,
|
||||
@@ -19,7 +19,7 @@ use crate::social::local_service::LocalSocialService;
|
||||
|
||||
// ── Fakes ────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// No fake exists anywhere for FollowCommand/FollowQuery. `InMemorySocialRepository`
|
||||
// No fake exists anywhere for FollowCommand/FollowQuery. `InMemoryStore`
|
||||
// (domain::testing) implements SocialCommand/FollowGraphQuery/BlockQuery — the same
|
||||
// level as LocalSocialService itself — so it cannot stand in as its dependency.
|
||||
// A single struct backs both ports, mirroring how one database table serves both
|
||||
@@ -246,7 +246,7 @@ fn instance() -> InstanceIdentity {
|
||||
InstanceIdentity::new("http://md.example")
|
||||
}
|
||||
|
||||
async fn register_user(repo: &Arc<InMemoryUserRepository>, username: &str) -> UserId {
|
||||
async fn register_user(repo: &Arc<InMemoryStore>, username: &str) -> UserId {
|
||||
let user = test_user(username);
|
||||
let id = user.id().clone();
|
||||
repo.save(&user).await.unwrap();
|
||||
@@ -254,7 +254,7 @@ async fn register_user(repo: &Arc<InMemoryUserRepository>, username: &str) -> Us
|
||||
}
|
||||
|
||||
fn service(
|
||||
user_repo: Arc<InMemoryUserRepository>,
|
||||
user_repo: Arc<InMemoryStore>,
|
||||
follow_store: &Arc<FollowFakeStore>,
|
||||
instance: InstanceIdentity,
|
||||
) -> LocalSocialService {
|
||||
@@ -270,7 +270,7 @@ fn service(
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_local_user_writes_both_sides() {
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
let user_repo = InMemoryStore::new();
|
||||
let bob_id = register_user(&user_repo, "bob").await;
|
||||
let alice_id = register_user(&user_repo, "alice").await;
|
||||
let follow_store = FollowFakeStore::new();
|
||||
@@ -302,7 +302,7 @@ async fn follow_local_user_writes_both_sides() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_self_is_rejected() {
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
let user_repo = InMemoryStore::new();
|
||||
let alice_id = register_user(&user_repo, "alice").await;
|
||||
let follow_store = FollowFakeStore::new();
|
||||
let svc = service(user_repo, &follow_store, instance());
|
||||
@@ -335,7 +335,7 @@ async fn follow_self_is_rejected() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_remote_target_errors_rather_than_silently_succeeding() {
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
let user_repo = InMemoryStore::new();
|
||||
let alice_id = register_user(&user_repo, "alice").await;
|
||||
let follow_store = FollowFakeStore::new();
|
||||
let svc = service(user_repo, &follow_store, instance());
|
||||
@@ -376,7 +376,7 @@ async fn reject_follow_updates_follower_status_but_removes_the_follow_row() {
|
||||
// the follow row outright. A test that only checked the follower row would
|
||||
// pass against a reject_follow "corrected" into accept_follow's symmetry —
|
||||
// the second assertion is the one that carries the weight.
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
let user_repo = InMemoryStore::new();
|
||||
let owner_id = register_user(&user_repo, "owner").await;
|
||||
let requester_id = register_user(&user_repo, "requester").await;
|
||||
let follow_store = FollowFakeStore::new();
|
||||
@@ -422,7 +422,7 @@ async fn reject_follow_updates_follower_status_but_removes_the_follow_row() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn block_errors_and_get_blocked_is_empty() {
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
let user_repo = InMemoryStore::new();
|
||||
let alice_id = register_user(&user_repo, "alice").await;
|
||||
let follow_store = FollowFakeStore::new();
|
||||
let svc = service(user_repo, &follow_store, instance());
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::{
|
||||
InMemoryGoalRepository, InMemorySocialRepository, InMemoryWrapUpRepository,
|
||||
InMemoryWrapUpStatsQuery, NoopFederationAdminQuery,
|
||||
InMemoryStore, InMemoryWrapUpRepository, InMemoryWrapUpStatsQuery, NoopFederationAdminQuery,
|
||||
};
|
||||
use domain::{
|
||||
ports::{
|
||||
@@ -17,11 +16,7 @@ use domain::{
|
||||
testing::{
|
||||
FakeAuthService, FakeDiaryQuery, FakeDocumentParser, FakeMetadataClient,
|
||||
FakePasswordHasher, FakePersonQuery, FakePosterFetcher, FakeSearchCommand, FakeSearchPort,
|
||||
FakeStatsRepository, InMemoryImportProfileRepository, InMemoryImportSessionRepository,
|
||||
InMemoryMovieProfileRepository, InMemoryMovieRepository, InMemoryProfileFieldsRepo,
|
||||
InMemoryRefreshSessionRepository, InMemoryReviewRepository, InMemoryUserRepository,
|
||||
InMemoryUserSettingsRepository, InMemoryWatchEventRepository, InMemoryWatchlistRepository,
|
||||
InMemoryWebhookTokenRepository, NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter,
|
||||
FakeStatsRepository, NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter,
|
||||
PanicPersonCommand,
|
||||
},
|
||||
};
|
||||
@@ -89,14 +84,11 @@ impl Default for TestContextBuilder {
|
||||
|
||||
impl TestContextBuilder {
|
||||
pub fn new() -> Self {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let goals = InMemoryGoalRepository::new();
|
||||
let social = InMemorySocialRepository::new();
|
||||
let store = InMemoryStore::new();
|
||||
Self {
|
||||
movie_command: Arc::clone(&movies) as _,
|
||||
movie_query: movies as _,
|
||||
review_repo: InMemoryReviewRepository::new(),
|
||||
movie_command: Arc::clone(&store) as _,
|
||||
movie_query: Arc::clone(&store) as _,
|
||||
review_repo: Arc::clone(&store) as _,
|
||||
diary_repo: FakeDiaryQuery::new(),
|
||||
diary_exporter: Arc::new(PanicDiaryExporter),
|
||||
document_parser: Arc::new(FakeDocumentParser),
|
||||
@@ -107,30 +99,30 @@ impl TestContextBuilder {
|
||||
event_publisher: NoopEventPublisher::new(),
|
||||
auth_service: Arc::new(FakeAuthService),
|
||||
password_hasher: Arc::new(FakePasswordHasher),
|
||||
user_repo: InMemoryUserRepository::new(),
|
||||
import_session_repo: InMemoryImportSessionRepository::new(),
|
||||
import_profile_repo: InMemoryImportProfileRepository::new(),
|
||||
movie_profile_repo: InMemoryMovieProfileRepository::new(),
|
||||
watchlist_repo: InMemoryWatchlistRepository::new(),
|
||||
watch_event_command: Arc::clone(&watch_events) as _,
|
||||
watch_event_query: watch_events as _,
|
||||
webhook_token_repo: InMemoryWebhookTokenRepository::new(),
|
||||
profile_fields_repo: InMemoryProfileFieldsRepo::new(),
|
||||
user_repo: Arc::clone(&store) as _,
|
||||
import_session_repo: Arc::clone(&store) as _,
|
||||
import_profile_repo: Arc::clone(&store) as _,
|
||||
movie_profile_repo: Arc::clone(&store) as _,
|
||||
watchlist_repo: Arc::clone(&store) as _,
|
||||
watch_event_command: Arc::clone(&store) as _,
|
||||
watch_event_query: Arc::clone(&store) as _,
|
||||
webhook_token_repo: Arc::clone(&store) as _,
|
||||
profile_fields_repo: Arc::clone(&store) as _,
|
||||
person_command: Arc::new(PanicPersonCommand),
|
||||
person_query: Arc::new(FakePersonQuery),
|
||||
search_port: Arc::new(FakeSearchPort),
|
||||
search_command: Arc::new(FakeSearchCommand),
|
||||
wrapup_stats: InMemoryWrapUpStatsQuery::new(),
|
||||
wrapup_repo: InMemoryWrapUpRepository::new(),
|
||||
goal_command: Arc::clone(&goals) as _,
|
||||
goal_query: goals as _,
|
||||
user_settings_repo: InMemoryUserSettingsRepository::new(),
|
||||
goal_command: Arc::clone(&store) as _,
|
||||
goal_query: Arc::clone(&store) as _,
|
||||
user_settings_repo: Arc::clone(&store) as _,
|
||||
review_logger: Arc::new(NoopReviewLogger),
|
||||
social_command: Arc::clone(&social) as _,
|
||||
social_query_unified: Arc::clone(&social) as _,
|
||||
block_query: Arc::clone(&social) as _,
|
||||
social_command: Arc::clone(&store) as _,
|
||||
social_query_unified: Arc::clone(&store) as _,
|
||||
block_query: Arc::clone(&store) as _,
|
||||
federation_admin: Arc::new(NoopFederationAdminQuery),
|
||||
refresh_session_repo: InMemoryRefreshSessionRepository::new(),
|
||||
refresh_session_repo: Arc::clone(&store) as _,
|
||||
config: AppConfig {
|
||||
allow_registration: true,
|
||||
base_url: "http://localhost:3000".into(),
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::test_support::register_command;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::models::UserRole;
|
||||
use domain::testing::InMemoryUserRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
|
||||
use crate::{
|
||||
auth::{commands::RegisterCommand, deps::RegisterDeps, register},
|
||||
@@ -40,7 +40,7 @@ async fn register_user(
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorize_admin_is_true_for_an_admin_user() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let users = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
let user = register_user(&b, "admin@example.com", "admin_user", UserRole::Admin).await;
|
||||
|
||||
@@ -56,7 +56,7 @@ async fn authorize_admin_is_true_for_an_admin_user() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorize_admin_is_false_for_a_regular_user() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let users = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
let user = register_user(
|
||||
&b,
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
|
||||
use domain::models::{ProfileField, User, UserProfile, UserRole};
|
||||
use domain::ports::UserRepository;
|
||||
use domain::testing::InMemoryUserRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
use domain::value_objects::{Email, PasswordHash, UserId, Username};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::{
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_profile_for_existing_user() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let users = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
let user_repo = b.user_repo.clone();
|
||||
let reg_deps = RegisterDeps {
|
||||
@@ -66,7 +66,7 @@ async fn fails_for_nonexistent_user() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_profile_with_avatar_banner_and_fields() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let users = InMemoryStore::new();
|
||||
let uid = UserId::generate();
|
||||
|
||||
let user = User::from_persistence(
|
||||
@@ -87,7 +87,7 @@ async fn returns_profile_with_avatar_banner_and_fields() {
|
||||
}],
|
||||
},
|
||||
);
|
||||
users.store.lock().unwrap().insert(uid.value(), user);
|
||||
users.seed_user(user);
|
||||
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
let user_repo = b.user_repo.clone();
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ports::UserRepository;
|
||||
use domain::testing::{InMemoryUserRepository, NoopEventPublisher};
|
||||
use domain::testing::{InMemoryStore, NoopEventPublisher};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
users::{commands::UpdateProfileCommand, deps::UpdateProfileDeps, update_profile},
|
||||
};
|
||||
|
||||
async fn register_user(b: &TestContextBuilder, users: &Arc<InMemoryUserRepository>) -> Uuid {
|
||||
async fn register_user(b: &TestContextBuilder, users: &Arc<InMemoryStore>) -> Uuid {
|
||||
let reg_deps = RegisterDeps {
|
||||
user: b.user_repo.clone(),
|
||||
password_hasher: b.password_hasher.clone(),
|
||||
@@ -32,7 +32,7 @@ async fn register_user(b: &TestContextBuilder, users: &Arc<InMemoryUserRepositor
|
||||
|
||||
#[tokio::test]
|
||||
async fn updates_display_name() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let users = InMemoryStore::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let b = TestContextBuilder::new()
|
||||
.with_users(Arc::clone(&users) as _)
|
||||
@@ -64,7 +64,7 @@ async fn updates_display_name() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_avatar_content_type() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let users = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
let deps = UpdateProfileDeps {
|
||||
user: b.user_repo.clone(),
|
||||
@@ -88,7 +88,7 @@ async fn rejects_invalid_avatar_content_type() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn uploads_avatar() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let users = InMemoryStore::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let b = TestContextBuilder::new()
|
||||
.with_users(Arc::clone(&users) as _)
|
||||
@@ -126,7 +126,7 @@ async fn uploads_avatar() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn uploads_banner() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let users = InMemoryStore::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let b = TestContextBuilder::new()
|
||||
.with_users(Arc::clone(&users) as _)
|
||||
@@ -185,7 +185,7 @@ async fn fails_for_nonexistent_user() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_invalid_banner_content_type() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let users = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
let deps = UpdateProfileDeps {
|
||||
user: b.user_repo.clone(),
|
||||
@@ -209,7 +209,7 @@ async fn rejects_invalid_banner_content_type() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn text_only_update_emits_user_updated_no_image_stored() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let users = InMemoryStore::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let b = TestContextBuilder::new()
|
||||
.with_users(Arc::clone(&users) as _)
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use domain::events::DomainEvent;
|
||||
use domain::models::ProfileField;
|
||||
use domain::testing::{InMemoryProfileFieldsRepo, NoopEventPublisher};
|
||||
use domain::testing::{InMemoryStore, NoopEventPublisher};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
@@ -14,7 +14,7 @@ use crate::{
|
||||
|
||||
#[tokio::test]
|
||||
async fn saves_profile_fields() {
|
||||
let fields_repo = InMemoryProfileFieldsRepo::new();
|
||||
let fields_repo = InMemoryStore::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let b = TestContextBuilder::new()
|
||||
.with_profile_fields(Arc::clone(&fields_repo) as _)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::test_support::update_user_settings_command;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::testing::InMemoryUserSettingsRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
@@ -15,7 +15,7 @@ use crate::{
|
||||
|
||||
#[tokio::test]
|
||||
async fn updates_federate_goals() {
|
||||
let settings_repo = InMemoryUserSettingsRepository::new();
|
||||
let settings_repo = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_user_settings(Arc::clone(&settings_repo) as _);
|
||||
let user_settings = b.user_settings_repo.clone();
|
||||
let uid = Uuid::nil();
|
||||
@@ -42,7 +42,7 @@ async fn updates_federate_goals() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn updates_federate_reviews() {
|
||||
let settings_repo = InMemoryUserSettingsRepository::new();
|
||||
let settings_repo = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_user_settings(Arc::clone(&settings_repo) as _);
|
||||
let user_settings = b.user_settings_repo.clone();
|
||||
let uid = Uuid::nil();
|
||||
@@ -69,7 +69,7 @@ async fn updates_federate_reviews() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn updates_federate_watchlist() {
|
||||
let settings_repo = InMemoryUserSettingsRepository::new();
|
||||
let settings_repo = InMemoryStore::new();
|
||||
let b = TestContextBuilder::new().with_user_settings(Arc::clone(&settings_repo) as _);
|
||||
let user_settings = b.user_settings_repo.clone();
|
||||
let uid = Uuid::nil();
|
||||
|
||||
@@ -4,15 +4,12 @@ use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
ports::MovieCommand,
|
||||
testing::{InMemoryMovieRepository, InMemoryWatchlistRepository, NoopEventPublisher},
|
||||
testing::{InMemoryStore, NoopEventPublisher},
|
||||
};
|
||||
|
||||
use crate::watchlist::{add, commands::AddToWatchlistCommand, deps::WatchlistAddDeps};
|
||||
|
||||
fn make_deps(
|
||||
movies: Arc<InMemoryMovieRepository>,
|
||||
watchlist: Arc<InMemoryWatchlistRepository>,
|
||||
) -> WatchlistAddDeps {
|
||||
fn make_deps(movies: Arc<InMemoryStore>, watchlist: Arc<InMemoryStore>) -> WatchlistAddDeps {
|
||||
WatchlistAddDeps {
|
||||
movie_command: Arc::clone(&movies) as _,
|
||||
movie_query: movies,
|
||||
@@ -24,8 +21,8 @@ fn make_deps(
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_to_watchlist_resolves_and_saves() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let movies = InMemoryStore::new();
|
||||
let watchlist = Arc::clone(&movies);
|
||||
|
||||
let movie = test_movie_with_year("The Thing", 1982);
|
||||
let movie_uuid = movie.id().value();
|
||||
@@ -37,13 +34,13 @@ async fn test_add_to_watchlist_resolves_and_saves() {
|
||||
|
||||
add::execute(&deps, cmd).await.unwrap();
|
||||
|
||||
assert_eq!(watchlist.count(), 1);
|
||||
assert_eq!(watchlist.watchlist_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_to_watchlist_already_present_is_idempotent() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let movies = InMemoryStore::new();
|
||||
let watchlist = Arc::clone(&movies);
|
||||
|
||||
let movie = test_movie_with_year("RoboCop", 1987);
|
||||
let movie_uuid = movie.id().value();
|
||||
@@ -57,13 +54,17 @@ async fn test_add_to_watchlist_already_present_is_idempotent() {
|
||||
add::execute(&deps, make_cmd()).await.unwrap();
|
||||
add::execute(&deps, make_cmd()).await.unwrap();
|
||||
|
||||
assert_eq!(watchlist.count(), 1, "idempotent add should not duplicate");
|
||||
assert_eq!(
|
||||
watchlist.watchlist_count(),
|
||||
1,
|
||||
"idempotent add should not duplicate"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_to_watchlist_with_manual_movie() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let movies = InMemoryStore::new();
|
||||
let watchlist = Arc::clone(&movies);
|
||||
|
||||
let deps = make_deps(Arc::clone(&movies), Arc::clone(&watchlist));
|
||||
|
||||
@@ -74,14 +75,14 @@ async fn test_add_to_watchlist_with_manual_movie() {
|
||||
|
||||
add::execute(&deps, cmd).await.unwrap();
|
||||
|
||||
assert_eq!(watchlist.count(), 1);
|
||||
assert_eq!(movies.count(), 1);
|
||||
assert_eq!(watchlist.watchlist_count(), 1);
|
||||
assert_eq!(movies.movie_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_to_watchlist_movie_not_found_by_id() {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let movies = InMemoryStore::new();
|
||||
let watchlist = Arc::clone(&movies);
|
||||
|
||||
let deps = make_deps(Arc::clone(&movies), Arc::clone(&watchlist));
|
||||
|
||||
|
||||
@@ -3,10 +3,7 @@ use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::testing::{
|
||||
InMemoryRemoteWatchlistRepository, InMemoryUserRepository, InMemoryWatchlistRepository,
|
||||
PanicRemoteWatchlistRepository, PanicWatchlistRepository,
|
||||
};
|
||||
use domain::testing::{InMemoryStore, PanicRemoteWatchlistRepository, PanicWatchlistRepository};
|
||||
use domain::value_objects::Email;
|
||||
|
||||
use crate::auth::deps::RegisterDeps;
|
||||
@@ -37,7 +34,7 @@ async fn watchlist_for_owner_returns_local_when_user_row_exists() {
|
||||
|
||||
let deps = GetWatchlistForOwnerDeps {
|
||||
user: b.user_repo.clone(),
|
||||
watchlist: InMemoryWatchlistRepository::new(),
|
||||
watchlist: InMemoryStore::new(),
|
||||
remote_watchlist: Arc::new(PanicRemoteWatchlistRepository),
|
||||
};
|
||||
|
||||
@@ -52,20 +49,18 @@ async fn watchlist_for_owner_returns_local_when_user_row_exists() {
|
||||
async fn watchlist_for_owner_returns_remote_when_no_local_row() {
|
||||
let owner_id = Uuid::new_v4();
|
||||
|
||||
let remote = InMemoryRemoteWatchlistRepository::with_entries(vec![
|
||||
domain::models::RemoteWatchlistEntry {
|
||||
ap_id: "https://remote.example/ap/1".into(),
|
||||
actor_url: "https://remote.example/users/owner".into(),
|
||||
movie_title: "Remote Movie".into(),
|
||||
release_year: 2021,
|
||||
external_metadata_id: None,
|
||||
poster_url: None,
|
||||
added_at: chrono::Utc::now(),
|
||||
},
|
||||
]);
|
||||
let remote = InMemoryStore::with_remote_watchlist(vec![domain::models::RemoteWatchlistEntry {
|
||||
ap_id: "https://remote.example/ap/1".into(),
|
||||
actor_url: "https://remote.example/users/owner".into(),
|
||||
movie_title: "Remote Movie".into(),
|
||||
release_year: 2021,
|
||||
external_metadata_id: None,
|
||||
poster_url: None,
|
||||
added_at: chrono::Utc::now(),
|
||||
}]);
|
||||
|
||||
let deps = GetWatchlistForOwnerDeps {
|
||||
user: InMemoryUserRepository::new(),
|
||||
user: InMemoryStore::new(),
|
||||
watchlist: Arc::new(PanicWatchlistRepository),
|
||||
remote_watchlist: remote,
|
||||
};
|
||||
@@ -94,7 +89,7 @@ async fn watchlist_for_owner_local_arm_validates_limit() {
|
||||
|
||||
let deps = GetWatchlistForOwnerDeps {
|
||||
user: b.user_repo.clone(),
|
||||
watchlist: InMemoryWatchlistRepository::new(),
|
||||
watchlist: InMemoryStore::new(),
|
||||
remote_watchlist: Arc::new(PanicRemoteWatchlistRepository),
|
||||
};
|
||||
|
||||
@@ -111,10 +106,10 @@ async fn watchlist_for_owner_local_arm_validates_limit() {
|
||||
#[tokio::test]
|
||||
async fn watchlist_for_owner_remote_arm_ignores_invalid_limit() {
|
||||
let owner_id = Uuid::new_v4();
|
||||
let remote = InMemoryRemoteWatchlistRepository::new();
|
||||
let remote = InMemoryStore::new();
|
||||
|
||||
let deps = GetWatchlistForOwnerDeps {
|
||||
user: InMemoryUserRepository::new(),
|
||||
user: InMemoryStore::new(),
|
||||
watchlist: Arc::new(PanicWatchlistRepository),
|
||||
remote_watchlist: remote,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use domain::models::WatchlistEntry;
|
||||
use domain::ports::WatchlistRepository;
|
||||
use domain::testing::InMemoryWatchlistRepository;
|
||||
use domain::testing::InMemoryStore;
|
||||
use domain::value_objects::{MovieId, UserId};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::watchlist::{deps::IsOnWatchlistDeps, is_on, queries::IsOnWatchlistQue
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_true_when_present() {
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let watchlist = InMemoryStore::new();
|
||||
let uid = Uuid::new_v4();
|
||||
let mid = Uuid::new_v4();
|
||||
watchlist
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
use domain::events::DomainEvent;
|
||||
use domain::models::WatchlistEntry;
|
||||
use domain::ports::WatchlistRepository;
|
||||
use domain::testing::{InMemoryWatchlistRepository, NoopEventPublisher};
|
||||
use domain::testing::{InMemoryStore, NoopEventPublisher};
|
||||
use domain::value_objects::{MovieId, UserId};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -14,7 +14,7 @@ use crate::watchlist::{
|
||||
|
||||
#[tokio::test]
|
||||
async fn removes_entry_and_emits_event() {
|
||||
let watchlist = InMemoryWatchlistRepository::new();
|
||||
let watchlist = InMemoryStore::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let uid = Uuid::new_v4();
|
||||
let mid = Uuid::new_v4();
|
||||
@@ -40,7 +40,7 @@ async fn removes_entry_and_emits_event() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(watchlist.count(), 0);
|
||||
assert_eq!(watchlist.watchlist_count(), 0);
|
||||
let published = events.published();
|
||||
assert!(
|
||||
published
|
||||
|
||||
@@ -4,13 +4,8 @@ use application::config::AppConfig;
|
||||
use domain::testing::{
|
||||
FakeAuthService, FakeDocumentParser, FakeMetadataClient, FakePasswordHasher, FakePersonQuery,
|
||||
FakePosterFetcher, FakeSearchCommand, FakeSearchPort, FakeStatsRepository,
|
||||
InMemoryGoalRepository, InMemoryImportProfileRepository, InMemoryImportSessionRepository,
|
||||
InMemoryInsightsQuery, InMemoryMovieProfileRepository, InMemoryMovieRepository,
|
||||
InMemoryProfileFieldsRepo, InMemoryRefreshSessionRepository, InMemoryReviewRepository,
|
||||
InMemorySocialRepository, InMemoryUserRepository, InMemoryUserSettingsRepository,
|
||||
InMemoryWatchEventRepository, InMemoryWatchlistRepository, InMemoryWebhookTokenRepository,
|
||||
InMemoryWrapUpRepository, InMemoryWrapUpStatsQuery, NoopEventPublisher,
|
||||
NoopFederationAdminQuery, NoopObjectStorage, NoopRemoteGoalRepository,
|
||||
InMemoryInsightsQuery, InMemoryStore, InMemoryWrapUpRepository, InMemoryWrapUpStatsQuery,
|
||||
NoopEventPublisher, NoopFederationAdminQuery, NoopObjectStorage, NoopRemoteGoalRepository,
|
||||
NoopRemoteWatchlistRepository, PanicDiaryExporter, PanicPersonCommand,
|
||||
};
|
||||
|
||||
@@ -92,7 +87,7 @@ impl domain::ports::ImageRefQuery for NoopImageRefQuery {
|
||||
}
|
||||
|
||||
/// `DatabaseOutput::follow_command`/`follow_query` need a fake independent of
|
||||
/// `InMemorySocialRepository` (domain::testing), which implements
|
||||
/// `InMemoryStore` (domain::testing), which implements
|
||||
/// `SocialCommand`/`FollowGraphQuery`/`BlockQuery` — the composite ports these
|
||||
/// two feed, one level up — so it cannot stand in as their dependency. These
|
||||
/// fixture fields are never exercised by `build_deps`/`build_worker_deps`
|
||||
@@ -223,30 +218,30 @@ impl domain::ports::FollowQuery for NoopFollowQuery {
|
||||
}
|
||||
|
||||
fn test_repositories() -> Repositories {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let goals = InMemoryGoalRepository::new();
|
||||
let social = InMemorySocialRepository::new();
|
||||
let movies = InMemoryStore::new();
|
||||
let watch_events = Arc::clone(&movies);
|
||||
let goals = Arc::clone(&movies);
|
||||
let social = Arc::clone(&movies);
|
||||
|
||||
Repositories {
|
||||
movie_command: Arc::clone(&movies) as _,
|
||||
movie_query: movies as _,
|
||||
review: InMemoryReviewRepository::new(),
|
||||
review: InMemoryStore::new(),
|
||||
diary: domain::testing::FakeDiaryQuery::new(),
|
||||
stats: FakeStatsRepository::new(),
|
||||
user: InMemoryUserRepository::new(),
|
||||
import_session: InMemoryImportSessionRepository::new(),
|
||||
import_profile: InMemoryImportProfileRepository::new(),
|
||||
movie_profile: InMemoryMovieProfileRepository::new(),
|
||||
watchlist: InMemoryWatchlistRepository::new(),
|
||||
user: InMemoryStore::new(),
|
||||
import_session: InMemoryStore::new(),
|
||||
import_profile: InMemoryStore::new(),
|
||||
movie_profile: InMemoryStore::new(),
|
||||
watchlist: InMemoryStore::new(),
|
||||
watch_event_command: Arc::clone(&watch_events) as _,
|
||||
watch_event_query: watch_events as _,
|
||||
webhook_token: InMemoryWebhookTokenRepository::new(),
|
||||
webhook_token: InMemoryStore::new(),
|
||||
person_command: Arc::new(PanicPersonCommand),
|
||||
person_query: Arc::new(FakePersonQuery),
|
||||
search_port: Arc::new(FakeSearchPort),
|
||||
search_command: Arc::new(FakeSearchCommand),
|
||||
profile_fields: InMemoryProfileFieldsRepo::new(),
|
||||
profile_fields: InMemoryStore::new(),
|
||||
remote_watchlist: Arc::new(NoopRemoteWatchlistRepository),
|
||||
social_command: Arc::clone(&social) as _,
|
||||
follow_graph: Arc::clone(&social) as _,
|
||||
@@ -257,9 +252,9 @@ fn test_repositories() -> Repositories {
|
||||
insights: InMemoryInsightsQuery::new(),
|
||||
goal_command: Arc::clone(&goals) as _,
|
||||
goal_query: goals as _,
|
||||
user_settings: InMemoryUserSettingsRepository::new(),
|
||||
user_settings: InMemoryStore::new(),
|
||||
remote_goal: Arc::new(NoopRemoteGoalRepository),
|
||||
refresh_session: InMemoryRefreshSessionRepository::new(),
|
||||
refresh_session: InMemoryStore::new(),
|
||||
federated_profile: None,
|
||||
}
|
||||
}
|
||||
@@ -274,30 +269,30 @@ fn test_db_pool() -> crate::DbPool {
|
||||
}
|
||||
|
||||
fn test_database_output() -> DatabaseOutput {
|
||||
let movies = InMemoryMovieRepository::new();
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
let goals = InMemoryGoalRepository::new();
|
||||
let user_settings = InMemoryUserSettingsRepository::new();
|
||||
let movies = InMemoryStore::new();
|
||||
let watch_events = Arc::clone(&movies);
|
||||
let goals = Arc::clone(&movies);
|
||||
let user_settings = Arc::clone(&movies);
|
||||
|
||||
DatabaseOutput {
|
||||
movie_command: Arc::clone(&movies) as _,
|
||||
movie_query: movies as _,
|
||||
review: InMemoryReviewRepository::new(),
|
||||
review: InMemoryStore::new(),
|
||||
diary: domain::testing::FakeDiaryQuery::new(),
|
||||
stats: FakeStatsRepository::new(),
|
||||
user: InMemoryUserRepository::new(),
|
||||
import_session: InMemoryImportSessionRepository::new(),
|
||||
import_profile: InMemoryImportProfileRepository::new(),
|
||||
movie_profile: InMemoryMovieProfileRepository::new(),
|
||||
watchlist: InMemoryWatchlistRepository::new(),
|
||||
user: InMemoryStore::new(),
|
||||
import_session: InMemoryStore::new(),
|
||||
import_profile: InMemoryStore::new(),
|
||||
movie_profile: InMemoryStore::new(),
|
||||
watchlist: InMemoryStore::new(),
|
||||
watch_event_command: Arc::clone(&watch_events) as _,
|
||||
watch_event_query: watch_events as _,
|
||||
webhook_token: InMemoryWebhookTokenRepository::new(),
|
||||
webhook_token: InMemoryStore::new(),
|
||||
person_command: Arc::new(PanicPersonCommand),
|
||||
person_query: Arc::new(FakePersonQuery),
|
||||
search_port: Arc::new(FakeSearchPort),
|
||||
search_command: Arc::new(FakeSearchCommand),
|
||||
profile_fields: InMemoryProfileFieldsRepo::new(),
|
||||
profile_fields: InMemoryStore::new(),
|
||||
ap_content: Arc::new(NoopLocalApContentQuery),
|
||||
wrapup_stats: InMemoryWrapUpStatsQuery::new(),
|
||||
wrapup_repo: InMemoryWrapUpRepository::new(),
|
||||
@@ -307,7 +302,7 @@ fn test_database_output() -> DatabaseOutput {
|
||||
user_settings: Arc::clone(&user_settings) as _,
|
||||
federation_settings: user_settings as _,
|
||||
remote_goal: Arc::new(NoopRemoteGoalRepository),
|
||||
refresh_session: InMemoryRefreshSessionRepository::new(),
|
||||
refresh_session: InMemoryStore::new(),
|
||||
deduplicator: Arc::new(NoopMovieDeduplicator),
|
||||
image_ref_command: Arc::new(NoopImageRefCommand),
|
||||
image_ref_query: Arc::new(NoopImageRefQuery),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::testing::test_review;
|
||||
use crate::testing::{test_review, test_watched_at};
|
||||
use crate::value_objects::{Comment, MovieId, Rating, UserId, WatchMedium};
|
||||
|
||||
use super::*;
|
||||
@@ -11,15 +11,28 @@ fn review_with_medium(watch_medium: Option<WatchMedium>) -> Review {
|
||||
})
|
||||
}
|
||||
|
||||
fn new_review_with_medium(watch_medium: Option<WatchMedium>) -> Review {
|
||||
Review::new(
|
||||
MovieId::generate(),
|
||||
UserId::generate(),
|
||||
Rating::new(4).unwrap(),
|
||||
Some(Comment::new("great film".into()).unwrap()),
|
||||
test_watched_at(),
|
||||
watch_medium,
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_review_stores_watch_medium() {
|
||||
let review = review_with_medium(Some(WatchMedium::Cinema));
|
||||
let review = new_review_with_medium(Some(WatchMedium::Cinema));
|
||||
assert_eq!(review.watch_medium(), Some(&WatchMedium::Cinema));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_review_without_medium() {
|
||||
let review = review_with_medium(None);
|
||||
let review = new_review_with_medium(None);
|
||||
assert_eq!(review.watch_medium(), None);
|
||||
}
|
||||
|
||||
|
||||
26
crates/domain/src/testing/fakes/mod.rs
Normal file
26
crates/domain/src/testing/fakes/mod.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
mod insights;
|
||||
mod noops;
|
||||
mod panics;
|
||||
mod store;
|
||||
mod store_goal;
|
||||
mod store_import;
|
||||
mod store_movie;
|
||||
mod store_review;
|
||||
mod store_session;
|
||||
mod store_social;
|
||||
mod store_user;
|
||||
mod store_watch_event;
|
||||
mod store_watchlist;
|
||||
mod stubs;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod wrapup;
|
||||
|
||||
pub use insights::*;
|
||||
pub use noops::*;
|
||||
pub use panics::*;
|
||||
pub use store::{
|
||||
InMemoryStore, MOVIE_CASCADE_TABLES, USER_CASCADE_TABLES, USER_CASCADE_TABLES_OUTSIDE_STORE,
|
||||
};
|
||||
pub use stubs::*;
|
||||
pub use wrapup::*;
|
||||
171
crates/domain/src/testing/fakes/store.rs
Normal file
171
crates/domain/src/testing/fakes/store.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{
|
||||
Goal, ImportProfile, ImportSession, Movie, MovieProfile, ProfileField, RefreshSession,
|
||||
RemoteWatchlistEntry, Review, User, UserSettings, WatchEvent, WatchlistEntry, WebhookToken,
|
||||
};
|
||||
use crate::value_objects::SocialIdentity;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) enum FollowState {
|
||||
Pending,
|
||||
Accepted,
|
||||
}
|
||||
|
||||
/// Tables whose rows the schema removes when a user row is deleted
|
||||
/// (`ON DELETE CASCADE` on a column referencing `users(id)`), and which
|
||||
/// `InMemoryStore` models. The sqlite adapter's cascade test asserts the same list.
|
||||
pub const USER_CASCADE_TABLES: &[&str] = &[
|
||||
"watchlist_entries",
|
||||
"goals",
|
||||
"user_settings",
|
||||
"user_profile_fields",
|
||||
"webhook_tokens",
|
||||
"watch_events",
|
||||
"ap_followers",
|
||||
"ap_following",
|
||||
"blocked_actors",
|
||||
];
|
||||
|
||||
/// Tables that cascade from a user but whose aggregate this store does not hold
|
||||
/// (they belong to the federation crate).
|
||||
pub const USER_CASCADE_TABLES_OUTSIDE_STORE: &[&str] = &["ap_local_actors"];
|
||||
|
||||
/// Tables whose rows the schema removes when a movie row is deleted.
|
||||
pub const MOVIE_CASCADE_TABLES: &[&str] = &[
|
||||
"watchlist_entries",
|
||||
"movie_profiles",
|
||||
"movie_genres",
|
||||
"movie_keywords",
|
||||
"movie_cast",
|
||||
"movie_crew",
|
||||
];
|
||||
|
||||
/// One in-memory implementation of every persistence port, mirroring the
|
||||
/// relational constraints of the sqlite schema (cascades, uniqueness, FK
|
||||
/// restrictions). Share one `Arc<InMemoryStore>` across all port slots.
|
||||
pub struct InMemoryStore {
|
||||
pub(super) movies: RwLock<HashMap<Uuid, Movie>>,
|
||||
pub(super) movie_profiles: RwLock<HashMap<Uuid, MovieProfile>>,
|
||||
pub(super) reviews: RwLock<HashMap<Uuid, Review>>,
|
||||
pub(super) users: RwLock<HashMap<Uuid, User>>,
|
||||
pub(super) profile_fields: RwLock<HashMap<Uuid, Vec<ProfileField>>>,
|
||||
pub(super) user_settings: RwLock<HashMap<Uuid, UserSettings>>,
|
||||
pub(super) refresh_sessions: RwLock<Vec<RefreshSession>>,
|
||||
pub(super) watchlist: RwLock<HashMap<(Uuid, Uuid), WatchlistEntry>>,
|
||||
pub(super) remote_watchlist: RwLock<Vec<RemoteWatchlistEntry>>,
|
||||
pub(super) goals: RwLock<HashMap<Uuid, Goal>>,
|
||||
pub(super) watch_events: RwLock<Vec<WatchEvent>>,
|
||||
pub(super) webhook_tokens: RwLock<Vec<WebhookToken>>,
|
||||
pub(super) import_sessions: RwLock<HashMap<Uuid, ImportSession>>,
|
||||
pub(super) import_profiles: RwLock<HashMap<Uuid, ImportProfile>>,
|
||||
pub(super) follows: RwLock<Vec<(Uuid, SocialIdentity, FollowState)>>,
|
||||
pub(super) blocked: RwLock<Vec<(Uuid, SocialIdentity)>>,
|
||||
}
|
||||
|
||||
impl InMemoryStore {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
movies: RwLock::default(),
|
||||
movie_profiles: RwLock::default(),
|
||||
reviews: RwLock::default(),
|
||||
users: RwLock::default(),
|
||||
profile_fields: RwLock::default(),
|
||||
user_settings: RwLock::default(),
|
||||
refresh_sessions: RwLock::default(),
|
||||
watchlist: RwLock::default(),
|
||||
remote_watchlist: RwLock::default(),
|
||||
goals: RwLock::default(),
|
||||
watch_events: RwLock::default(),
|
||||
webhook_tokens: RwLock::default(),
|
||||
import_sessions: RwLock::default(),
|
||||
import_profiles: RwLock::default(),
|
||||
follows: RwLock::default(),
|
||||
blocked: RwLock::default(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn with_remote_watchlist(entries: Vec<RemoteWatchlistEntry>) -> Arc<Self> {
|
||||
let store = Self::new();
|
||||
*store.remote_watchlist.write().unwrap() = entries;
|
||||
store
|
||||
}
|
||||
|
||||
pub fn movie_count(&self) -> usize {
|
||||
self.movies.read().unwrap().len()
|
||||
}
|
||||
|
||||
pub fn movie_profile_count(&self) -> usize {
|
||||
self.movie_profiles.read().unwrap().len()
|
||||
}
|
||||
|
||||
pub fn review_count(&self) -> usize {
|
||||
self.reviews.read().unwrap().len()
|
||||
}
|
||||
|
||||
pub fn user_count(&self) -> usize {
|
||||
self.users.read().unwrap().len()
|
||||
}
|
||||
|
||||
pub fn watchlist_count(&self) -> usize {
|
||||
self.watchlist.read().unwrap().len()
|
||||
}
|
||||
|
||||
pub fn goal_count(&self) -> usize {
|
||||
self.goals.read().unwrap().len()
|
||||
}
|
||||
|
||||
pub fn import_session_count(&self) -> usize {
|
||||
self.import_sessions.read().unwrap().len()
|
||||
}
|
||||
|
||||
pub fn refresh_session_count(&self) -> usize {
|
||||
self.refresh_sessions.read().unwrap().len()
|
||||
}
|
||||
|
||||
/// Inserts a user bypassing the uniqueness checks of `UserRepository::save`.
|
||||
pub fn seed_user(&self, user: User) {
|
||||
self.users.write().unwrap().insert(user.id().value(), user);
|
||||
}
|
||||
|
||||
/// Row count of the sqlite table of the same name, for the tables this
|
||||
/// store models. Panics on a table the store does not hold.
|
||||
pub fn rows_in(&self, table: &str) -> usize {
|
||||
match table {
|
||||
"watchlist_entries" => self.watchlist_count(),
|
||||
"goals" => self.goal_count(),
|
||||
"user_settings" => self.user_settings.read().unwrap().len(),
|
||||
"user_profile_fields" => self
|
||||
.profile_fields
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.map(Vec::len)
|
||||
.sum(),
|
||||
"webhook_tokens" => self.webhook_tokens.read().unwrap().len(),
|
||||
"watch_events" => self.watch_events.read().unwrap().len(),
|
||||
"ap_following" => self.follows.read().unwrap().len(),
|
||||
"ap_followers" => self
|
||||
.follows
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(_, target, _)| matches!(target, SocialIdentity::Local(_)))
|
||||
.count(),
|
||||
"blocked_actors" => self.blocked.read().unwrap().len(),
|
||||
"movie_profiles" => self.movie_profile_count(),
|
||||
"movie_genres" => self.sum_profiles(|p| p.genres.len()),
|
||||
"movie_keywords" => self.sum_profiles(|p| p.keywords.len()),
|
||||
"movie_cast" => self.sum_profiles(|p| p.cast.len()),
|
||||
"movie_crew" => self.sum_profiles(|p| p.crew.len()),
|
||||
other => panic!("InMemoryStore does not model table {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn sum_profiles(&self, f: impl Fn(&MovieProfile) -> usize) -> usize {
|
||||
self.movie_profiles.read().unwrap().values().map(f).sum()
|
||||
}
|
||||
}
|
||||
70
crates/domain/src/testing/fakes/store_goal.rs
Normal file
70
crates/domain/src/testing/fakes/store_goal.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
models::Goal,
|
||||
ports::{GoalCommand, GoalQuery},
|
||||
value_objects::{GoalId, UserId},
|
||||
};
|
||||
|
||||
use super::InMemoryStore;
|
||||
|
||||
#[async_trait]
|
||||
impl GoalCommand for InMemoryStore {
|
||||
/// `goals` is UNIQUE(user_id, year).
|
||||
async fn save(&self, goal: &Goal) -> Result<(), DomainError> {
|
||||
let mut goals = self.goals.write().unwrap();
|
||||
let duplicate = goals.values().any(|g| {
|
||||
g.id() == goal.id() || (g.user_id() == goal.user_id() && g.year() == goal.year())
|
||||
});
|
||||
if duplicate {
|
||||
return Err(DomainError::InfrastructureError(
|
||||
"Database operation failed".into(),
|
||||
));
|
||||
}
|
||||
goals.insert(goal.id().value(), goal.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update(&self, goal: &Goal) -> Result<(), DomainError> {
|
||||
let mut store = self.goals.write().unwrap();
|
||||
match store.entry(goal.id().value()) {
|
||||
std::collections::hash_map::Entry::Occupied(mut e) => {
|
||||
e.insert(goal.clone());
|
||||
Ok(())
|
||||
}
|
||||
std::collections::hash_map::Entry::Vacant(_) => {
|
||||
Err(DomainError::NotFound("goal".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &GoalId, _user_id: &UserId) -> Result<(), DomainError> {
|
||||
self.goals.write().unwrap().remove(&id.value());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GoalQuery for InMemoryStore {
|
||||
async fn find_by_user_and_year(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
year: u16,
|
||||
) -> Result<Option<Goal>, DomainError> {
|
||||
let store = self.goals.read().unwrap();
|
||||
Ok(store
|
||||
.values()
|
||||
.find(|g| g.user_id().value() == user_id.value() && g.year() == year)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError> {
|
||||
let store = self.goals.read().unwrap();
|
||||
Ok(store
|
||||
.values()
|
||||
.filter(|g| g.user_id().value() == user_id.value())
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
98
crates/domain/src/testing/fakes/store_import.rs
Normal file
98
crates/domain/src/testing/fakes/store_import.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
models::{ImportProfile, ImportSession},
|
||||
ports::{ImportProfileRepository, ImportSessionRepository},
|
||||
value_objects::{ImportProfileId, ImportSessionId, UserId},
|
||||
};
|
||||
|
||||
use super::InMemoryStore;
|
||||
|
||||
#[async_trait]
|
||||
impl ImportSessionRepository for InMemoryStore {
|
||||
async fn create(&self, session: &ImportSession) -> Result<(), DomainError> {
|
||||
self.import_sessions
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(session.id.value(), session.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
id: &ImportSessionId,
|
||||
user_id: &UserId,
|
||||
) -> Result<Option<ImportSession>, DomainError> {
|
||||
let store = self.import_sessions.read().unwrap();
|
||||
Ok(store
|
||||
.get(&id.value())
|
||||
.filter(|s| s.user_id.value() == user_id.value())
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn update(&self, session: &ImportSession) -> Result<(), DomainError> {
|
||||
self.import_sessions
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(session.id.value(), session.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> {
|
||||
self.import_sessions.write().unwrap().remove(&id.value());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
let mut store = self.import_sessions.write().unwrap();
|
||||
let now = chrono::Utc::now().naive_utc();
|
||||
let before_len = store.len();
|
||||
store.retain(|_, s| s.expires_at > now);
|
||||
Ok((before_len - store.len()) as u64)
|
||||
}
|
||||
|
||||
async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
let mut store = self.import_sessions.write().unwrap();
|
||||
let now = chrono::Utc::now().naive_utc();
|
||||
store.retain(|_, s| !(s.user_id.value() == user_id.value() && s.expires_at <= now));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ImportProfileRepository for InMemoryStore {
|
||||
async fn save(&self, profile: &ImportProfile) -> Result<(), DomainError> {
|
||||
self.import_profiles
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(profile.id.value(), profile.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ImportProfile>, DomainError> {
|
||||
let store = self.import_profiles.read().unwrap();
|
||||
Ok(store
|
||||
.values()
|
||||
.filter(|p| p.user_id.value() == user_id.value())
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
id: &ImportProfileId,
|
||||
user_id: &UserId,
|
||||
) -> Result<Option<ImportProfile>, DomainError> {
|
||||
let store = self.import_profiles.read().unwrap();
|
||||
Ok(store
|
||||
.get(&id.value())
|
||||
.filter(|p| p.user_id.value() == user_id.value())
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> {
|
||||
self.import_profiles.write().unwrap().remove(&id.value());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
200
crates/domain/src/testing/fakes/store_movie.rs
Normal file
200
crates/domain/src/testing/fakes/store_movie.rs
Normal file
@@ -0,0 +1,200 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
Movie, MovieFilter, MovieProfile, MovieSummary, PersistedWatchEvent, WatchEvent,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::{MovieCommand, MovieProfileRepository, MovieQuery},
|
||||
value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear},
|
||||
};
|
||||
|
||||
use super::InMemoryStore;
|
||||
|
||||
#[async_trait]
|
||||
impl MovieCommand for InMemoryStore {
|
||||
/// `movies.external_metadata_id` is UNIQUE: another movie row holding the
|
||||
/// same external id is a constraint violation, which the sqlite adapter
|
||||
/// surfaces as an infrastructure error.
|
||||
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> {
|
||||
let mut movies = self.movies.write().unwrap();
|
||||
if let Some(external) = movie.external_metadata_id() {
|
||||
let taken = movies.values().any(|m| {
|
||||
m.id() != movie.id()
|
||||
&& m.external_metadata_id().map(|e| e.value()) == Some(external.value())
|
||||
});
|
||||
if taken {
|
||||
return Err(DomainError::InfrastructureError(
|
||||
"Database operation failed".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
movies.insert(movie.id().value(), movie.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Schema: `reviews.movie_id REFERENCES movies(id)` has no cascade, so a
|
||||
/// movie with reviews cannot be deleted. `watchlist_entries` and the
|
||||
/// `movie_*` profile tables cascade; `watch_events.movie_id` is set to NULL.
|
||||
async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> {
|
||||
let id = movie_id.value();
|
||||
if !self.movies.read().unwrap().contains_key(&id) {
|
||||
return Ok(());
|
||||
}
|
||||
if self
|
||||
.reviews
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.any(|r| r.movie_id().value() == id)
|
||||
{
|
||||
return Err(DomainError::InfrastructureError(
|
||||
"Database operation failed".into(),
|
||||
));
|
||||
}
|
||||
self.movies.write().unwrap().remove(&id);
|
||||
self.watchlist
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|(_, movie), _| *movie != id);
|
||||
self.movie_profiles.write().unwrap().remove(&id);
|
||||
let mut events = self.watch_events.write().unwrap();
|
||||
for event in events.iter_mut() {
|
||||
if event.movie_id().map(|m| m.value()) == Some(id) {
|
||||
*event = detached_from_movie(event);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MovieQuery for InMemoryStore {
|
||||
async fn get_movie_by_external_id(
|
||||
&self,
|
||||
external_metadata_id: &ExternalMetadataId,
|
||||
) -> Result<Option<Movie>, DomainError> {
|
||||
let store = self.movies.read().unwrap();
|
||||
Ok(store
|
||||
.values()
|
||||
.find(|m| {
|
||||
m.external_metadata_id()
|
||||
.map(|e| e.value() == external_metadata_id.value())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
Ok(self.movies.read().unwrap().get(&movie_id.value()).cloned())
|
||||
}
|
||||
|
||||
async fn get_movies_by_title_and_year(
|
||||
&self,
|
||||
title: &MovieTitle,
|
||||
year: &ReleaseYear,
|
||||
) -> Result<Vec<Movie>, DomainError> {
|
||||
let store = self.movies.read().unwrap();
|
||||
Ok(store
|
||||
.values()
|
||||
.filter(|m| m.title() == title && m.release_year() == year)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn existing_external_ids(
|
||||
&self,
|
||||
ids: &[ExternalMetadataId],
|
||||
) -> Result<std::collections::HashSet<String>, DomainError> {
|
||||
let store = self.movies.read().unwrap();
|
||||
let known: std::collections::HashSet<String> = store
|
||||
.values()
|
||||
.filter_map(|m| m.external_metadata_id().map(|e| e.value().to_string()))
|
||||
.collect();
|
||||
Ok(ids
|
||||
.iter()
|
||||
.map(|id| id.value().to_string())
|
||||
.filter(|v| known.contains(v))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn existing_title_year_pairs(
|
||||
&self,
|
||||
pairs: &[(MovieTitle, ReleaseYear)],
|
||||
) -> Result<std::collections::HashSet<(String, u16)>, DomainError> {
|
||||
let store = self.movies.read().unwrap();
|
||||
let known: std::collections::HashSet<(String, u16)> = store
|
||||
.values()
|
||||
.map(|m| (m.title().value().to_string(), m.release_year().value()))
|
||||
.collect();
|
||||
Ok(pairs
|
||||
.iter()
|
||||
.map(|(t, y)| (t.value().to_string(), y.value()))
|
||||
.filter(|p| known.contains(p))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn list_movies(
|
||||
&self,
|
||||
_page: &PageParams,
|
||||
_filter: &MovieFilter,
|
||||
) -> Result<Paginated<MovieSummary>, DomainError> {
|
||||
Ok(Paginated {
|
||||
items: vec![],
|
||||
total_count: 0,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_movies_with_external_id(&self) -> Result<Vec<Movie>, DomainError> {
|
||||
Ok(self
|
||||
.movies
|
||||
.read()
|
||||
.unwrap()
|
||||
.values()
|
||||
.filter(|m| m.external_metadata_id().is_some())
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MovieProfileRepository for InMemoryStore {
|
||||
async fn upsert(&self, profile: &MovieProfile) -> Result<(), DomainError> {
|
||||
self.movie_profiles
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(profile.movie_id.value(), profile.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_movie_id(&self, id: &MovieId) -> Result<Option<MovieProfile>, DomainError> {
|
||||
Ok(self
|
||||
.movie_profiles
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(&id.value())
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn list_stale(&self) -> Result<Vec<(MovieId, String)>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
fn detached_from_movie(event: &WatchEvent) -> WatchEvent {
|
||||
WatchEvent::from_persistence(PersistedWatchEvent {
|
||||
id: event.id().clone(),
|
||||
user_id: event.user_id().clone(),
|
||||
movie_id: None,
|
||||
title: event.title().to_string(),
|
||||
year: event.year(),
|
||||
external_metadata_id: event.external_metadata_id().map(str::to_string),
|
||||
source: event.source().clone(),
|
||||
watched_at: *event.watched_at(),
|
||||
status: event.status().clone(),
|
||||
created_at: *event.created_at(),
|
||||
})
|
||||
}
|
||||
52
crates/domain/src/testing/fakes/store_review.rs
Normal file
52
crates/domain/src/testing/fakes/store_review.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
models::Review,
|
||||
ports::ReviewRepository,
|
||||
value_objects::{ReviewId, UserId},
|
||||
};
|
||||
|
||||
use super::InMemoryStore;
|
||||
|
||||
#[async_trait]
|
||||
impl ReviewRepository for InMemoryStore {
|
||||
async fn save_review(&self, review: &Review) -> Result<(), DomainError> {
|
||||
self.reviews
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(review.id().value(), review.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||
Ok(self
|
||||
.reviews
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(&review_id.value())
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn update_review(&self, review: &Review) -> Result<(), DomainError> {
|
||||
self.reviews
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(review.id().value(), review.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> {
|
||||
self.reviews.write().unwrap().remove(&review_id.value());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError> {
|
||||
let store = self.reviews.read().unwrap();
|
||||
Ok(store
|
||||
.values()
|
||||
.filter(|r| r.user_id() == user_id)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
58
crates/domain/src/testing/fakes/store_session.rs
Normal file
58
crates/domain/src/testing/fakes/store_session.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError, models::RefreshSession, ports::RefreshSessionRepository,
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
use super::InMemoryStore;
|
||||
|
||||
#[async_trait]
|
||||
impl RefreshSessionRepository for InMemoryStore {
|
||||
/// `refresh_sessions.token` is UNIQUE.
|
||||
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
|
||||
let mut sessions = self.refresh_sessions.write().unwrap();
|
||||
if sessions.iter().any(|s| s.token == session.token) {
|
||||
return Err(DomainError::InfrastructureError(
|
||||
"Database operation failed".into(),
|
||||
));
|
||||
}
|
||||
sessions.push(session.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_token(&self, token: &str) -> Result<Option<RefreshSession>, DomainError> {
|
||||
Ok(self
|
||||
.refresh_sessions
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|s| s.token == token)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn revoke(&self, token: &str) -> Result<(), DomainError> {
|
||||
self.refresh_sessions
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|s| s.token != token);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
self.refresh_sessions
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|s| s.user_id != *user_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
let mut store = self.refresh_sessions.write().unwrap();
|
||||
let before = store.len();
|
||||
let now = Utc::now();
|
||||
store.retain(|s| s.expires_at >= now);
|
||||
Ok((before - store.len()) as u64)
|
||||
}
|
||||
}
|
||||
274
crates/domain/src/testing/fakes/store_social.rs
Normal file
274
crates/domain/src/testing/fakes/store_social.rs
Normal file
@@ -0,0 +1,274 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
ports::{BlockQuery, FollowGraphQuery, SocialCommand},
|
||||
value_objects::{FollowRelation, FollowStatus, SocialActor, SocialIdentity, UserId},
|
||||
};
|
||||
|
||||
use super::InMemoryStore;
|
||||
use super::store::FollowState;
|
||||
|
||||
fn identity_to_actor(identity: &SocialIdentity) -> SocialActor {
|
||||
let handle = match identity {
|
||||
SocialIdentity::Local(uid) => format!("user-{}", uid.value()),
|
||||
SocialIdentity::Remote { actor_url } => actor_url.clone(),
|
||||
};
|
||||
SocialActor {
|
||||
identity: identity.clone(),
|
||||
handle,
|
||||
display_name: None,
|
||||
avatar_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SocialCommand for InMemoryStore {
|
||||
async fn follow(
|
||||
&self,
|
||||
follower: &UserId,
|
||||
target: &crate::value_objects::FollowTarget,
|
||||
) -> Result<(), DomainError> {
|
||||
let identity = match target {
|
||||
crate::value_objects::FollowTarget::Identity(id) => id.clone(),
|
||||
crate::value_objects::FollowTarget::Handle(h) => SocialIdentity::Remote {
|
||||
actor_url: h.clone(),
|
||||
},
|
||||
};
|
||||
if let SocialIdentity::Local(target_id) = &identity
|
||||
&& follower == target_id
|
||||
{
|
||||
return Err(DomainError::ValidationError(
|
||||
"Cannot follow yourself".into(),
|
||||
));
|
||||
}
|
||||
let mut store = self.follows.write().unwrap();
|
||||
let already = store
|
||||
.iter()
|
||||
.any(|(f, t, _)| *f == follower.value() && *t == identity);
|
||||
if already {
|
||||
return Err(DomainError::ValidationError("Already following".into()));
|
||||
}
|
||||
store.push((follower.value(), identity, FollowState::Pending));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn unfollow(
|
||||
&self,
|
||||
follower: &UserId,
|
||||
target: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
let mut store = self.follows.write().unwrap();
|
||||
let before = store.len();
|
||||
store.retain(|(f, t, _)| !(*f == follower.value() && t == target));
|
||||
if store.len() == before {
|
||||
return Err(DomainError::NotFound(
|
||||
"Follow relationship not found".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn accept_follow(
|
||||
&self,
|
||||
owner: &UserId,
|
||||
requester: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
let mut store = self.follows.write().unwrap();
|
||||
let target_identity = SocialIdentity::Local(owner.clone());
|
||||
for (f, t, state) in store.iter_mut() {
|
||||
let requester_matches = match requester {
|
||||
SocialIdentity::Local(uid) => *f == uid.value(),
|
||||
SocialIdentity::Remote { actor_url } => {
|
||||
if let SocialIdentity::Remote {
|
||||
actor_url: stored_url,
|
||||
} = requester
|
||||
{
|
||||
stored_url == actor_url
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
if requester_matches && *t == target_identity && *state == FollowState::Pending {
|
||||
*state = FollowState::Accepted;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(DomainError::NotFound(
|
||||
"Pending follow request not found".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn reject_follow(
|
||||
&self,
|
||||
owner: &UserId,
|
||||
requester: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
let mut store = self.follows.write().unwrap();
|
||||
let target_identity = SocialIdentity::Local(owner.clone());
|
||||
let before = store.len();
|
||||
store.retain(|(f, t, state)| {
|
||||
let requester_matches = match requester {
|
||||
SocialIdentity::Local(uid) => *f == uid.value(),
|
||||
SocialIdentity::Remote { .. } => {
|
||||
// For remote, match by checking the stored requester identity
|
||||
false // simplified: reject removes by follower uuid match
|
||||
}
|
||||
};
|
||||
!(requester_matches && *t == target_identity && *state == FollowState::Pending)
|
||||
});
|
||||
if store.len() == before {
|
||||
return Err(DomainError::NotFound(
|
||||
"Pending follow request not found".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_follower(
|
||||
&self,
|
||||
owner: &UserId,
|
||||
follower: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
let mut store = self.follows.write().unwrap();
|
||||
let target_identity = SocialIdentity::Local(owner.clone());
|
||||
let before = store.len();
|
||||
store.retain(|(f, t, _)| {
|
||||
let follower_matches = match follower {
|
||||
SocialIdentity::Local(uid) => *f == uid.value(),
|
||||
SocialIdentity::Remote { .. } => false,
|
||||
};
|
||||
!(follower_matches && *t == target_identity)
|
||||
});
|
||||
if store.len() == before {
|
||||
return Err(DomainError::NotFound("Follower not found".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
|
||||
let mut store = self.blocked.write().unwrap();
|
||||
store.push((blocker.value(), target.clone()));
|
||||
// Also remove any existing follow relationships
|
||||
let mut follows = self.follows.write().unwrap();
|
||||
follows.retain(|(f, t, _)| !(*f == blocker.value() && t == target));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn unblock(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
|
||||
let mut store = self.blocked.write().unwrap();
|
||||
store.retain(|(b, t)| !(*b == blocker.value() && t == target));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FollowGraphQuery for InMemoryStore {
|
||||
async fn get_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let store = self.follows.read().unwrap();
|
||||
Ok(store
|
||||
.iter()
|
||||
.filter(|(f, _, state)| *f == user.value() && *state == FollowState::Accepted)
|
||||
.map(|(_, t, _)| identity_to_actor(t))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let store = self.follows.read().unwrap();
|
||||
let target = SocialIdentity::Local(user.clone());
|
||||
Ok(store
|
||||
.iter()
|
||||
.filter(|(_, t, state)| *t == target && *state == FollowState::Accepted)
|
||||
.map(|(f, _, _)| {
|
||||
let id = SocialIdentity::Local(UserId::from_uuid(*f));
|
||||
identity_to_actor(&id)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_pending_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let store = self.follows.read().unwrap();
|
||||
let target = SocialIdentity::Local(user.clone());
|
||||
Ok(store
|
||||
.iter()
|
||||
.filter(|(_, t, state)| *t == target && *state == FollowState::Pending)
|
||||
.map(|(f, _, _)| {
|
||||
let id = SocialIdentity::Local(UserId::from_uuid(*f));
|
||||
identity_to_actor(&id)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_pending_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let store = self.follows.read().unwrap();
|
||||
Ok(store
|
||||
.iter()
|
||||
.filter(|(f, _, state)| *f == user.value() && *state == FollowState::Pending)
|
||||
.map(|(_, t, _)| identity_to_actor(t))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_following(&self, user: &UserId) -> Result<usize, DomainError> {
|
||||
let store = self.follows.read().unwrap();
|
||||
Ok(store
|
||||
.iter()
|
||||
.filter(|(f, _, state)| *f == user.value() && *state == FollowState::Accepted)
|
||||
.count())
|
||||
}
|
||||
|
||||
async fn count_followers(&self, user: &UserId) -> Result<usize, DomainError> {
|
||||
let store = self.follows.read().unwrap();
|
||||
let target = SocialIdentity::Local(user.clone());
|
||||
Ok(store
|
||||
.iter()
|
||||
.filter(|(_, t, state)| *t == target && *state == FollowState::Accepted)
|
||||
.count())
|
||||
}
|
||||
|
||||
async fn count_pending_followers(&self, user: &UserId) -> Result<usize, DomainError> {
|
||||
let store = self.follows.read().unwrap();
|
||||
let target = SocialIdentity::Local(user.clone());
|
||||
Ok(store
|
||||
.iter()
|
||||
.filter(|(_, t, state)| *t == target && *state == FollowState::Pending)
|
||||
.count())
|
||||
}
|
||||
|
||||
async fn get_relation(
|
||||
&self,
|
||||
viewer: &UserId,
|
||||
target: &SocialIdentity,
|
||||
) -> Result<FollowRelation, DomainError> {
|
||||
let store = self.follows.read().unwrap();
|
||||
let viewer_identity = SocialIdentity::Local(viewer.clone());
|
||||
let state_to_status = |s: &FollowState| match s {
|
||||
FollowState::Pending => FollowStatus::Pending,
|
||||
FollowState::Accepted => FollowStatus::Accepted,
|
||||
};
|
||||
Ok(FollowRelation {
|
||||
following: store
|
||||
.iter()
|
||||
.find(|(f, t, _)| *f == viewer.value() && t == target)
|
||||
.map(|(_, _, s)| state_to_status(s)),
|
||||
followed_by: store
|
||||
.iter()
|
||||
.find(|(f, t, _)| {
|
||||
SocialIdentity::Local(UserId::from_uuid(*f)) == *target && *t == viewer_identity
|
||||
})
|
||||
.map(|(_, _, s)| state_to_status(s)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BlockQuery for InMemoryStore {
|
||||
async fn get_blocked(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let store = self.blocked.read().unwrap();
|
||||
Ok(store
|
||||
.iter()
|
||||
.filter(|(b, _)| *b == user.value())
|
||||
.map(|(_, t)| identity_to_actor(t))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
169
crates/domain/src/testing/fakes/store_user.rs
Normal file
169
crates/domain/src/testing/fakes/store_user.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
models::{FederationFlags, ProfileField, User, UserSettings, UserSummary},
|
||||
ports::{
|
||||
UserFederationSettingsQuery, UserProfileFieldsRepository, UserRepository,
|
||||
UserSettingsRepository,
|
||||
},
|
||||
value_objects::{Email, SocialIdentity, UserId, Username},
|
||||
};
|
||||
|
||||
use super::InMemoryStore;
|
||||
|
||||
#[async_trait]
|
||||
impl UserRepository for InMemoryStore {
|
||||
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||
let store = self.users.read().unwrap();
|
||||
Ok(store
|
||||
.values()
|
||||
.find(|u| u.email().value() == email.value())
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||
let store = self.users.read().unwrap();
|
||||
Ok(store
|
||||
.values()
|
||||
.find(|u| u.username().value() == username.value())
|
||||
.cloned())
|
||||
}
|
||||
|
||||
/// `users.email` and `users.username` are UNIQUE; the sqlite adapter checks
|
||||
/// both up front and reports a validation error.
|
||||
async fn save(&self, user: &User) -> Result<(), DomainError> {
|
||||
let mut users = self.users.write().unwrap();
|
||||
if users
|
||||
.values()
|
||||
.any(|u| u.email().value() == user.email().value())
|
||||
{
|
||||
return Err(DomainError::ValidationError(
|
||||
"Email already registered".into(),
|
||||
));
|
||||
}
|
||||
if users
|
||||
.values()
|
||||
.any(|u| u.username().value() == user.username().value())
|
||||
{
|
||||
return Err(DomainError::ValidationError(
|
||||
"Username already taken".into(),
|
||||
));
|
||||
}
|
||||
users.insert(user.id().value(), user.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||
Ok(self.users.read().unwrap().get(&id.value()).cloned())
|
||||
}
|
||||
|
||||
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn update_profile(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
profile: &crate::models::UserProfile,
|
||||
) -> Result<(), DomainError> {
|
||||
let mut store = self.users.write().unwrap();
|
||||
if let Some(user) = store.get_mut(&user_id.value()) {
|
||||
user.update_profile(profile.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserProfileFieldsRepository for InMemoryStore {
|
||||
async fn get_fields(&self, user_id: &UserId) -> Result<Vec<ProfileField>, DomainError> {
|
||||
let store = self.profile_fields.read().unwrap();
|
||||
Ok(store.get(&user_id.value()).cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn set_fields(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
fields: Vec<ProfileField>,
|
||||
) -> Result<(), DomainError> {
|
||||
self.profile_fields
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(user_id.value(), fields);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserSettingsRepository for InMemoryStore {
|
||||
async fn get(&self, user_id: &UserId) -> Result<UserSettings, DomainError> {
|
||||
let store = self.user_settings.read().unwrap();
|
||||
Ok(store
|
||||
.get(&user_id.value())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| UserSettings::new(user_id.clone())))
|
||||
}
|
||||
|
||||
async fn save(&self, settings: &UserSettings) -> Result<(), DomainError> {
|
||||
self.user_settings
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(settings.user_id().value(), settings.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserFederationSettingsQuery for InMemoryStore {
|
||||
async fn get_federation_flags(&self, user_id: &UserId) -> Result<FederationFlags, DomainError> {
|
||||
let store = self.user_settings.read().unwrap();
|
||||
let settings = store
|
||||
.get(&user_id.value())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| UserSettings::new(user_id.clone()));
|
||||
Ok(FederationFlags {
|
||||
goals: settings.federate_goals(),
|
||||
reviews: settings.federate_reviews(),
|
||||
watchlist: settings.federate_watchlist(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl InMemoryStore {
|
||||
/// Deletes a user the way `DELETE FROM users` does under the production
|
||||
/// schema: rows in `USER_CASCADE_TABLES` go with it. Reviews, refresh
|
||||
/// sessions, import sessions and import profiles carry a plain `user_id`
|
||||
/// column with no foreign key, so they stay.
|
||||
pub fn delete_user(&self, user_id: &UserId) {
|
||||
let id = user_id.value();
|
||||
self.users.write().unwrap().remove(&id);
|
||||
self.watchlist
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|(user, _), _| *user != id);
|
||||
self.goals
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|_, g| g.user_id().value() != id);
|
||||
self.user_settings.write().unwrap().remove(&id);
|
||||
self.profile_fields.write().unwrap().remove(&id);
|
||||
self.webhook_tokens
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|t| t.user_id().value() != id);
|
||||
self.watch_events
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|e| e.user_id().value() != id);
|
||||
let me = SocialIdentity::Local(user_id.clone());
|
||||
self.follows
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|(follower, target, _)| *follower != id && *target != me);
|
||||
self.blocked
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|(blocker, _)| *blocker != id);
|
||||
}
|
||||
}
|
||||
123
crates/domain/src/testing/fakes/store_watch_event.rs
Normal file
123
crates/domain/src/testing/fakes/store_watch_event.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::NaiveDateTime;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
models::{WatchEvent, WatchEventStatus, WebhookToken},
|
||||
ports::{WatchEventCommand, WatchEventQuery, WebhookTokenRepository},
|
||||
value_objects::{UserId, WatchEventId, WebhookTokenId},
|
||||
};
|
||||
|
||||
use super::InMemoryStore;
|
||||
|
||||
#[async_trait]
|
||||
impl WatchEventCommand for InMemoryStore {
|
||||
async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> {
|
||||
self.watch_events.write().unwrap().push(event.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_status(
|
||||
&self,
|
||||
_id: &WatchEventId,
|
||||
_status: WatchEventStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_status_batch(
|
||||
&self,
|
||||
ids: &[WatchEventId],
|
||||
_status: WatchEventStatus,
|
||||
) -> Result<u64, DomainError> {
|
||||
Ok(ids.len() as u64)
|
||||
}
|
||||
|
||||
async fn delete_non_pending_older_than(
|
||||
&self,
|
||||
before: NaiveDateTime,
|
||||
) -> Result<u64, DomainError> {
|
||||
let mut store = self.watch_events.write().unwrap();
|
||||
let before_len = store.len();
|
||||
store.retain(|e| *e.status() == WatchEventStatus::Pending || *e.created_at() >= before);
|
||||
Ok((before_len - store.len()) as u64)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WatchEventQuery for InMemoryStore {
|
||||
async fn list_pending(&self, user_id: &UserId) -> Result<Vec<WatchEvent>, DomainError> {
|
||||
let store = self.watch_events.read().unwrap();
|
||||
Ok(store
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
e.user_id().value() == user_id.value() && *e.status() == WatchEventStatus::Pending
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_by_id(&self, id: &WatchEventId) -> Result<Option<WatchEvent>, DomainError> {
|
||||
let store = self.watch_events.read().unwrap();
|
||||
Ok(store.iter().find(|e| e.id().value() == id.value()).cloned())
|
||||
}
|
||||
|
||||
async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result<Vec<WatchEvent>, DomainError> {
|
||||
let id_vals: Vec<Uuid> = ids.iter().map(|id| id.value()).collect();
|
||||
let store = self.watch_events.read().unwrap();
|
||||
Ok(store
|
||||
.iter()
|
||||
.filter(|e| id_vals.contains(&e.id().value()))
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn find_duplicate(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
external_id: &str,
|
||||
after: NaiveDateTime,
|
||||
) -> Result<bool, DomainError> {
|
||||
let store = self.watch_events.read().unwrap();
|
||||
Ok(store.iter().any(|e| {
|
||||
e.user_id().value() == user_id.value()
|
||||
&& e.external_metadata_id() == Some(external_id)
|
||||
&& *e.watched_at() > after
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WebhookTokenRepository for InMemoryStore {
|
||||
async fn save(&self, token: &WebhookToken) -> Result<(), DomainError> {
|
||||
self.webhook_tokens.write().unwrap().push(token.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_by_token_hash(&self, hash: &str) -> Result<Option<WebhookToken>, DomainError> {
|
||||
let store = self.webhook_tokens.read().unwrap();
|
||||
Ok(store.iter().find(|t| t.token_hash() == hash).cloned())
|
||||
}
|
||||
|
||||
async fn list_by_user(&self, user_id: &UserId) -> Result<Vec<WebhookToken>, DomainError> {
|
||||
let store = self.webhook_tokens.read().unwrap();
|
||||
Ok(store
|
||||
.iter()
|
||||
.filter(|t| t.user_id().value() == user_id.value())
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &WebhookTokenId, _user_id: &UserId) -> Result<(), DomainError> {
|
||||
self.webhook_tokens
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|t| t.id().value() != id.value());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn touch_last_used(&self, _id: &WebhookTokenId) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
110
crates/domain/src/testing/fakes/store_watchlist.rs
Normal file
110
crates/domain/src/testing/fakes/store_watchlist.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
RemoteWatchlistEntry, WatchlistEntry, WatchlistWithMovie,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::{RemoteWatchlistRepository, WatchlistRepository},
|
||||
value_objects::{MovieId, UserId},
|
||||
};
|
||||
|
||||
use super::InMemoryStore;
|
||||
|
||||
#[async_trait]
|
||||
impl WatchlistRepository for InMemoryStore {
|
||||
/// `INSERT OR IGNORE` on UNIQUE(user_id, movie_id): adding twice is a no-op.
|
||||
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> {
|
||||
let key = (entry.user_id.value(), entry.movie_id.value());
|
||||
self.watchlist
|
||||
.write()
|
||||
.unwrap()
|
||||
.entry(key)
|
||||
.or_insert_with(|| entry.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> {
|
||||
let key = (user_id.value(), movie_id.value());
|
||||
self.watchlist
|
||||
.write()
|
||||
.unwrap()
|
||||
.remove(&key)
|
||||
.ok_or_else(|| DomainError::NotFound("watchlist entry".into()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_if_present(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
movie_id: &MovieId,
|
||||
) -> Result<bool, DomainError> {
|
||||
let key = (user_id.value(), movie_id.value());
|
||||
Ok(self.watchlist.write().unwrap().remove(&key).is_some())
|
||||
}
|
||||
|
||||
async fn get_for_user(
|
||||
&self,
|
||||
_user_id: &UserId,
|
||||
_page: &PageParams,
|
||||
) -> Result<Paginated<WatchlistWithMovie>, DomainError> {
|
||||
Ok(Paginated {
|
||||
items: vec![],
|
||||
total_count: 0,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result<bool, DomainError> {
|
||||
let key = (user_id.value(), movie_id.value());
|
||||
Ok(self.watchlist.read().unwrap().contains_key(&key))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteWatchlistRepository for InMemoryStore {
|
||||
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> {
|
||||
self.remote_watchlist.write().unwrap().push(entry);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError> {
|
||||
self.remote_watchlist
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|e| !(e.ap_id == ap_id && e.actor_url == actor_url));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_actor_url(
|
||||
&self,
|
||||
actor_url: &str,
|
||||
) -> Result<Vec<RemoteWatchlistEntry>, DomainError> {
|
||||
Ok(self
|
||||
.remote_watchlist
|
||||
.read()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|e| e.actor_url == actor_url)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError> {
|
||||
self.remote_watchlist
|
||||
.write()
|
||||
.unwrap()
|
||||
.retain(|e| e.actor_url != actor_url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_derived_uuid(
|
||||
&self,
|
||||
_uuid: Uuid,
|
||||
) -> Result<Vec<RemoteWatchlistEntry>, DomainError> {
|
||||
Ok(self.remote_watchlist.read().unwrap().clone())
|
||||
}
|
||||
}
|
||||
388
crates/domain/src/testing/fakes/tests.rs
Normal file
388
crates/domain/src/testing/fakes/tests.rs
Normal file
@@ -0,0 +1,388 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{InMemoryStore, MOVIE_CASCADE_TABLES, USER_CASCADE_TABLES};
|
||||
use crate::errors::DomainError;
|
||||
use crate::models::{
|
||||
CastMember, CrewMember, Genre, Keyword, Movie, MovieProfile, ProfileField, RefreshSession,
|
||||
UserSettings, WatchEvent, WatchEventSource, WatchlistEntry, WebhookToken,
|
||||
};
|
||||
use crate::ports::{
|
||||
GoalCommand, MovieCommand, MovieProfileRepository, RefreshSessionRepository, ReviewRepository,
|
||||
SocialCommand, UserProfileFieldsRepository, UserRepository, UserSettingsRepository,
|
||||
WatchEventCommand, WatchEventQuery, WatchlistRepository, WebhookTokenRepository,
|
||||
};
|
||||
use crate::testing::{
|
||||
test_goal, test_movie, test_movie_with_external_id, test_review, test_user, test_watched_at,
|
||||
};
|
||||
use crate::value_objects::{ExternalMetadataId, FollowTarget, MovieId, SocialIdentity, UserId};
|
||||
|
||||
const REMOTE_ACTOR: &str = "https://remote.example/users/ann";
|
||||
|
||||
fn movie_profile(movie_id: &MovieId) -> MovieProfile {
|
||||
MovieProfile {
|
||||
movie_id: movie_id.clone(),
|
||||
tmdb_id: 603,
|
||||
imdb_id: None,
|
||||
overview: None,
|
||||
tagline: None,
|
||||
runtime_minutes: None,
|
||||
budget_usd: None,
|
||||
revenue_usd: None,
|
||||
vote_average: None,
|
||||
vote_count: None,
|
||||
original_language: None,
|
||||
collection_name: None,
|
||||
genres: vec![Genre {
|
||||
tmdb_id: 1,
|
||||
name: "Sci-Fi".into(),
|
||||
}],
|
||||
keywords: vec![Keyword {
|
||||
tmdb_id: 2,
|
||||
name: "simulation".into(),
|
||||
}],
|
||||
cast: vec![CastMember {
|
||||
tmdb_person_id: 3,
|
||||
name: "Actor".into(),
|
||||
character: "Role".into(),
|
||||
billing_order: 0,
|
||||
profile_path: None,
|
||||
}],
|
||||
crew: vec![CrewMember {
|
||||
tmdb_person_id: 4,
|
||||
name: "Director".into(),
|
||||
job: "Director".into(),
|
||||
department: "Directing".into(),
|
||||
profile_path: None,
|
||||
}],
|
||||
enriched_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves a user and one row in every table the schema cascades from a user,
|
||||
/// plus rows in the tables that hang off a user without a foreign key.
|
||||
async fn seed_user_with_dependents(store: &Arc<InMemoryStore>) -> (UserId, Movie) {
|
||||
let user = test_user("alice");
|
||||
let user_id = user.id().clone();
|
||||
let other = test_user("bob");
|
||||
let movie = test_movie("Heat");
|
||||
UserRepository::save(&**store, &user).await.unwrap();
|
||||
UserRepository::save(&**store, &other).await.unwrap();
|
||||
store.upsert_movie(&movie).await.unwrap();
|
||||
|
||||
store
|
||||
.add(&WatchlistEntry::new(user_id.clone(), movie.id().clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
GoalCommand::save(&**store, &test_goal(&user_id, 2024, 50))
|
||||
.await
|
||||
.unwrap();
|
||||
UserSettingsRepository::save(&**store, &UserSettings::new(user_id.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.set_fields(
|
||||
&user_id,
|
||||
vec![ProfileField {
|
||||
name: "Site".into(),
|
||||
value: "https://example.com".into(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
WebhookTokenRepository::save(
|
||||
&**store,
|
||||
&WebhookToken::new(
|
||||
user_id.clone(),
|
||||
"hash".into(),
|
||||
WatchEventSource::Jellyfin,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
WatchEventCommand::save(
|
||||
&**store,
|
||||
&WatchEvent::new(
|
||||
user_id.clone(),
|
||||
"Heat".into(),
|
||||
Some(1995),
|
||||
None,
|
||||
WatchEventSource::Jellyfin,
|
||||
test_watched_at(),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.follow(
|
||||
&user_id,
|
||||
&FollowTarget::Identity(SocialIdentity::Remote {
|
||||
actor_url: REMOTE_ACTOR.into(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.follow(
|
||||
other.id(),
|
||||
&FollowTarget::Identity(SocialIdentity::Local(user_id.clone())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.block(
|
||||
&user_id,
|
||||
&SocialIdentity::Remote {
|
||||
actor_url: REMOTE_ACTOR.into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
store
|
||||
.save_review(&test_review(movie.id(), &user_id, 4))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.create(&RefreshSession {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: user_id.clone(),
|
||||
token: "refresh-token".into(),
|
||||
expires_at: Utc::now() + chrono::Duration::days(1),
|
||||
created_at: Utc::now(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
(user_id, movie)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_user_removes_watchlist_goals_settings_profile_fields_tokens_events_follows_and_blocks()
|
||||
{
|
||||
let store = InMemoryStore::new();
|
||||
let (user_id, _) = seed_user_with_dependents(&store).await;
|
||||
for table in USER_CASCADE_TABLES {
|
||||
assert!(store.rows_in(table) > 0, "{table} should be seeded");
|
||||
}
|
||||
|
||||
store.delete_user(&user_id);
|
||||
|
||||
assert!(store.find_by_id(&user_id).await.unwrap().is_none());
|
||||
for table in USER_CASCADE_TABLES {
|
||||
assert_eq!(store.rows_in(table), 0, "{table} should cascade");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_user_keeps_reviews_and_refresh_sessions_because_the_schema_has_no_foreign_key()
|
||||
{
|
||||
let store = InMemoryStore::new();
|
||||
let (user_id, _) = seed_user_with_dependents(&store).await;
|
||||
|
||||
store.delete_user(&user_id);
|
||||
|
||||
assert_eq!(store.review_count(), 1);
|
||||
assert_eq!(store.refresh_session_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_user_leaves_other_users_rows_alone() {
|
||||
let store = InMemoryStore::new();
|
||||
let (user_id, movie) = seed_user_with_dependents(&store).await;
|
||||
let bystander = test_user("carol");
|
||||
UserRepository::save(&*store, &bystander).await.unwrap();
|
||||
store
|
||||
.add(&WatchlistEntry::new(
|
||||
bystander.id().clone(),
|
||||
movie.id().clone(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
store.delete_user(&user_id);
|
||||
|
||||
assert_eq!(store.watchlist_count(), 1);
|
||||
assert!(store.find_by_id(bystander.id()).await.unwrap().is_some());
|
||||
}
|
||||
|
||||
async fn seed_movie_with_dependents(store: &Arc<InMemoryStore>, with_review: bool) -> Movie {
|
||||
let user = test_user("alice");
|
||||
UserRepository::save(&**store, &user).await.unwrap();
|
||||
let movie = test_movie("Heat");
|
||||
store.upsert_movie(&movie).await.unwrap();
|
||||
store
|
||||
.add(&WatchlistEntry::new(user.id().clone(), movie.id().clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
store.upsert(&movie_profile(movie.id())).await.unwrap();
|
||||
WatchEventCommand::save(
|
||||
&**store,
|
||||
&WatchEvent::new(
|
||||
user.id().clone(),
|
||||
"Heat".into(),
|
||||
Some(1995),
|
||||
None,
|
||||
WatchEventSource::Plex,
|
||||
test_watched_at(),
|
||||
Some(movie.id().clone()),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
if with_review {
|
||||
store
|
||||
.save_review(&test_review(movie.id(), user.id(), 5))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
movie
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_movie_removes_watchlist_entries_and_enrichment_profile() {
|
||||
let store = InMemoryStore::new();
|
||||
let movie = seed_movie_with_dependents(&store, false).await;
|
||||
for table in MOVIE_CASCADE_TABLES {
|
||||
assert!(store.rows_in(table) > 0, "{table} should be seeded");
|
||||
}
|
||||
|
||||
store.delete_movie(movie.id()).await.unwrap();
|
||||
|
||||
assert_eq!(store.movie_count(), 0);
|
||||
for table in MOVIE_CASCADE_TABLES {
|
||||
assert_eq!(store.rows_in(table), 0, "{table} should cascade");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_movie_detaches_watch_events_instead_of_removing_them() {
|
||||
let store = InMemoryStore::new();
|
||||
let movie = seed_movie_with_dependents(&store, false).await;
|
||||
let user_id = store.watch_events.read().unwrap()[0].user_id().clone();
|
||||
|
||||
store.delete_movie(movie.id()).await.unwrap();
|
||||
|
||||
let pending = store.list_pending(&user_id).await.unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert!(pending[0].movie_id().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_movie_that_has_reviews_is_refused_because_reviews_do_not_cascade() {
|
||||
let store = InMemoryStore::new();
|
||||
let movie = seed_movie_with_dependents(&store, true).await;
|
||||
|
||||
let result = store.delete_movie(movie.id()).await;
|
||||
|
||||
assert!(matches!(result, Err(DomainError::InfrastructureError(_))));
|
||||
assert_eq!(store.movie_count(), 1);
|
||||
assert_eq!(store.review_count(), 1);
|
||||
assert_eq!(store.watchlist_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saving_a_user_with_a_taken_username_is_a_validation_error() {
|
||||
let store = InMemoryStore::new();
|
||||
UserRepository::save(&*store, &test_user("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
let clash = crate::models::User::new(
|
||||
crate::value_objects::Email::new("other@example.com".into()).unwrap(),
|
||||
crate::value_objects::Username::new("alice".into()).unwrap(),
|
||||
crate::value_objects::PasswordHash::new("hashed".into()).unwrap(),
|
||||
crate::models::UserRole::Standard,
|
||||
);
|
||||
|
||||
let result = UserRepository::save(&*store, &clash).await;
|
||||
|
||||
assert!(matches!(result, Err(DomainError::ValidationError(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saving_a_user_with_a_taken_email_is_a_validation_error() {
|
||||
let store = InMemoryStore::new();
|
||||
UserRepository::save(&*store, &test_user("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
let clash = crate::models::User::new(
|
||||
crate::value_objects::Email::new("alice@example.com".into()).unwrap(),
|
||||
crate::value_objects::Username::new("alice2".into()).unwrap(),
|
||||
crate::value_objects::PasswordHash::new("hashed".into()).unwrap(),
|
||||
crate::models::UserRole::Standard,
|
||||
);
|
||||
|
||||
let result = UserRepository::save(&*store, &clash).await;
|
||||
|
||||
assert!(matches!(result, Err(DomainError::ValidationError(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saving_a_second_goal_for_the_same_user_and_year_is_an_infrastructure_error() {
|
||||
let store = InMemoryStore::new();
|
||||
let user_id = UserId::generate();
|
||||
GoalCommand::save(&*store, &test_goal(&user_id, 2024, 10))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = GoalCommand::save(&*store, &test_goal(&user_id, 2024, 20)).await;
|
||||
|
||||
assert!(matches!(result, Err(DomainError::InfrastructureError(_))));
|
||||
assert_eq!(store.goal_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adding_the_same_movie_to_a_watchlist_twice_keeps_one_entry() {
|
||||
let store = InMemoryStore::new();
|
||||
let user_id = UserId::generate();
|
||||
let movie_id = MovieId::generate();
|
||||
|
||||
store
|
||||
.add(&WatchlistEntry::new(user_id.clone(), movie_id.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.add(&WatchlistEntry::new(user_id, movie_id))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(store.watchlist_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upserting_a_different_movie_with_a_taken_external_id_is_an_infrastructure_error() {
|
||||
let store = InMemoryStore::new();
|
||||
let external = ExternalMetadataId::new("tt0113277".into()).unwrap();
|
||||
store
|
||||
.upsert_movie(&test_movie_with_external_id("Heat", 1995, external.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = store
|
||||
.upsert_movie(&test_movie_with_external_id("Heat 2", 2030, external))
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(DomainError::InfrastructureError(_))));
|
||||
assert_eq!(store.movie_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creating_a_refresh_session_with_a_taken_token_is_an_infrastructure_error() {
|
||||
let store = InMemoryStore::new();
|
||||
let session = |token: &str| RefreshSession {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: UserId::generate(),
|
||||
token: token.into(),
|
||||
expires_at: Utc::now() + chrono::Duration::days(1),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
store.create(&session("same")).await.unwrap();
|
||||
|
||||
let result = store.create(&session("same")).await;
|
||||
|
||||
assert!(matches!(result, Err(DomainError::InfrastructureError(_))));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,5 @@
|
||||
mod factories;
|
||||
mod fakes;
|
||||
mod in_memory;
|
||||
mod insights;
|
||||
mod noops;
|
||||
mod panics;
|
||||
mod wrapup;
|
||||
|
||||
pub use factories::*;
|
||||
pub use fakes::*;
|
||||
pub use in_memory::*;
|
||||
pub use insights::*;
|
||||
pub use noops::*;
|
||||
pub use panics::*;
|
||||
pub use wrapup::*;
|
||||
|
||||
Reference in New Issue
Block a user