From 06eec2feae11b6f0ae7e5f133af4cfc5f1d05c98 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 22:19:24 +0100 Subject: [PATCH] test: cascading in-memory store --- .../http-axum/src/extractors/tests.rs | 46 +- crates/adapters/sqlite/tests/cascade_test.rs | 317 +++++ crates/application/src/auth/tests/login.rs | 6 +- crates/application/src/auth/tests/logout.rs | 4 +- crates/application/src/auth/tests/refresh.rs | 8 +- crates/application/src/auth/tests/register.rs | 6 +- .../src/diary/tests/delete_review.rs | 16 +- .../src/diary/tests/edit_review.rs | 13 +- .../src/diary/tests/get_activity_feed.rs | 4 +- .../src/diary/tests/get_movie_social_page.rs | 10 +- .../application/src/diary/tests/log_review.rs | 24 +- .../src/diary/tests/review_logger.rs | 69 +- crates/application/src/goals/tests/create.rs | 10 +- crates/application/src/goals/tests/delete.rs | 8 +- .../src/import/tests/apply_mapping.rs | 14 +- .../src/import/tests/apply_profile.rs | 24 +- .../src/import/tests/apply_profile_and_map.rs | 20 +- .../application/src/import/tests/cleanup.rs | 4 +- .../src/import/tests/create_session.rs | 4 +- .../src/import/tests/delete_profile.rs | 4 +- .../application/src/import/tests/execute.rs | 36 +- .../src/import/tests/get_mapping_stage.rs | 8 +- .../src/import/tests/get_preview_stage.rs | 8 +- .../src/import/tests/get_session_state.rs | 6 +- .../src/import/tests/list_profiles.rs | 4 +- .../src/import/tests/save_profile.rs | 10 +- .../src/integrations/tests/cleanup.rs | 4 +- .../src/integrations/tests/confirm.rs | 28 +- .../src/integrations/tests/dismiss.rs | 10 +- .../src/integrations/tests/generate_token.rs | 4 +- .../src/integrations/tests/get_queue.rs | 8 +- .../src/integrations/tests/get_tokens.rs | 6 +- .../src/integrations/tests/ingest.rs | 20 +- .../src/integrations/tests/revoke_token.rs | 4 +- .../src/movies/tests/enrich_movie.rs | 17 +- .../src/movies/tests/get_movie_profile.rs | 6 +- .../src/movies/tests/get_movies.rs | 4 +- .../src/movies/tests/request_enrichment.rs | 8 +- .../src/movies/tests/sync_poster.rs | 15 +- .../application/src/social/tests/execute.rs | 10 +- .../src/social/tests/local_service.rs | 18 +- crates/application/src/test_helpers.rs | 52 +- .../src/users/tests/authorize_admin.rs | 6 +- .../src/users/tests/get_current_profile.rs | 8 +- .../src/users/tests/update_profile.rs | 16 +- .../src/users/tests/update_profile_fields.rs | 4 +- .../src/users/tests/update_settings.rs | 8 +- crates/application/src/watchlist/tests/add.rs | 35 +- .../tests/get_watchlist_for_owner.rs | 35 +- .../application/src/watchlist/tests/is_on.rs | 4 +- .../application/src/watchlist/tests/remove.rs | 6 +- crates/bootstrap/src/tests/build.rs | 65 +- crates/domain/src/models/tests/review.rs | 19 +- .../src/testing/{ => fakes}/insights.rs | 0 crates/domain/src/testing/fakes/mod.rs | 26 + .../domain/src/testing/{ => fakes}/noops.rs | 0 .../domain/src/testing/{ => fakes}/panics.rs | 0 crates/domain/src/testing/fakes/store.rs | 171 +++ crates/domain/src/testing/fakes/store_goal.rs | 70 + .../domain/src/testing/fakes/store_import.rs | 98 ++ .../domain/src/testing/fakes/store_movie.rs | 200 +++ .../domain/src/testing/fakes/store_review.rs | 52 + .../domain/src/testing/fakes/store_session.rs | 58 + .../domain/src/testing/fakes/store_social.rs | 274 ++++ crates/domain/src/testing/fakes/store_user.rs | 169 +++ .../src/testing/fakes/store_watch_event.rs | 123 ++ .../src/testing/fakes/store_watchlist.rs | 110 ++ .../src/testing/{fakes.rs => fakes/stubs.rs} | 0 crates/domain/src/testing/fakes/tests.rs | 388 ++++++ .../domain/src/testing/{ => fakes}/wrapup.rs | 0 crates/domain/src/testing/in_memory.rs | 1214 ----------------- crates/domain/src/testing/mod.rs | 10 - 72 files changed, 2439 insertions(+), 1627 deletions(-) create mode 100644 crates/adapters/sqlite/tests/cascade_test.rs rename crates/domain/src/testing/{ => fakes}/insights.rs (100%) create mode 100644 crates/domain/src/testing/fakes/mod.rs rename crates/domain/src/testing/{ => fakes}/noops.rs (100%) rename crates/domain/src/testing/{ => fakes}/panics.rs (100%) create mode 100644 crates/domain/src/testing/fakes/store.rs create mode 100644 crates/domain/src/testing/fakes/store_goal.rs create mode 100644 crates/domain/src/testing/fakes/store_import.rs create mode 100644 crates/domain/src/testing/fakes/store_movie.rs create mode 100644 crates/domain/src/testing/fakes/store_review.rs create mode 100644 crates/domain/src/testing/fakes/store_session.rs create mode 100644 crates/domain/src/testing/fakes/store_social.rs create mode 100644 crates/domain/src/testing/fakes/store_user.rs create mode 100644 crates/domain/src/testing/fakes/store_watch_event.rs create mode 100644 crates/domain/src/testing/fakes/store_watchlist.rs rename crates/domain/src/testing/{fakes.rs => fakes/stubs.rs} (100%) create mode 100644 crates/domain/src/testing/fakes/tests.rs rename crates/domain/src/testing/{ => fakes}/wrapup.rs (100%) delete mode 100644 crates/domain/src/testing/in_memory.rs diff --git a/crates/adapters/http-axum/src/extractors/tests.rs b/crates/adapters/http-axum/src/extractors/tests.rs index af6d7b20..271344fc 100644 --- a/crates/adapters/http-axum/src/extractors/tests.rs +++ b/crates/adapters/http-axum/src/extractors/tests.rs @@ -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)), diff --git a/crates/adapters/sqlite/tests/cascade_test.rs b/crates/adapters/sqlite/tests/cascade_test.rs new file mode 100644 index 00000000..4e671fd4 --- /dev/null +++ b/crates/adapters/sqlite/tests/cascade_test.rs @@ -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 { + 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 { + 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,) = 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); +} diff --git a/crates/application/src/auth/tests/login.rs b/crates/application/src/auth/tests/login.rs index b317e2b5..6b422981 100644 --- a/crates/application/src/auth/tests/login.rs +++ b/crates/application/src/auth/tests/login.rs @@ -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; diff --git a/crates/application/src/auth/tests/logout.rs b/crates/application/src/auth/tests/logout.rs index ac355548..582cf530 100644 --- a/crates/application/src/auth/tests/logout.rs +++ b/crates/application/src/auth/tests/logout.rs @@ -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 { diff --git a/crates/application/src/auth/tests/refresh.rs b/crates/application/src/auth/tests/refresh.rs index 21493cc4..cbaf3c77 100644 --- a/crates/application/src/auth/tests/refresh.rs +++ b/crates/application/src/auth/tests/refresh.rs @@ -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; diff --git a/crates/application/src/auth/tests/register.rs b/crates/application/src/auth/tests/register.rs index 5249ddcc..71025243 100644 --- a/crates/application/src/auth/tests/register.rs +++ b/crates/application/src/auth/tests/register.rs @@ -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(), diff --git a/crates/application/src/diary/tests/delete_review.rs b/crates/application/src/diary/tests/delete_review.rs index a30902ab..121e0d42 100644 --- a/crates/application/src/diary/tests/delete_review.rs +++ b/crates/application/src/diary/tests/delete_review.rs @@ -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"); } diff --git a/crates/application/src/diary/tests/edit_review.rs b/crates/application/src/diary/tests/edit_review.rs index e5244c8b..d0a92afc 100644 --- a/crates/application/src/diary/tests/edit_review.rs +++ b/crates/application/src/diary/tests/edit_review.rs @@ -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, + Arc, Arc, 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, - events: &Arc, -) -> EditReviewDeps { +fn deps(reviews: &Arc, events: &Arc) -> 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)) diff --git a/crates/application/src/diary/tests/get_activity_feed.rs b/crates/application/src/diary/tests/get_activity_feed.rs index 0780aee8..7e923f56 100644 --- a/crates/application/src/diary/tests/get_activity_feed.rs +++ b/crates/application/src/diary/tests/get_activity_feed.rs @@ -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, } } diff --git a/crates/application/src/diary/tests/get_movie_social_page.rs b/crates/application/src/diary/tests/get_movie_social_page.rs index 547b543b..dfab284d 100644 --- a/crates/application/src/diary/tests/get_movie_social_page.rs +++ b/crates/application/src/diary/tests/get_movie_social_page.rs @@ -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( diff --git a/crates/application/src/diary/tests/log_review.rs b/crates/application/src/diary/tests/log_review.rs index f511bfdd..78f11a32 100644 --- a/crates/application/src/diary/tests/log_review.rs +++ b/crates/application/src/diary/tests/log_review.rs @@ -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, - reviews: &Arc, + movies: &Arc, + reviews: &Arc, events: &Arc, ) -> Arc { 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); diff --git a/crates/application/src/diary/tests/review_logger.rs b/crates/application/src/diary/tests/review_logger.rs index 3b8fbaf0..0077b572 100644 --- a/crates/application/src/diary/tests/review_logger.rs +++ b/crates/application/src/diary/tests/review_logger.rs @@ -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, - reviews: &Arc, - watchlist: &Arc, + movies: &Arc, + reviews: &Arc, + watchlist: &Arc, events: &Arc, ) -> 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( diff --git a/crates/application/src/goals/tests/create.rs b/crates/application/src/goals/tests/create.rs index b3db8e00..a4996fcd 100644 --- a/crates/application/src/goals/tests/create.rs +++ b/crates/application/src/goals/tests/create.rs @@ -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] diff --git a/crates/application/src/goals/tests/delete.rs b/crates/application/src/goals/tests/delete.rs index 07aa2724..6ff2d425 100644 --- a/crates/application/src/goals/tests/delete.rs +++ b/crates/application/src/goals/tests/delete.rs @@ -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] diff --git a/crates/application/src/import/tests/apply_mapping.rs b/crates/application/src/import/tests/apply_mapping.rs index 93d7755b..57179cd1 100644 --- a/crates/application/src/import/tests/apply_mapping.rs +++ b/crates/application/src/import/tests/apply_mapping.rs @@ -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(); diff --git a/crates/application/src/import/tests/apply_profile.rs b/crates/application/src/import/tests/apply_profile.rs index c90caaa9..7420a5fd 100644 --- a/crates/application/src/import/tests/apply_profile.rs +++ b/crates/application/src/import/tests/apply_profile.rs @@ -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()); diff --git a/crates/application/src/import/tests/apply_profile_and_map.rs b/crates/application/src/import/tests/apply_profile_and_map.rs index 8906d5d1..b4b390d5 100644 --- a/crates/application/src/import/tests/apply_profile_and_map.rs +++ b/crates/application/src/import/tests/apply_profile_and_map.rs @@ -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()); } diff --git a/crates/application/src/import/tests/cleanup.rs b/crates/application/src/import/tests/cleanup.rs index 9eb3b811..3302099e 100644 --- a/crates/application/src/import/tests/cleanup.rs +++ b/crates/application/src/import/tests/cleanup.rs @@ -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(); diff --git a/crates/application/src/import/tests/create_session.rs b/crates/application/src/import/tests/create_session.rs index 9ebe974c..19119518 100644 --- a/crates/application/src/import/tests/create_session.rs +++ b/crates/application/src/import/tests/create_session.rs @@ -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 { diff --git a/crates/application/src/import/tests/delete_profile.rs b/crates/application/src/import/tests/delete_profile.rs index 32568636..79f89d18 100644 --- a/crates/application/src/import/tests/delete_profile.rs +++ b/crates/application/src/import/tests/delete_profile.rs @@ -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 _, }; diff --git a/crates/application/src/import/tests/execute.rs b/crates/application/src/import/tests/execute.rs index cc9fddf0..fc2ca7ab 100644 --- a/crates/application/src/import/tests/execute.rs +++ b/crates/application/src/import/tests/execute.rs @@ -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) diff --git a/crates/application/src/import/tests/get_mapping_stage.rs b/crates/application/src/import/tests/get_mapping_stage.rs index 11743d62..d33392f7 100644 --- a/crates/application/src/import/tests/get_mapping_stage.rs +++ b/crates/application/src/import/tests/get_mapping_stage.rs @@ -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 { diff --git a/crates/application/src/import/tests/get_preview_stage.rs b/crates/application/src/import/tests/get_preview_stage.rs index 7467c60a..a8644d3e 100644 --- a/crates/application/src/import/tests/get_preview_stage.rs +++ b/crates/application/src/import/tests/get_preview_stage.rs @@ -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 _, }; diff --git a/crates/application/src/import/tests/get_session_state.rs b/crates/application/src/import/tests/get_session_state.rs index ac02dbd7..f8d4e218 100644 --- a/crates/application/src/import/tests/get_session_state.rs +++ b/crates/application/src/import/tests/get_session_state.rs @@ -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 { diff --git a/crates/application/src/import/tests/list_profiles.rs b/crates/application/src/import/tests/list_profiles.rs index 79f20a7a..0fd096e5 100644 --- a/crates/application/src/import/tests/list_profiles.rs +++ b/crates/application/src/import/tests/list_profiles.rs @@ -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 _, }; diff --git a/crates/application/src/import/tests/save_profile.rs b/crates/application/src/import/tests/save_profile.rs index 41698fd5..4714a5dc 100644 --- a/crates/application/src/import/tests/save_profile.rs +++ b/crates/application/src/import/tests/save_profile.rs @@ -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)); diff --git a/crates/application/src/integrations/tests/cleanup.rs b/crates/application/src/integrations/tests/cleanup.rs index df614ae7..ec321c62 100644 --- a/crates/application/src/integrations/tests/cleanup.rs +++ b/crates/application/src/integrations/tests/cleanup.rs @@ -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(); diff --git a/crates/application/src/integrations/tests/confirm.rs b/crates/application/src/integrations/tests/confirm.rs index 9c7f3a99..a967d22e 100644 --- a/crates/application/src/integrations/tests/confirm.rs +++ b/crates/application/src/integrations/tests/confirm.rs @@ -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 { } fn deps( - watch_events: &Arc, + watch_events: &Arc, review_logger: Arc, ) -> 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 = 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( diff --git a/crates/application/src/integrations/tests/dismiss.rs b/crates/application/src/integrations/tests/dismiss.rs index e764efc6..1ebfdfce 100644 --- a/crates/application/src/integrations/tests/dismiss.rs +++ b/crates/application/src/integrations/tests/dismiss.rs @@ -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) -> DismissWatchEventsDeps { +fn deps(watch_events: &Arc) -> 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) -> 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); diff --git a/crates/application/src/integrations/tests/generate_token.rs b/crates/application/src/integrations/tests/generate_token.rs index 31227c5e..80334ac1 100644 --- a/crates/application/src/integrations/tests/generate_token.rs +++ b/crates/application/src/integrations/tests/generate_token.rs @@ -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 = InMemoryWebhookTokenRepository::new(); + let tokens: Arc = InMemoryStore::new(); let user_id = Uuid::new_v4(); let result = generate_token::execute( diff --git a/crates/application/src/integrations/tests/get_queue.rs b/crates/application/src/integrations/tests/get_queue.rs index 2169cbc2..50bacfb4 100644 --- a/crates/application/src/integrations/tests/get_queue.rs +++ b/crates/application/src/integrations/tests/get_queue.rs @@ -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) -> GetWatchQueueDeps { +fn deps(events: &Arc) -> GetWatchQueueDeps { GetWatchQueueDeps { watch_event_query: Arc::clone(events) as _, } @@ -18,7 +18,7 @@ fn deps(events: &Arc) -> 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( diff --git a/crates/application/src/integrations/tests/get_tokens.rs b/crates/application/src/integrations/tests/get_tokens.rs index 8577eb87..5eedc517 100644 --- a/crates/application/src/integrations/tests/get_tokens.rs +++ b/crates/application/src/integrations/tests/get_tokens.rs @@ -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) -> GetWebhookTokensDeps { #[tokio::test] async fn returns_empty_when_no_tokens() { - let tokens: Arc = InMemoryWebhookTokenRepository::new(); + let tokens: Arc = 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 = InMemoryWebhookTokenRepository::new(); + let tokens: Arc = InMemoryStore::new(); let user_id = Uuid::new_v4(); diff --git a/crates/application/src/integrations/tests/ingest.rs b/crates/application/src/integrations/tests/ingest.rs index b5d5ed47..98194231 100644 --- a/crates/application/src/integrations/tests/ingest.rs +++ b/crates/application/src/integrations/tests/ingest.rs @@ -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 = InMemoryWebhookTokenRepository::new(); - let watch_events = InMemoryWatchEventRepository::new(); + let tokens = InMemoryStore::new(); + let watch_events = Arc::clone(&tokens); let event_publisher: Arc = 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 = InMemoryWebhookTokenRepository::new(); - let watch_events = InMemoryWatchEventRepository::new(); + let tokens = InMemoryStore::new(); + let watch_events = Arc::clone(&tokens); let event_publisher: Arc = 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), diff --git a/crates/application/src/integrations/tests/revoke_token.rs b/crates/application/src/integrations/tests/revoke_token.rs index bc7dac40..718ac757 100644 --- a/crates/application/src/integrations/tests/revoke_token.rs +++ b/crates/application/src/integrations/tests/revoke_token.rs @@ -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 = InMemoryWebhookTokenRepository::new(); + let tokens: Arc = InMemoryStore::new(); let user_id = Uuid::new_v4(); diff --git a/crates/application/src/movies/tests/enrich_movie.rs b/crates/application/src/movies/tests/enrich_movie.rs index 8fb3a21f..89d7c59f 100644 --- a/crates/application/src/movies/tests/enrich_movie.rs +++ b/crates/application/src/movies/tests/enrich_movie.rs @@ -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); } diff --git a/crates/application/src/movies/tests/get_movie_profile.rs b/crates/application/src/movies/tests/get_movie_profile.rs index 577ea3d4..969625ae 100644 --- a/crates/application/src/movies/tests/get_movie_profile.rs +++ b/crates/application/src/movies/tests/get_movie_profile.rs @@ -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 { diff --git a/crates/application/src/movies/tests/get_movies.rs b/crates/application/src/movies/tests/get_movies.rs index 9ac9c9e0..3bb8c49d 100644 --- a/crates/application/src/movies/tests/get_movies.rs +++ b/crates/application/src/movies/tests/get_movies.rs @@ -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( diff --git a/crates/application/src/movies/tests/request_enrichment.rs b/crates/application/src/movies/tests/request_enrichment.rs index 1de0be2d..bee95b9d 100644 --- a/crates/application/src/movies/tests/request_enrichment.rs +++ b/crates/application/src/movies/tests/request_enrichment.rs @@ -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( diff --git a/crates/application/src/movies/tests/sync_poster.rs b/crates/application/src/movies/tests/sync_poster.rs index 40604afd..b9588196 100644 --- a/crates/application/src/movies/tests/sync_poster.rs +++ b/crates/application/src/movies/tests/sync_poster.rs @@ -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(); diff --git a/crates/application/src/social/tests/execute.rs b/crates/application/src/social/tests/execute.rs index 724afa00..3e907a03 100644 --- a/crates/application/src/social/tests/execute.rs +++ b/crates/application/src/social/tests/execute.rs @@ -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, + Arc, Arc, 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 _, diff --git a/crates/application/src/social/tests/local_service.rs b/crates/application/src/social/tests/local_service.rs index 0acc875e..cbe2b3ad 100644 --- a/crates/application/src/social/tests/local_service.rs +++ b/crates/application/src/social/tests/local_service.rs @@ -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, username: &str) -> UserId { +async fn register_user(repo: &Arc, 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, username: &str) -> Us } fn service( - user_repo: Arc, + user_repo: Arc, follow_store: &Arc, 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()); diff --git a/crates/application/src/test_helpers.rs b/crates/application/src/test_helpers.rs index 51872a51..a377538b 100644 --- a/crates/application/src/test_helpers.rs +++ b/crates/application/src/test_helpers.rs @@ -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(), diff --git a/crates/application/src/users/tests/authorize_admin.rs b/crates/application/src/users/tests/authorize_admin.rs index aebf71cb..ce2e6eda 100644 --- a/crates/application/src/users/tests/authorize_admin.rs +++ b/crates/application/src/users/tests/authorize_admin.rs @@ -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, diff --git a/crates/application/src/users/tests/get_current_profile.rs b/crates/application/src/users/tests/get_current_profile.rs index 3f7b0f5b..01f6faac 100644 --- a/crates/application/src/users/tests/get_current_profile.rs +++ b/crates/application/src/users/tests/get_current_profile.rs @@ -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(); diff --git a/crates/application/src/users/tests/update_profile.rs b/crates/application/src/users/tests/update_profile.rs index 1145c77d..f1e0de0c 100644 --- a/crates/application/src/users/tests/update_profile.rs +++ b/crates/application/src/users/tests/update_profile.rs @@ -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) -> Uuid { +async fn register_user(b: &TestContextBuilder, users: &Arc) -> 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, - watchlist: Arc, -) -> WatchlistAddDeps { +fn make_deps(movies: Arc, watchlist: Arc) -> 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)); diff --git a/crates/application/src/watchlist/tests/get_watchlist_for_owner.rs b/crates/application/src/watchlist/tests/get_watchlist_for_owner.rs index e9788ba3..2d13a9d4 100644 --- a/crates/application/src/watchlist/tests/get_watchlist_for_owner.rs +++ b/crates/application/src/watchlist/tests/get_watchlist_for_owner.rs @@ -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, }; diff --git a/crates/application/src/watchlist/tests/is_on.rs b/crates/application/src/watchlist/tests/is_on.rs index 7ec7e09a..9ca68ba3 100644 --- a/crates/application/src/watchlist/tests/is_on.rs +++ b/crates/application/src/watchlist/tests/is_on.rs @@ -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 diff --git a/crates/application/src/watchlist/tests/remove.rs b/crates/application/src/watchlist/tests/remove.rs index 0a58d27d..256f629c 100644 --- a/crates/application/src/watchlist/tests/remove.rs +++ b/crates/application/src/watchlist/tests/remove.rs @@ -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 diff --git a/crates/bootstrap/src/tests/build.rs b/crates/bootstrap/src/tests/build.rs index c3c506f9..f88cf6b8 100644 --- a/crates/bootstrap/src/tests/build.rs +++ b/crates/bootstrap/src/tests/build.rs @@ -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), diff --git a/crates/domain/src/models/tests/review.rs b/crates/domain/src/models/tests/review.rs index 74c9c9b1..13eacffa 100644 --- a/crates/domain/src/models/tests/review.rs +++ b/crates/domain/src/models/tests/review.rs @@ -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) -> Review { }) } +fn new_review_with_medium(watch_medium: Option) -> 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); } diff --git a/crates/domain/src/testing/insights.rs b/crates/domain/src/testing/fakes/insights.rs similarity index 100% rename from crates/domain/src/testing/insights.rs rename to crates/domain/src/testing/fakes/insights.rs diff --git a/crates/domain/src/testing/fakes/mod.rs b/crates/domain/src/testing/fakes/mod.rs new file mode 100644 index 00000000..59a15eaf --- /dev/null +++ b/crates/domain/src/testing/fakes/mod.rs @@ -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::*; diff --git a/crates/domain/src/testing/noops.rs b/crates/domain/src/testing/fakes/noops.rs similarity index 100% rename from crates/domain/src/testing/noops.rs rename to crates/domain/src/testing/fakes/noops.rs diff --git a/crates/domain/src/testing/panics.rs b/crates/domain/src/testing/fakes/panics.rs similarity index 100% rename from crates/domain/src/testing/panics.rs rename to crates/domain/src/testing/fakes/panics.rs diff --git a/crates/domain/src/testing/fakes/store.rs b/crates/domain/src/testing/fakes/store.rs new file mode 100644 index 00000000..857210a0 --- /dev/null +++ b/crates/domain/src/testing/fakes/store.rs @@ -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` across all port slots. +pub struct InMemoryStore { + pub(super) movies: RwLock>, + pub(super) movie_profiles: RwLock>, + pub(super) reviews: RwLock>, + pub(super) users: RwLock>, + pub(super) profile_fields: RwLock>>, + pub(super) user_settings: RwLock>, + pub(super) refresh_sessions: RwLock>, + pub(super) watchlist: RwLock>, + pub(super) remote_watchlist: RwLock>, + pub(super) goals: RwLock>, + pub(super) watch_events: RwLock>, + pub(super) webhook_tokens: RwLock>, + pub(super) import_sessions: RwLock>, + pub(super) import_profiles: RwLock>, + pub(super) follows: RwLock>, + pub(super) blocked: RwLock>, +} + +impl InMemoryStore { + pub fn new() -> Arc { + 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) -> Arc { + 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() + } +} diff --git a/crates/domain/src/testing/fakes/store_goal.rs b/crates/domain/src/testing/fakes/store_goal.rs new file mode 100644 index 00000000..c927457e --- /dev/null +++ b/crates/domain/src/testing/fakes/store_goal.rs @@ -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, 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, DomainError> { + let store = self.goals.read().unwrap(); + Ok(store + .values() + .filter(|g| g.user_id().value() == user_id.value()) + .cloned() + .collect()) + } +} diff --git a/crates/domain/src/testing/fakes/store_import.rs b/crates/domain/src/testing/fakes/store_import.rs new file mode 100644 index 00000000..b5108ec2 --- /dev/null +++ b/crates/domain/src/testing/fakes/store_import.rs @@ -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, 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 { + 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, 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, 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(()) + } +} diff --git a/crates/domain/src/testing/fakes/store_movie.rs b/crates/domain/src/testing/fakes/store_movie.rs new file mode 100644 index 00000000..56852ee8 --- /dev/null +++ b/crates/domain/src/testing/fakes/store_movie.rs @@ -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, 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, 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, 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, DomainError> { + let store = self.movies.read().unwrap(); + let known: std::collections::HashSet = 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, 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, DomainError> { + Ok(Paginated { + items: vec![], + total_count: 0, + limit: 10, + offset: 0, + }) + } + + async fn list_movies_with_external_id(&self) -> Result, 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, DomainError> { + Ok(self + .movie_profiles + .read() + .unwrap() + .get(&id.value()) + .cloned()) + } + + async fn list_stale(&self) -> Result, 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(), + }) +} diff --git a/crates/domain/src/testing/fakes/store_review.rs b/crates/domain/src/testing/fakes/store_review.rs new file mode 100644 index 00000000..cf276ac4 --- /dev/null +++ b/crates/domain/src/testing/fakes/store_review.rs @@ -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, 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, DomainError> { + let store = self.reviews.read().unwrap(); + Ok(store + .values() + .filter(|r| r.user_id() == user_id) + .cloned() + .collect()) + } +} diff --git a/crates/domain/src/testing/fakes/store_session.rs b/crates/domain/src/testing/fakes/store_session.rs new file mode 100644 index 00000000..514e56af --- /dev/null +++ b/crates/domain/src/testing/fakes/store_session.rs @@ -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, 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 { + 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) + } +} diff --git a/crates/domain/src/testing/fakes/store_social.rs b/crates/domain/src/testing/fakes/store_social.rs new file mode 100644 index 00000000..2d5395ff --- /dev/null +++ b/crates/domain/src/testing/fakes/store_social.rs @@ -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, 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, 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, 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, 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 { + 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 { + 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 { + 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 { + 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, DomainError> { + let store = self.blocked.read().unwrap(); + Ok(store + .iter() + .filter(|(b, _)| *b == user.value()) + .map(|(_, t)| identity_to_actor(t)) + .collect()) + } +} diff --git a/crates/domain/src/testing/fakes/store_user.rs b/crates/domain/src/testing/fakes/store_user.rs new file mode 100644 index 00000000..9c0a4c28 --- /dev/null +++ b/crates/domain/src/testing/fakes/store_user.rs @@ -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, 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, 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, DomainError> { + Ok(self.users.read().unwrap().get(&id.value()).cloned()) + } + + async fn list_with_stats(&self) -> Result, 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, 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, + ) -> 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 { + 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 { + 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); + } +} diff --git a/crates/domain/src/testing/fakes/store_watch_event.rs b/crates/domain/src/testing/fakes/store_watch_event.rs new file mode 100644 index 00000000..1bd2625f --- /dev/null +++ b/crates/domain/src/testing/fakes/store_watch_event.rs @@ -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 { + Ok(ids.len() as u64) + } + + async fn delete_non_pending_older_than( + &self, + before: NaiveDateTime, + ) -> Result { + 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, 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, 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, DomainError> { + let id_vals: Vec = 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 { + 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, 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, 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(()) + } +} diff --git a/crates/domain/src/testing/fakes/store_watchlist.rs b/crates/domain/src/testing/fakes/store_watchlist.rs new file mode 100644 index 00000000..57c2a7ba --- /dev/null +++ b/crates/domain/src/testing/fakes/store_watchlist.rs @@ -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 { + 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, DomainError> { + Ok(Paginated { + items: vec![], + total_count: 0, + limit: 10, + offset: 0, + }) + } + + async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result { + 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, 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, DomainError> { + Ok(self.remote_watchlist.read().unwrap().clone()) + } +} diff --git a/crates/domain/src/testing/fakes.rs b/crates/domain/src/testing/fakes/stubs.rs similarity index 100% rename from crates/domain/src/testing/fakes.rs rename to crates/domain/src/testing/fakes/stubs.rs diff --git a/crates/domain/src/testing/fakes/tests.rs b/crates/domain/src/testing/fakes/tests.rs new file mode 100644 index 00000000..34305ea3 --- /dev/null +++ b/crates/domain/src/testing/fakes/tests.rs @@ -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) -> (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, 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(_)))); +} diff --git a/crates/domain/src/testing/wrapup.rs b/crates/domain/src/testing/fakes/wrapup.rs similarity index 100% rename from crates/domain/src/testing/wrapup.rs rename to crates/domain/src/testing/fakes/wrapup.rs diff --git a/crates/domain/src/testing/in_memory.rs b/crates/domain/src/testing/in_memory.rs deleted file mode 100644 index 21a20c6d..00000000 --- a/crates/domain/src/testing/in_memory.rs +++ /dev/null @@ -1,1214 +0,0 @@ -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -use async_trait::async_trait; -use uuid::Uuid; - -use chrono::NaiveDateTime; - -use chrono::Utc; - -use crate::{ - errors::DomainError, - models::{ - FederationFlags, Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile, - MovieSummary, ProfileField, RefreshSession, RemoteWatchlistEntry, Review, User, - UserSettings, UserSummary, WatchEvent, WatchEventStatus, WatchlistEntry, - WatchlistWithMovie, WebhookToken, - collections::{PageParams, Paginated}, - }, - ports::{ - BlockQuery, FollowGraphQuery, GoalCommand, GoalQuery, ImportProfileRepository, - ImportSessionRepository, MovieCommand, MovieProfileRepository, MovieQuery, - RefreshSessionRepository, RemoteWatchlistRepository, ReviewRepository, SocialCommand, - UserFederationSettingsQuery, UserProfileFieldsRepository, UserRepository, - UserSettingsRepository, WatchEventCommand, WatchEventQuery, WatchlistRepository, - WebhookTokenRepository, - }, - value_objects::{ - Email, ExternalMetadataId, FollowRelation, FollowStatus, GoalId, ImportProfileId, - ImportSessionId, MovieId, MovieTitle, ReleaseYear, ReviewId, SocialActor, SocialIdentity, - UserId, Username, WatchEventId, WebhookTokenId, - }, -}; - -// ── InMemoryMovieRepository ─────────────────────────────────────────────────── - -pub struct InMemoryMovieRepository { - pub store: Mutex>, -} - -impl InMemoryMovieRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl MovieCommand for InMemoryMovieRepository { - async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .insert(movie.id().value(), movie.clone()); - Ok(()) - } - - async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> { - self.store.lock().unwrap().remove(&movie_id.value()); - Ok(()) - } -} - -#[async_trait] -impl MovieQuery for InMemoryMovieRepository { - async fn get_movie_by_external_id( - &self, - external_metadata_id: &ExternalMetadataId, - ) -> Result, DomainError> { - let store = self.store.lock().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, DomainError> { - Ok(self.store.lock().unwrap().get(&movie_id.value()).cloned()) - } - - async fn get_movies_by_title_and_year( - &self, - title: &MovieTitle, - year: &ReleaseYear, - ) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .values() - .filter(|m| m.title() == title && m.release_year() == year) - .cloned() - .collect()) - } - - async fn existing_external_ids( - &self, - ids: &[ExternalMetadataId], - ) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - let known: std::collections::HashSet = 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, DomainError> { - let store = self.store.lock().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, DomainError> { - Ok(Paginated { - items: vec![], - total_count: 0, - limit: 10, - offset: 0, - }) - } - - async fn list_movies_with_external_id(&self) -> Result, DomainError> { - Ok(self - .store - .lock() - .unwrap() - .values() - .filter(|m| m.external_metadata_id().is_some()) - .cloned() - .collect()) - } -} - -// ── InMemoryReviewRepository ────────────────────────────────────────────────── - -pub struct InMemoryReviewRepository { - store: Mutex>, -} - -impl InMemoryReviewRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl ReviewRepository for InMemoryReviewRepository { - async fn save_review(&self, review: &Review) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .insert(review.id().value(), review.clone()); - Ok(()) - } - - async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError> { - Ok(self.store.lock().unwrap().get(&review_id.value()).cloned()) - } - - async fn update_review(&self, review: &Review) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .insert(review.id().value(), review.clone()); - Ok(()) - } - - async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> { - self.store.lock().unwrap().remove(&review_id.value()); - Ok(()) - } - - async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .values() - .filter(|r| r.user_id() == user_id) - .cloned() - .collect()) - } -} - -// ── InMemoryUserRepository ──────────────────────────────────────────────────── - -pub struct InMemoryUserRepository { - pub store: Mutex>, -} - -impl InMemoryUserRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl UserRepository for InMemoryUserRepository { - async fn find_by_email(&self, email: &Email) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .values() - .find(|u| u.email().value() == email.value()) - .cloned()) - } - - async fn find_by_username(&self, username: &Username) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .values() - .find(|u| u.username().value() == username.value()) - .cloned()) - } - - async fn save(&self, user: &User) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .insert(user.id().value(), user.clone()); - Ok(()) - } - - async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { - Ok(self.store.lock().unwrap().get(&id.value()).cloned()) - } - - async fn list_with_stats(&self) -> Result, DomainError> { - Ok(vec![]) - } - - async fn update_profile( - &self, - user_id: &UserId, - profile: &crate::models::UserProfile, - ) -> Result<(), DomainError> { - let mut store = self.store.lock().unwrap(); - if let Some(user) = store.get_mut(&user_id.value()) { - user.update_profile(profile.clone()); - } - Ok(()) - } -} - -// ── InMemoryWatchlistRepository ─────────────────────────────────────────────── - -pub struct InMemoryWatchlistRepository { - store: Mutex>, -} - -impl InMemoryWatchlistRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl WatchlistRepository for InMemoryWatchlistRepository { - async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> { - let key = (entry.user_id.value(), entry.movie_id.value()); - self.store - .lock() - .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.store - .lock() - .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 { - let key = (user_id.value(), movie_id.value()); - Ok(self.store.lock().unwrap().remove(&key).is_some()) - } - - async fn get_for_user( - &self, - _user_id: &UserId, - _page: &PageParams, - ) -> Result, DomainError> { - Ok(Paginated { - items: vec![], - total_count: 0, - limit: 10, - offset: 0, - }) - } - - async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result { - let key = (user_id.value(), movie_id.value()); - Ok(self.store.lock().unwrap().contains_key(&key)) - } -} - -// ── InMemoryRemoteWatchlistRepository ───────────────────────────────────────── - -pub struct InMemoryRemoteWatchlistRepository { - store: Mutex>, -} - -impl InMemoryRemoteWatchlistRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(Vec::new()), - }) - } - - pub fn with_entries(entries: Vec) -> Arc { - Arc::new(Self { - store: Mutex::new(entries), - }) - } -} - -#[async_trait] -impl RemoteWatchlistRepository for InMemoryRemoteWatchlistRepository { - async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> { - self.store.lock().unwrap().push(entry); - Ok(()) - } - - async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError> { - self.store - .lock() - .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, DomainError> { - Ok(self - .store - .lock() - .unwrap() - .iter() - .filter(|e| e.actor_url == actor_url) - .cloned() - .collect()) - } - - async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .retain(|e| e.actor_url != actor_url); - Ok(()) - } - - async fn get_by_derived_uuid( - &self, - _uuid: Uuid, - ) -> Result, DomainError> { - Ok(self.store.lock().unwrap().clone()) - } -} - -// ── InMemoryGoalRepository ────────────────────────────────────────────────── - -pub struct InMemoryGoalRepository { - store: Mutex>, -} - -impl InMemoryGoalRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl GoalCommand for InMemoryGoalRepository { - async fn save(&self, goal: &Goal) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .insert(goal.id().value(), goal.clone()); - Ok(()) - } - - async fn update(&self, goal: &Goal) -> Result<(), DomainError> { - let mut store = self.store.lock().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.store.lock().unwrap().remove(&id.value()); - Ok(()) - } -} - -#[async_trait] -impl GoalQuery for InMemoryGoalRepository { - async fn find_by_user_and_year( - &self, - user_id: &UserId, - year: u16, - ) -> Result, DomainError> { - let store = self.store.lock().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, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store - .values() - .filter(|g| g.user_id().value() == user_id.value()) - .cloned() - .collect()) - } -} - -// ── InMemoryUserSettingsRepository ────────────────────────────────────────── - -pub struct InMemoryUserSettingsRepository { - store: Mutex>, -} - -impl InMemoryUserSettingsRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl UserSettingsRepository for InMemoryUserSettingsRepository { - async fn get(&self, user_id: &UserId) -> Result { - let store = self.store.lock().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.store - .lock() - .unwrap() - .insert(settings.user_id().value(), settings.clone()); - Ok(()) - } -} - -#[async_trait] -impl UserFederationSettingsQuery for InMemoryUserSettingsRepository { - async fn get_federation_flags(&self, user_id: &UserId) -> Result { - let store = self.store.lock().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(), - }) - } -} - -// ── InMemoryWebhookTokenRepository ────────────────────────────────────────── - -pub struct InMemoryWebhookTokenRepository { - store: Mutex>, -} - -impl InMemoryWebhookTokenRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(Vec::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl WebhookTokenRepository for InMemoryWebhookTokenRepository { - async fn save(&self, token: &WebhookToken) -> Result<(), DomainError> { - self.store.lock().unwrap().push(token.clone()); - Ok(()) - } - - async fn find_by_token_hash(&self, hash: &str) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store.iter().find(|t| t.token_hash() == hash).cloned()) - } - - async fn list_by_user(&self, user_id: &UserId) -> Result, DomainError> { - let store = self.store.lock().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.store - .lock() - .unwrap() - .retain(|t| t.id().value() != id.value()); - Ok(()) - } - - async fn touch_last_used(&self, _id: &WebhookTokenId) -> Result<(), DomainError> { - Ok(()) - } -} - -// ── InMemoryWatchEventRepository ──────────────────────────────────────────── - -pub struct InMemoryWatchEventRepository { - store: Mutex>, -} - -impl InMemoryWatchEventRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(Vec::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl WatchEventCommand for InMemoryWatchEventRepository { - async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> { - self.store.lock().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 { - Ok(ids.len() as u64) - } - - async fn delete_non_pending_older_than( - &self, - before: NaiveDateTime, - ) -> Result { - let mut store = self.store.lock().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 InMemoryWatchEventRepository { - async fn list_pending(&self, user_id: &UserId) -> Result, DomainError> { - let store = self.store.lock().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, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store.iter().find(|e| e.id().value() == id.value()).cloned()) - } - - async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result, DomainError> { - let id_vals: Vec = ids.iter().map(|id| id.value()).collect(); - let store = self.store.lock().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 { - let store = self.store.lock().unwrap(); - Ok(store.iter().any(|e| { - e.user_id().value() == user_id.value() - && e.external_metadata_id() == Some(external_id) - && *e.watched_at() > after - })) - } -} - -// ── InMemoryImportSessionRepository ───────────────────────────────────────── - -pub struct InMemoryImportSessionRepository { - store: Mutex>, -} - -impl InMemoryImportSessionRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl ImportSessionRepository for InMemoryImportSessionRepository { - async fn create(&self, session: &ImportSession) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .insert(session.id.value(), session.clone()); - Ok(()) - } - - async fn get( - &self, - id: &ImportSessionId, - user_id: &UserId, - ) -> Result, DomainError> { - let store = self.store.lock().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.store - .lock() - .unwrap() - .insert(session.id.value(), session.clone()); - Ok(()) - } - - async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> { - self.store.lock().unwrap().remove(&id.value()); - Ok(()) - } - - async fn delete_expired(&self) -> Result { - let mut store = self.store.lock().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.store.lock().unwrap(); - let now = chrono::Utc::now().naive_utc(); - store.retain(|_, s| !(s.user_id.value() == user_id.value() && s.expires_at <= now)); - Ok(()) - } -} - -// ── InMemoryImportProfileRepository ───────────────────────────────────────── - -pub struct InMemoryImportProfileRepository { - store: Mutex>, -} - -impl InMemoryImportProfileRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl ImportProfileRepository for InMemoryImportProfileRepository { - async fn save(&self, profile: &ImportProfile) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .insert(profile.id.value(), profile.clone()); - Ok(()) - } - - async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError> { - let store = self.store.lock().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, DomainError> { - let store = self.store.lock().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.store.lock().unwrap().remove(&id.value()); - Ok(()) - } -} - -// ── InMemoryMovieProfileRepository ────────────────────────────────────────── - -pub struct InMemoryMovieProfileRepository { - store: Mutex>, -} - -impl InMemoryMovieProfileRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl MovieProfileRepository for InMemoryMovieProfileRepository { - async fn upsert(&self, profile: &MovieProfile) -> Result<(), DomainError> { - self.store - .lock() - .unwrap() - .insert(profile.movie_id.value(), profile.clone()); - Ok(()) - } - - async fn get_by_movie_id(&self, id: &MovieId) -> Result, DomainError> { - Ok(self.store.lock().unwrap().get(&id.value()).cloned()) - } - - async fn list_stale(&self) -> Result, DomainError> { - Ok(vec![]) - } -} - -// ── InMemoryProfileFieldsRepo ─────────────────────────────────────────────── - -pub struct InMemoryProfileFieldsRepo { - store: Mutex>>, -} - -impl InMemoryProfileFieldsRepo { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(HashMap::new()), - }) - } - - pub fn count(&self) -> usize { - self.store.lock().unwrap().len() - } -} - -#[async_trait] -impl UserProfileFieldsRepository for InMemoryProfileFieldsRepo { - async fn get_fields(&self, user_id: &UserId) -> Result, DomainError> { - let store = self.store.lock().unwrap(); - Ok(store.get(&user_id.value()).cloned().unwrap_or_default()) - } - - async fn set_fields( - &self, - user_id: &UserId, - fields: Vec, - ) -> Result<(), DomainError> { - self.store.lock().unwrap().insert(user_id.value(), fields); - Ok(()) - } -} - -// ── InMemoryRefreshSessionRepository ──────────────────────────────────────── - -pub struct InMemoryRefreshSessionRepository { - pub store: Mutex>, -} - -impl InMemoryRefreshSessionRepository { - pub fn new() -> Arc { - Arc::new(Self { - store: Mutex::new(Vec::new()), - }) - } -} - -#[async_trait] -impl RefreshSessionRepository for InMemoryRefreshSessionRepository { - async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> { - self.store.lock().unwrap().push(session.clone()); - Ok(()) - } - - async fn get_by_token(&self, token: &str) -> Result, DomainError> { - Ok(self - .store - .lock() - .unwrap() - .iter() - .find(|s| s.token == token) - .cloned()) - } - - async fn revoke(&self, token: &str) -> Result<(), DomainError> { - self.store.lock().unwrap().retain(|s| s.token != token); - Ok(()) - } - - async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> { - self.store.lock().unwrap().retain(|s| s.user_id != *user_id); - Ok(()) - } - - async fn delete_expired(&self) -> Result { - let mut store = self.store.lock().unwrap(); - let before = store.len(); - let now = Utc::now(); - store.retain(|s| s.expires_at >= now); - Ok((before - store.len()) as u64) - } -} - -// ── InMemorySocialRepository ──────────────────────────────────────────────── - -#[derive(Debug, Clone, PartialEq, Eq)] -enum FollowState { - Pending, - Accepted, -} - -pub struct InMemorySocialRepository { - follows: Mutex>, - blocked: Mutex>, -} - -impl InMemorySocialRepository { - pub fn new() -> Arc { - Arc::new(Self { - follows: Mutex::new(Vec::new()), - blocked: Mutex::new(Vec::new()), - }) - } - - 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 InMemorySocialRepository { - 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.lock().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.lock().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.lock().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.lock().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.lock().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.lock().unwrap(); - store.push((blocker.value(), target.clone())); - // Also remove any existing follow relationships - let mut follows = self.follows.lock().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.lock().unwrap(); - store.retain(|(b, t)| !(*b == blocker.value() && t == target)); - Ok(()) - } -} - -#[async_trait] -impl FollowGraphQuery for InMemorySocialRepository { - async fn get_following(&self, user: &UserId) -> Result, DomainError> { - let store = self.follows.lock().unwrap(); - Ok(store - .iter() - .filter(|(f, _, state)| *f == user.value() && *state == FollowState::Accepted) - .map(|(_, t, _)| Self::identity_to_actor(t)) - .collect()) - } - - async fn get_followers(&self, user: &UserId) -> Result, DomainError> { - let store = self.follows.lock().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)); - Self::identity_to_actor(&id) - }) - .collect()) - } - - async fn get_pending_followers(&self, user: &UserId) -> Result, DomainError> { - let store = self.follows.lock().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)); - Self::identity_to_actor(&id) - }) - .collect()) - } - - async fn get_pending_following(&self, user: &UserId) -> Result, DomainError> { - let store = self.follows.lock().unwrap(); - Ok(store - .iter() - .filter(|(f, _, state)| *f == user.value() && *state == FollowState::Pending) - .map(|(_, t, _)| Self::identity_to_actor(t)) - .collect()) - } - - async fn count_following(&self, user: &UserId) -> Result { - let store = self.follows.lock().unwrap(); - Ok(store - .iter() - .filter(|(f, _, state)| *f == user.value() && *state == FollowState::Accepted) - .count()) - } - - async fn count_followers(&self, user: &UserId) -> Result { - let store = self.follows.lock().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 { - let store = self.follows.lock().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 { - let store = self.follows.lock().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 InMemorySocialRepository { - async fn get_blocked(&self, user: &UserId) -> Result, DomainError> { - let store = self.blocked.lock().unwrap(); - Ok(store - .iter() - .filter(|(b, _)| *b == user.value()) - .map(|(_, t)| Self::identity_to_actor(t)) - .collect()) - } -} diff --git a/crates/domain/src/testing/mod.rs b/crates/domain/src/testing/mod.rs index 8a36fbf8..007b939e 100644 --- a/crates/domain/src/testing/mod.rs +++ b/crates/domain/src/testing/mod.rs @@ -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::*;