test: move domain tests to tests/
Self dev-dep w/ test-helpers for domain::testing. Also moved social blanket-impl + InMemoryStore tests (public API only). No private-item exceptions.
This commit is contained in:
@@ -13,8 +13,6 @@ mod store_user;
|
||||
mod store_watch_event;
|
||||
mod store_watchlist;
|
||||
mod stubs;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod wrapup;
|
||||
|
||||
pub use insights::*;
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{InMemoryStore, MOVIE_CASCADE_TABLES, USER_CASCADE_TABLES};
|
||||
use crate::errors::DomainError;
|
||||
use crate::models::{
|
||||
CastMember, CrewMember, Genre, Keyword, Movie, MovieProfile, ProfileField, RefreshSession,
|
||||
UserSettings, WatchEvent, WatchEventSource, WatchlistEntry, WebhookToken,
|
||||
};
|
||||
use crate::ports::{
|
||||
GoalCommand, MovieCommand, MovieProfileRepository, RefreshSessionRepository, ReviewRepository,
|
||||
SocialCommand, UserProfileFieldsRepository, UserRepository, UserSettingsRepository,
|
||||
WatchEventCommand, WatchEventQuery, WatchlistRepository, WebhookTokenRepository,
|
||||
};
|
||||
use crate::testing::{
|
||||
test_goal, test_movie, test_movie_with_external_id, test_review, test_user, test_watched_at,
|
||||
};
|
||||
use crate::value_objects::{ExternalMetadataId, FollowTarget, MovieId, SocialIdentity, UserId};
|
||||
|
||||
const REMOTE_ACTOR: &str = "https://remote.example/users/ann";
|
||||
|
||||
fn movie_profile(movie_id: &MovieId) -> MovieProfile {
|
||||
MovieProfile {
|
||||
movie_id: movie_id.clone(),
|
||||
tmdb_id: 603,
|
||||
imdb_id: None,
|
||||
overview: None,
|
||||
tagline: None,
|
||||
runtime_minutes: None,
|
||||
budget_usd: None,
|
||||
revenue_usd: None,
|
||||
vote_average: None,
|
||||
vote_count: None,
|
||||
original_language: None,
|
||||
collection_name: None,
|
||||
genres: vec![Genre {
|
||||
tmdb_id: 1,
|
||||
name: "Sci-Fi".into(),
|
||||
}],
|
||||
keywords: vec![Keyword {
|
||||
tmdb_id: 2,
|
||||
name: "simulation".into(),
|
||||
}],
|
||||
cast: vec![CastMember {
|
||||
tmdb_person_id: 3,
|
||||
name: "Actor".into(),
|
||||
character: "Role".into(),
|
||||
billing_order: 0,
|
||||
profile_path: None,
|
||||
}],
|
||||
crew: vec![CrewMember {
|
||||
tmdb_person_id: 4,
|
||||
name: "Director".into(),
|
||||
job: "Director".into(),
|
||||
department: "Directing".into(),
|
||||
profile_path: None,
|
||||
}],
|
||||
enriched_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves a user and one row in every table the schema cascades from a user,
|
||||
/// plus rows in the tables that hang off a user without a foreign key.
|
||||
async fn seed_user_with_dependents(store: &Arc<InMemoryStore>) -> (UserId, Movie) {
|
||||
let user = test_user("alice");
|
||||
let user_id = user.id().clone();
|
||||
let other = test_user("bob");
|
||||
let movie = test_movie("Heat");
|
||||
UserRepository::save(&**store, &user).await.unwrap();
|
||||
UserRepository::save(&**store, &other).await.unwrap();
|
||||
store.upsert_movie(&movie).await.unwrap();
|
||||
|
||||
store
|
||||
.add(&WatchlistEntry::new(user_id.clone(), movie.id().clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
GoalCommand::save(&**store, &test_goal(&user_id, 2024, 50))
|
||||
.await
|
||||
.unwrap();
|
||||
UserSettingsRepository::save(&**store, &UserSettings::new(user_id.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.set_fields(
|
||||
&user_id,
|
||||
vec![ProfileField {
|
||||
name: "Site".into(),
|
||||
value: "https://example.com".into(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
WebhookTokenRepository::save(
|
||||
&**store,
|
||||
&WebhookToken::new(
|
||||
user_id.clone(),
|
||||
"hash".into(),
|
||||
WatchEventSource::Jellyfin,
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
WatchEventCommand::save(
|
||||
&**store,
|
||||
&WatchEvent::new(
|
||||
user_id.clone(),
|
||||
"Heat".into(),
|
||||
Some(1995),
|
||||
None,
|
||||
WatchEventSource::Jellyfin,
|
||||
test_watched_at(),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.follow(
|
||||
&user_id,
|
||||
&FollowTarget::Identity(SocialIdentity::Remote {
|
||||
actor_url: REMOTE_ACTOR.into(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.follow(
|
||||
other.id(),
|
||||
&FollowTarget::Identity(SocialIdentity::Local(user_id.clone())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.block(
|
||||
&user_id,
|
||||
&SocialIdentity::Remote {
|
||||
actor_url: REMOTE_ACTOR.into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
store
|
||||
.save_review(&test_review(movie.id(), &user_id, 4))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.create(&RefreshSession {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: user_id.clone(),
|
||||
token: "refresh-token".into(),
|
||||
expires_at: Utc::now() + chrono::Duration::days(1),
|
||||
created_at: Utc::now(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
(user_id, movie)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_user_removes_watchlist_goals_settings_profile_fields_tokens_events_follows_and_blocks()
|
||||
{
|
||||
let store = InMemoryStore::new();
|
||||
let (user_id, _) = seed_user_with_dependents(&store).await;
|
||||
for table in USER_CASCADE_TABLES {
|
||||
assert!(store.rows_in(table) > 0, "{table} should be seeded");
|
||||
}
|
||||
|
||||
store.delete_user(&user_id);
|
||||
|
||||
assert!(store.find_by_id(&user_id).await.unwrap().is_none());
|
||||
for table in USER_CASCADE_TABLES {
|
||||
assert_eq!(store.rows_in(table), 0, "{table} should cascade");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_user_keeps_reviews_and_refresh_sessions_because_the_schema_has_no_foreign_key()
|
||||
{
|
||||
let store = InMemoryStore::new();
|
||||
let (user_id, _) = seed_user_with_dependents(&store).await;
|
||||
|
||||
store.delete_user(&user_id);
|
||||
|
||||
assert_eq!(store.review_count(), 1);
|
||||
assert_eq!(store.refresh_session_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_user_leaves_other_users_rows_alone() {
|
||||
let store = InMemoryStore::new();
|
||||
let (user_id, movie) = seed_user_with_dependents(&store).await;
|
||||
let bystander = test_user("carol");
|
||||
UserRepository::save(&*store, &bystander).await.unwrap();
|
||||
store
|
||||
.add(&WatchlistEntry::new(
|
||||
bystander.id().clone(),
|
||||
movie.id().clone(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
store.delete_user(&user_id);
|
||||
|
||||
assert_eq!(store.watchlist_count(), 1);
|
||||
assert!(store.find_by_id(bystander.id()).await.unwrap().is_some());
|
||||
}
|
||||
|
||||
async fn seed_movie_with_dependents(store: &Arc<InMemoryStore>, with_review: bool) -> Movie {
|
||||
let user = test_user("alice");
|
||||
UserRepository::save(&**store, &user).await.unwrap();
|
||||
let movie = test_movie("Heat");
|
||||
store.upsert_movie(&movie).await.unwrap();
|
||||
store
|
||||
.add(&WatchlistEntry::new(user.id().clone(), movie.id().clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
store.upsert(&movie_profile(movie.id())).await.unwrap();
|
||||
WatchEventCommand::save(
|
||||
&**store,
|
||||
&WatchEvent::new(
|
||||
user.id().clone(),
|
||||
"Heat".into(),
|
||||
Some(1995),
|
||||
None,
|
||||
WatchEventSource::Plex,
|
||||
test_watched_at(),
|
||||
Some(movie.id().clone()),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
if with_review {
|
||||
store
|
||||
.save_review(&test_review(movie.id(), user.id(), 5))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
movie
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_movie_removes_watchlist_entries_and_enrichment_profile() {
|
||||
let store = InMemoryStore::new();
|
||||
let movie = seed_movie_with_dependents(&store, false).await;
|
||||
for table in MOVIE_CASCADE_TABLES {
|
||||
assert!(store.rows_in(table) > 0, "{table} should be seeded");
|
||||
}
|
||||
|
||||
store.delete_movie(movie.id()).await.unwrap();
|
||||
|
||||
assert_eq!(store.movie_count(), 0);
|
||||
for table in MOVIE_CASCADE_TABLES {
|
||||
assert_eq!(store.rows_in(table), 0, "{table} should cascade");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_movie_detaches_watch_events_instead_of_removing_them() {
|
||||
let store = InMemoryStore::new();
|
||||
let movie = seed_movie_with_dependents(&store, false).await;
|
||||
let user_id = store.watch_events.read().unwrap()[0].user_id().clone();
|
||||
|
||||
store.delete_movie(movie.id()).await.unwrap();
|
||||
|
||||
let pending = store.list_pending(&user_id).await.unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert!(pending[0].movie_id().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_a_movie_that_has_reviews_is_refused_because_reviews_do_not_cascade() {
|
||||
let store = InMemoryStore::new();
|
||||
let movie = seed_movie_with_dependents(&store, true).await;
|
||||
|
||||
let result = store.delete_movie(movie.id()).await;
|
||||
|
||||
assert!(matches!(result, Err(DomainError::InfrastructureError(_))));
|
||||
assert_eq!(store.movie_count(), 1);
|
||||
assert_eq!(store.review_count(), 1);
|
||||
assert_eq!(store.watchlist_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saving_a_user_with_a_taken_username_is_a_validation_error() {
|
||||
let store = InMemoryStore::new();
|
||||
UserRepository::save(&*store, &test_user("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
let clash = crate::models::User::new(
|
||||
crate::value_objects::Email::new("other@example.com".into()).unwrap(),
|
||||
crate::value_objects::Username::new("alice".into()).unwrap(),
|
||||
crate::value_objects::PasswordHash::new("hashed".into()).unwrap(),
|
||||
crate::models::UserRole::Standard,
|
||||
);
|
||||
|
||||
let result = UserRepository::save(&*store, &clash).await;
|
||||
|
||||
assert!(matches!(result, Err(DomainError::ValidationError(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saving_a_user_with_a_taken_email_is_a_validation_error() {
|
||||
let store = InMemoryStore::new();
|
||||
UserRepository::save(&*store, &test_user("alice"))
|
||||
.await
|
||||
.unwrap();
|
||||
let clash = crate::models::User::new(
|
||||
crate::value_objects::Email::new("alice@example.com".into()).unwrap(),
|
||||
crate::value_objects::Username::new("alice2".into()).unwrap(),
|
||||
crate::value_objects::PasswordHash::new("hashed".into()).unwrap(),
|
||||
crate::models::UserRole::Standard,
|
||||
);
|
||||
|
||||
let result = UserRepository::save(&*store, &clash).await;
|
||||
|
||||
assert!(matches!(result, Err(DomainError::ValidationError(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saving_a_second_goal_for_the_same_user_and_year_is_an_infrastructure_error() {
|
||||
let store = InMemoryStore::new();
|
||||
let user_id = UserId::generate();
|
||||
GoalCommand::save(&*store, &test_goal(&user_id, 2024, 10))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = GoalCommand::save(&*store, &test_goal(&user_id, 2024, 20)).await;
|
||||
|
||||
assert!(matches!(result, Err(DomainError::InfrastructureError(_))));
|
||||
assert_eq!(store.goal_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adding_the_same_movie_to_a_watchlist_twice_keeps_one_entry() {
|
||||
let store = InMemoryStore::new();
|
||||
let user_id = UserId::generate();
|
||||
let movie_id = MovieId::generate();
|
||||
|
||||
store
|
||||
.add(&WatchlistEntry::new(user_id.clone(), movie_id.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
store
|
||||
.add(&WatchlistEntry::new(user_id, movie_id))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(store.watchlist_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upserting_a_different_movie_with_a_taken_external_id_is_an_infrastructure_error() {
|
||||
let store = InMemoryStore::new();
|
||||
let external = ExternalMetadataId::new("tt0113277".into()).unwrap();
|
||||
store
|
||||
.upsert_movie(&test_movie_with_external_id("Heat", 1995, external.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = store
|
||||
.upsert_movie(&test_movie_with_external_id("Heat 2", 2030, external))
|
||||
.await;
|
||||
|
||||
assert!(matches!(result, Err(DomainError::InfrastructureError(_))));
|
||||
assert_eq!(store.movie_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creating_a_refresh_session_with_a_taken_token_is_an_infrastructure_error() {
|
||||
let store = InMemoryStore::new();
|
||||
let session = |token: &str| RefreshSession {
|
||||
id: Uuid::new_v4(),
|
||||
user_id: UserId::generate(),
|
||||
token: token.into(),
|
||||
expires_at: Utc::now() + chrono::Duration::days(1),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
store.create(&session("same")).await.unwrap();
|
||||
|
||||
let result = store.create(&session("same")).await;
|
||||
|
||||
assert!(matches!(result, Err(DomainError::InfrastructureError(_))));
|
||||
}
|
||||
Reference in New Issue
Block a user