//! 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"; /// Private in-memory database with plain `SqliteConnectOptions`; relies on /// sqlx's default `foreign_keys = ON`, which production also depends on. 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); }