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:
2026-10-25 22:34:08 +01:00
parent 1e6e9ddf6b
commit a2c713c1fa
39 changed files with 257 additions and 274 deletions

View File

@@ -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::*;

View File

@@ -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(_))));
}