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

@@ -0,0 +1,2 @@
#[path = "errors/errors_test.rs"]
mod errors_test;

View File

@@ -0,0 +1,31 @@
use domain::errors::DomainError;
#[test]
fn infrastructure_error_is_transient() {
assert!(DomainError::InfrastructureError("network timeout".into()).is_transient());
}
#[test]
fn not_found_is_not_transient() {
assert!(!DomainError::NotFound("thing".into()).is_transient());
}
#[test]
fn validation_error_is_not_transient() {
assert!(!DomainError::ValidationError("bad input".into()).is_transient());
}
#[test]
fn unauthorized_is_not_transient() {
assert!(!DomainError::Unauthorized("token expired".into()).is_transient());
}
#[test]
fn forbidden_is_not_transient() {
assert!(!DomainError::Forbidden("no access".into()).is_transient());
}
#[test]
fn invalid_rating_is_not_transient() {
assert!(!DomainError::InvalidRating { max: 5, given: 9 }.is_transient());
}

View File

@@ -0,0 +1,2 @@
#[path = "events/events_test.rs"]
mod events_test;

View File

@@ -0,0 +1,55 @@
use domain::events::DomainEvent;
use domain::value_objects::{FollowTarget, SocialIdentity, UserId};
#[test]
fn follow_accepted_matches() {
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
let event = DomainEvent::FollowAccepted {
owner: uid.clone(),
requester: SocialIdentity::Remote {
actor_url: "https://remote.example/users/alice".to_string(),
},
};
let DomainEvent::FollowAccepted { requester, .. } = event else {
panic!("wrong variant");
};
assert_eq!(
requester,
SocialIdentity::Remote {
actor_url: "https://remote.example/users/alice".to_string()
}
);
}
#[test]
fn follow_requested_with_identity() {
let follower = UserId::from_uuid(uuid::Uuid::new_v4());
let target = UserId::from_uuid(uuid::Uuid::new_v4());
let event = DomainEvent::FollowRequested {
follower: follower.clone(),
target: FollowTarget::Identity(SocialIdentity::Local(target.clone())),
};
assert!(matches!(
event,
DomainEvent::FollowRequested {
target: FollowTarget::Identity(SocialIdentity::Local(_)),
..
}
));
}
#[test]
fn follow_requested_with_handle() {
let follower = UserId::from_uuid(uuid::Uuid::new_v4());
let event = DomainEvent::FollowRequested {
follower: follower.clone(),
target: FollowTarget::Handle("@alice@remote.example".into()),
};
assert!(matches!(
event,
DomainEvent::FollowRequested {
target: FollowTarget::Handle(_),
..
}
));
}

View File

@@ -0,0 +1,12 @@
#[path = "models/constructors_test.rs"]
mod constructors_test;
#[path = "models/goal_test.rs"]
mod goal_test;
#[path = "models/person_test.rs"]
mod person_test;
#[path = "models/review_test.rs"]
mod review_test;
#[path = "models/stats_test.rs"]
mod stats_test;
#[path = "models/watch_event_test.rs"]
mod watch_event_test;

View File

@@ -0,0 +1,242 @@
use domain::models::*;
use domain::testing::{test_movie_with_director, test_movie_with_year, test_review, test_user};
use domain::value_objects::{Email, PasswordHash, UserId, Username};
#[test]
fn update_profile_sets_fields() {
let mut user = test_user("alice");
user.update_profile(UserProfile {
bio: Some("My bio".to_string()),
avatar_path: Some("avatars/abc".to_string()),
..Default::default()
});
assert_eq!(user.bio(), Some("My bio"));
assert_eq!(user.avatar_path(), Some("avatars/abc"));
}
#[test]
fn update_profile_clears_with_none() {
let mut user = test_user("alice");
user.update_profile(UserProfile {
bio: Some("bio".to_string()),
avatar_path: Some("path".to_string()),
..Default::default()
});
user.update_profile(UserProfile::default());
assert_eq!(user.bio(), None);
assert_eq!(user.avatar_path(), None);
}
// ── Movie ────────────────────────────────────────────────────────────────────
#[test]
fn movie_new_sets_fields() {
let m = Movie::new(
Some(domain::value_objects::ExternalMetadataId::new("tt1234567".into()).unwrap()),
domain::value_objects::MovieTitle::new("Blade Runner".into()).unwrap(),
domain::value_objects::ReleaseYear::new(1982).unwrap(),
Some("Ridley Scott".into()),
Some(domain::value_objects::PosterPath::new("/poster.jpg".into()).unwrap()),
);
assert_eq!(m.title().value(), "Blade Runner");
assert_eq!(m.release_year().value(), 1982);
assert_eq!(m.director(), Some("Ridley Scott"));
assert!(m.poster_path().is_some());
assert!(m.external_metadata_id().is_some());
}
#[test]
fn movie_update_poster() {
let mut m = test_movie_with_year("Blade Runner", 1982);
let new_poster = domain::value_objects::PosterPath::new("/new.jpg".into()).unwrap();
m.update_poster(new_poster);
assert_eq!(m.poster_path().unwrap().value(), "/new.jpg");
}
// ── Review ───────────────────────────────────────────────────────────────────
use domain::value_objects::{MovieId, Rating, ReviewId};
#[test]
fn review_new_sets_local_source() {
let r = test_review(&MovieId::generate(), &UserId::generate(), 4);
assert_eq!(*r.source(), ReviewSource::Local);
assert!(!r.is_remote());
}
#[test]
fn review_stars() {
let r = test_review(&MovieId::generate(), &UserId::generate(), 4);
assert_eq!(r.stars(), [true, true, true, true, false]);
}
#[test]
fn review_from_persistence() {
let id = ReviewId::generate();
let mid = MovieId::generate();
let uid = UserId::generate();
let ts = chrono::Utc::now().naive_utc();
let r = Review::from_persistence(PersistedReview {
id: id.clone(),
movie_id: mid.clone(),
user_id: uid.clone(),
rating: Rating::new(2).unwrap(),
comment: None,
watched_at: ts,
created_at: ts,
source: ReviewSource::Remote {
actor_url: "https://example.com/actor".into(),
},
watch_medium: None,
spoilers: true,
});
assert_eq!(*r.id(), id);
assert!(r.is_remote());
assert_eq!(r.comment(), None);
assert!(r.spoilers());
}
// ── User ─────────────────────────────────────────────────────────────────────
#[test]
fn user_new() {
let u = User::new(
Email::new("x@y.com".into()).unwrap(),
Username::new("bob".into()).unwrap(),
PasswordHash::new("hashed".into()).unwrap(),
UserRole::Admin,
);
assert_eq!(u.email().value(), "x@y.com");
assert_eq!(u.username().value(), "bob");
assert_eq!(u.role().as_str(), "admin");
assert_eq!(u.bio(), None);
}
#[test]
fn user_update_password() {
let mut u = test_user("alice");
u.update_password(PasswordHash::new("new_hash".into()).unwrap());
assert_eq!(u.password_hash().value(), "new_hash");
}
// ── GoalType ─────────────────────────────────────────────────────────────────
#[test]
fn goal_type_as_str() {
assert_eq!(GoalType::Movies.as_str(), "movies");
}
#[test]
fn goal_type_from_str() {
assert_eq!("movies".parse::<GoalType>().unwrap(), GoalType::Movies);
assert!("invalid".parse::<GoalType>().is_err());
}
// ── UserRole ─────────────────────────────────────────────────────────────────
#[test]
fn user_role_as_str() {
assert_eq!(UserRole::Standard.as_str(), "standard");
assert_eq!(UserRole::Admin.as_str(), "admin");
}
// ── ProfileField ─────────────────────────────────────────────────────────────
#[test]
fn profile_field_construction() {
let f = ProfileField {
name: "Website".into(),
value: "https://example.com".into(),
};
assert_eq!(f.name, "Website");
assert_eq!(f.value, "https://example.com");
}
// ── MovieStats ───────────────────────────────────────────────────────────────
#[test]
fn movie_stats_construction() {
let s = MovieStats {
total_count: 100,
avg_rating: Some(3.5),
federated_count: 10,
rating_histogram: [5, 10, 30, 40, 15],
};
assert_eq!(s.total_count, 100);
assert_eq!(s.rating_histogram[4], 15);
}
// ── FeedEntry ────────────────────────────────────────────────────────────────
#[test]
fn feed_entry_display_name_from_email() {
let entry = DiaryEntry::new(
test_movie_with_year("Test", 2000),
test_review(&MovieId::generate(), &UserId::generate(), 4),
);
let fe = FeedEntry::new(entry, "alice@example.com".into());
assert_eq!(fe.user_display_name(), "alice");
assert_eq!(fe.user_email(), "alice@example.com");
}
// ── MonthActivity ────────────────────────────────────────────────────────────
#[test]
fn month_activity_construction() {
let ma = MonthActivity {
year_month: "2024-06".into(),
month_label: "June".into(),
count: 5,
entries: vec![],
};
assert_eq!(ma.year_month, "2024-06");
assert_eq!(ma.count, 5);
assert!(ma.entries.is_empty());
}
// ── Movie::is_manual_match ───────────────────────────────────────────────────
#[test]
fn movie_is_manual_match_same_title_year() {
let m = test_movie_with_director("Blade Runner", 1982, "Ridley Scott");
let title = domain::value_objects::MovieTitle::new("Blade Runner".into()).unwrap();
let year = domain::value_objects::ReleaseYear::new(1982).unwrap();
assert!(m.is_manual_match(&title, &year, Some("ridley scott")));
}
#[test]
fn movie_is_manual_match_different_director_fails() {
let m = test_movie_with_director("Blade Runner", 1982, "Ridley Scott");
let title = domain::value_objects::MovieTitle::new("Blade Runner".into()).unwrap();
let year = domain::value_objects::ReleaseYear::new(1982).unwrap();
assert!(!m.is_manual_match(&title, &year, Some("Denis Villeneuve")));
}
// ── UserProfile field validation ─────────────────────────────────────────────
#[test]
fn profile_fields_validates_max_count() {
let fields: Vec<ProfileField> = (0..5)
.map(|i| ProfileField {
name: format!("f{i}"),
value: format!("v{i}"),
})
.collect();
assert!(UserProfile::validate_custom_fields(&fields).is_err());
}
#[test]
fn profile_fields_allows_four() {
let fields: Vec<ProfileField> = (0..4)
.map(|i| ProfileField {
name: format!("f{i}"),
value: format!("v{i}"),
})
.collect();
assert!(UserProfile::validate_custom_fields(&fields).is_ok());
}
#[test]
fn profile_fields_allows_zero() {
assert!(UserProfile::validate_custom_fields(&[]).is_ok());
}

View File

@@ -0,0 +1,86 @@
use domain::models::*;
use domain::testing::test_goal;
use domain::value_objects::{GoalId, UserId};
#[test]
fn new_goal_valid() {
let g = test_goal(&UserId::generate(), 2024, 52);
assert_eq!(g.year(), 2024);
assert_eq!(g.target_count(), 52);
}
#[test]
fn new_goal_rejects_year_before_2020() {
assert!(Goal::new(UserId::generate(), 2019, 10, GoalType::Movies).is_err());
}
#[test]
fn new_goal_rejects_zero_target() {
assert!(Goal::new(UserId::generate(), 2024, 0, GoalType::Movies).is_err());
}
#[test]
fn update_target_valid() {
let mut g = test_goal(&UserId::generate(), 2024, 10);
assert!(g.update_target(50).is_ok());
assert_eq!(g.target_count(), 50);
}
#[test]
fn update_target_rejects_zero() {
let mut g = test_goal(&UserId::generate(), 2024, 10);
assert!(g.update_target(0).is_err());
}
#[test]
fn from_persistence_preserves_fields() {
let id = GoalId::generate();
let uid = UserId::generate();
let ts = chrono::Utc::now().naive_utc();
let g = Goal::from_persistence(id.clone(), uid.clone(), 2025, 42, GoalType::Movies, ts);
assert_eq!(*g.id(), id);
assert_eq!(*g.user_id(), uid);
assert_eq!(g.year(), 2025);
assert_eq!(g.target_count(), 42);
assert_eq!(g.created_at(), &ts);
}
#[test]
fn percentage_calculation() {
let g = test_goal(&UserId::generate(), 2024, 100);
let wp = GoalWithProgress {
goal: g,
current_count: 50,
};
assert!((wp.percentage() - 50.0).abs() < f64::EPSILON);
}
#[test]
fn percentage_caps_at_100() {
let g = test_goal(&UserId::generate(), 2024, 10);
let wp = GoalWithProgress {
goal: g,
current_count: 20,
};
assert!((wp.percentage() - 100.0).abs() < f64::EPSILON);
}
#[test]
fn is_complete() {
let g = test_goal(&UserId::generate(), 2024, 10);
let wp = GoalWithProgress {
goal: g,
current_count: 10,
};
assert!(wp.is_complete());
}
#[test]
fn is_not_complete() {
let g = test_goal(&UserId::generate(), 2024, 10);
let wp = GoalWithProgress {
goal: g,
current_count: 9,
};
assert!(!wp.is_complete());
}

View File

@@ -0,0 +1,55 @@
use domain::models::*;
#[test]
fn person_new() {
let ext = ExternalPersonId::new("tmdb:12345");
let pid = PersonId::from_external(&ext);
let p = Person::basic(
pid,
ext,
"Keanu Reeves".into(),
Some("Acting".into()),
Some("/profiles/keanu.jpg".into()),
);
assert_eq!(p.name(), "Keanu Reeves");
assert_eq!(p.known_for_department(), Some("Acting"));
assert_eq!(p.profile_path(), Some("/profiles/keanu.jpg"));
assert_eq!(p.external_id().value(), "tmdb:12345");
assert_eq!(p.external_id().tmdb_id(), Some(12345));
}
#[test]
fn person_id_from_external() {
let ext = ExternalPersonId::new("tmdb:99999");
let pid = PersonId::from_external(&ext);
// UUIDv5 is deterministic — just ensure it's a valid uuid
let _ = pid.value();
}
#[test]
fn person_id_deterministic() {
let ext = ExternalPersonId::new("tmdb:42");
let a = PersonId::from_external(&ext);
let b = PersonId::from_external(&ext);
assert_eq!(a, b);
}
#[test]
fn person_credits_default_empty() {
let ext = ExternalPersonId::new("tmdb:1");
let pid = PersonId::from_external(&ext);
let p = Person::basic(pid, ext, "Test".into(), None, None);
let credits = PersonCredits {
person: p,
cast: vec![],
crew: vec![],
};
assert!(credits.cast.is_empty());
assert!(credits.crew.is_empty());
}
#[test]
fn external_person_id_tmdb_id_none_for_other() {
let ext = ExternalPersonId::new("imdb:nm0000206");
assert_eq!(ext.tmdb_id(), None);
}

View File

@@ -0,0 +1,106 @@
use domain::testing::{test_review, test_watched_at};
use domain::value_objects::{Comment, MovieId, Rating, UserId, WatchMedium};
use domain::models::*;
fn review_with_medium(watch_medium: Option<WatchMedium>) -> Review {
test_review(&MovieId::generate(), &UserId::generate(), 4).apply_edit(ReviewEdit {
comment: Some(Some(Comment::new("great film".into()).unwrap())),
watch_medium: Some(watch_medium),
..Default::default()
})
}
fn new_review_with_medium(watch_medium: Option<WatchMedium>) -> Review {
Review::new(
MovieId::generate(),
UserId::generate(),
Rating::new(4).unwrap(),
Some(Comment::new("great film".into()).unwrap()),
test_watched_at(),
watch_medium,
false,
)
.unwrap()
}
#[test]
fn new_review_stores_watch_medium() {
let review = new_review_with_medium(Some(WatchMedium::Cinema));
assert_eq!(review.watch_medium(), Some(&WatchMedium::Cinema));
}
#[test]
fn new_review_without_medium() {
let review = new_review_with_medium(None);
assert_eq!(review.watch_medium(), None);
}
#[test]
fn apply_edit_updates_rating_only() {
let original = review_with_medium(Some(WatchMedium::Streaming));
let edited = original.apply_edit(ReviewEdit {
rating: Some(Rating::new(2).unwrap()),
..Default::default()
});
assert_eq!(edited.rating().value(), 2);
assert_eq!(edited.comment().unwrap().value(), "great film");
assert_eq!(edited.watch_medium(), Some(&WatchMedium::Streaming));
}
#[test]
fn apply_edit_clears_comment() {
let original = review_with_medium(None);
let edited = original.apply_edit(ReviewEdit {
comment: Some(None),
..Default::default()
});
assert!(edited.comment().is_none());
assert_eq!(edited.rating().value(), 4);
}
#[test]
fn apply_edit_sets_watch_medium() {
let original = review_with_medium(None);
let edited = original.apply_edit(ReviewEdit {
watch_medium: Some(Some(WatchMedium::Cinema)),
..Default::default()
});
assert_eq!(edited.watch_medium(), Some(&WatchMedium::Cinema));
}
#[test]
fn apply_edit_clears_watch_medium() {
let original = review_with_medium(Some(WatchMedium::Cinema));
let edited = original.apply_edit(ReviewEdit {
watch_medium: Some(None),
..Default::default()
});
assert_eq!(edited.watch_medium(), None);
}
#[test]
fn apply_edit_toggles_spoilers() {
let original = review_with_medium(None);
let flag_on = original.apply_edit(ReviewEdit {
spoilers: Some(true),
..Default::default()
});
assert!(flag_on.spoilers());
let flag_off = flag_on.apply_edit(ReviewEdit {
spoilers: Some(false),
..Default::default()
});
assert!(!flag_off.spoilers());
let untouched = original.apply_edit(ReviewEdit::default());
assert!(!untouched.spoilers());
}
#[test]
fn apply_edit_no_changes_preserves_all() {
let original = review_with_medium(Some(WatchMedium::TV));
let edited = original.apply_edit(ReviewEdit::default());
assert_eq!(edited.rating().value(), 4);
assert_eq!(edited.comment().unwrap().value(), "great film");
assert_eq!(edited.watch_medium(), Some(&WatchMedium::TV));
}

View File

@@ -0,0 +1,71 @@
use domain::models::*;
fn appearance(person_id: u64, name: &str, billing: u32, movie: &str) -> ActorAppearance {
ActorAppearance {
tmdb_person_id: person_id,
name: name.to_string(),
profile_path: Some(format!("/profile_{person_id}.jpg")),
billing_order: billing,
movie_id: movie.to_string(),
}
}
#[test]
fn empty_appearances_returns_empty() {
assert!(compute_top_actors(vec![]).is_empty());
}
#[test]
fn lead_scores_higher_than_minor_with_same_movie_count() {
let appearances = vec![
appearance(1, "Lead Actor", 0, "movie_a"),
appearance(1, "Lead Actor", 1, "movie_b"),
appearance(2, "Minor Actor", 15, "movie_a"),
appearance(2, "Minor Actor", 12, "movie_b"),
];
let result = compute_top_actors(appearances);
assert_eq!(result.len(), 2);
assert_eq!(result[0].name, "Lead Actor");
assert_eq!(result[0].movie_count, 2);
assert!(result[0].score > result[1].score);
}
#[test]
fn same_movie_counted_once_per_actor() {
let appearances = vec![
appearance(1, "Actor A", 0, "movie_a"),
appearance(1, "Actor A", 0, "movie_a"),
];
let result = compute_top_actors(appearances);
assert_eq!(result[0].movie_count, 1);
}
#[test]
fn returns_at_most_five() {
let appearances: Vec<ActorAppearance> = (0..10)
.flat_map(|i| {
(0..=i)
.map(move |m| appearance(i as u64, &format!("Actor {i}"), 0, &format!("movie_{m}")))
})
.collect();
let result = compute_top_actors(appearances);
assert_eq!(result.len(), 5);
}
#[test]
fn tier_weights_applied_correctly() {
let appearances = vec![
appearance(1, "Lead", 0, "m1"),
appearance(2, "Support", 5, "m1"),
appearance(2, "Support", 3, "m2"),
appearance(3, "Minor", 10, "m1"),
];
let result = compute_top_actors(appearances);
assert_eq!(result[0].name, "Support");
assert!((result[0].score - 1.4).abs() < 0.001);
assert_eq!(result[0].movie_count, 2);
assert_eq!(result[1].name, "Lead");
assert!((result[1].score - 1.0).abs() < 0.001);
assert_eq!(result[2].name, "Minor");
assert!((result[2].score - 0.2).abs() < 0.001);
}

View File

@@ -0,0 +1,138 @@
use chrono::NaiveDateTime;
use domain::models::*;
use domain::value_objects::{MovieId, UserId, WebhookTokenId};
fn ts() -> NaiveDateTime {
chrono::NaiveDate::from_ymd_opt(2024, 6, 1)
.unwrap()
.and_hms_opt(12, 0, 0)
.unwrap()
}
#[test]
fn watch_event_new_has_pending_status() {
let e = WatchEvent::new(
UserId::generate(),
"Dune".into(),
Some(2021),
None,
WatchEventSource::Jellyfin,
ts(),
None,
);
assert_eq!(*e.status(), WatchEventStatus::Pending);
}
#[test]
fn watch_event_getters() {
let uid = UserId::generate();
let mid = MovieId::generate();
let e = WatchEvent::new(
uid.clone(),
"Arrival".into(),
Some(2016),
Some("ext123".into()),
WatchEventSource::Plex,
ts(),
Some(mid.clone()),
);
assert_eq!(*e.user_id(), uid);
assert_eq!(e.title(), "Arrival");
assert_eq!(e.year(), Some(2016));
assert_eq!(e.external_metadata_id(), Some("ext123"));
assert_eq!(*e.source(), WatchEventSource::Plex);
assert_eq!(e.watched_at(), &ts());
assert_eq!(*e.movie_id().unwrap(), mid);
}
#[test]
fn webhook_token_new() {
let uid = UserId::generate();
let t = WebhookToken::new(
uid.clone(),
"hash123".into(),
WatchEventSource::Jellyfin,
Some("my server".into()),
);
assert_eq!(*t.user_id(), uid);
assert_eq!(t.token_hash(), "hash123");
assert_eq!(*t.provider(), WatchEventSource::Jellyfin);
assert_eq!(t.label(), Some("my server"));
assert!(t.last_used_at().is_none());
}
#[test]
fn webhook_token_from_persistence() {
let id = WebhookTokenId::generate();
let uid = UserId::generate();
let created = ts();
let used = chrono::NaiveDate::from_ymd_opt(2024, 7, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap();
let t = WebhookToken::from_persistence(
id.clone(),
uid.clone(),
"h".into(),
WatchEventSource::Plex,
None,
created,
Some(used),
);
assert_eq!(*t.id(), id);
assert_eq!(*t.user_id(), uid);
assert_eq!(t.token_hash(), "h");
assert_eq!(*t.provider(), WatchEventSource::Plex);
assert_eq!(t.label(), None);
assert_eq!(t.created_at(), &created);
assert_eq!(t.last_used_at(), Some(&used));
}
#[test]
fn watch_event_source_display() {
assert_eq!(WatchEventSource::Jellyfin.to_string(), "jellyfin");
assert_eq!(WatchEventSource::Plex.to_string(), "plex");
}
#[test]
fn watch_event_source_from_str() {
assert_eq!(
"jellyfin".parse::<WatchEventSource>().unwrap(),
WatchEventSource::Jellyfin
);
assert_eq!(
"plex".parse::<WatchEventSource>().unwrap(),
WatchEventSource::Plex
);
assert!("unknown".parse::<WatchEventSource>().is_err());
}
#[test]
fn watch_event_status_display() {
assert_eq!(WatchEventStatus::Pending.to_string(), "pending");
assert_eq!(WatchEventStatus::Confirmed.to_string(), "confirmed");
assert_eq!(WatchEventStatus::Dismissed.to_string(), "dismissed");
}
#[test]
fn watch_event_status_from_str() {
for s in ["pending", "confirmed", "dismissed"] {
let parsed: WatchEventStatus = s.parse().unwrap();
assert_eq!(parsed.to_string(), s);
}
assert!("bogus".parse::<WatchEventStatus>().is_err());
}
#[test]
fn parsed_playback_event_fields() {
let p = ParsedPlaybackEvent {
title: "Matrix".into(),
year: Some(1999),
tmdb_id: Some("603".into()),
imdb_id: Some("tt0133093".into()),
};
assert_eq!(p.title, "Matrix");
assert_eq!(p.year, Some(1999));
assert_eq!(p.tmdb_id.as_deref(), Some("603"));
assert_eq!(p.imdb_id.as_deref(), Some("tt0133093"));
}

View File

@@ -0,0 +1,4 @@
#[path = "ports/federation_test.rs"]
mod federation_test;
#[path = "ports/social_test.rs"]
mod social_test;

View File

@@ -0,0 +1,42 @@
use domain::ports::noop::{NoopApBackfill, NoopApDocument, NoopInstanceBlocklist};
use domain::ports::{ApBackfillPort, ApDocumentPort, InstanceBlocklistPort};
#[tokio::test]
async fn noop_document_returns_empty_strings() {
let p = NoopApDocument;
assert_eq!(p.actor_json("anything").await.unwrap(), "");
assert_eq!(
p.followers_collection_json(uuid::Uuid::nil(), None)
.await
.unwrap(),
""
);
assert_eq!(
p.following_collection_json(uuid::Uuid::nil(), Some(2))
.await
.unwrap(),
""
);
}
#[tokio::test]
async fn noop_blocklist_returns_empty_and_ok() {
let p = NoopInstanceBlocklist;
assert!(p.get_blocked_domains().await.unwrap().is_empty());
p.add_blocked_domain("evil.example", Some("spam"))
.await
.unwrap();
p.remove_blocked_domain("evil.example").await.unwrap();
}
#[tokio::test]
async fn noop_backfill_returns_empty_and_ok() {
let p = NoopApBackfill;
assert!(p.get_following(uuid::Uuid::nil()).await.unwrap().is_empty());
p.import_remote_outbox("https://a/outbox", "https://a")
.await
.unwrap();
p.run_backfill_for_follower(uuid::Uuid::nil(), "https://a/inbox".into())
.await
.unwrap();
}

View File

@@ -0,0 +1,162 @@
use async_trait::async_trait;
use domain::errors::DomainError;
use domain::ports::{
BlockQuery, FollowGraphQuery, FollowTargetResolver, LocalSocial, ResolvedFollow, SocialCommand,
};
use domain::value_objects::SocialIdentity;
use std::sync::Arc;
struct Stub;
#[async_trait]
impl SocialCommand for Stub {
async fn follow(
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::FollowTarget,
) -> Result<(), DomainError> {
Ok(())
}
async fn unfollow(
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::SocialIdentity,
) -> Result<(), DomainError> {
Ok(())
}
async fn accept_follow(
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::SocialIdentity,
) -> Result<(), DomainError> {
Ok(())
}
async fn reject_follow(
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::SocialIdentity,
) -> Result<(), DomainError> {
Ok(())
}
async fn remove_follower(
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::SocialIdentity,
) -> Result<(), DomainError> {
Ok(())
}
async fn block(
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::SocialIdentity,
) -> Result<(), DomainError> {
Ok(())
}
async fn unblock(
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::SocialIdentity,
) -> Result<(), DomainError> {
Ok(())
}
}
#[async_trait]
impl FollowGraphQuery for Stub {
async fn get_following(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
Ok(vec![])
}
async fn get_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
Ok(vec![])
}
async fn get_pending_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
Ok(vec![])
}
async fn get_pending_following(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
Ok(vec![])
}
async fn count_following(
&self,
_: &domain::value_objects::UserId,
) -> Result<usize, DomainError> {
Ok(0)
}
async fn count_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<usize, DomainError> {
Ok(0)
}
async fn count_pending_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<usize, DomainError> {
Ok(0)
}
async fn get_relation(
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::SocialIdentity,
) -> Result<domain::value_objects::FollowRelation, DomainError> {
Err(DomainError::NotFound("stub".into()))
}
}
#[async_trait]
impl BlockQuery for Stub {
async fn get_blocked(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
Ok(vec![])
}
}
#[async_trait]
impl FollowTargetResolver for Stub {
async fn resolve_target(
&self,
target: &domain::value_objects::FollowTarget,
) -> Result<SocialIdentity, DomainError> {
match target {
domain::value_objects::FollowTarget::Identity(id) => Ok(id.clone()),
domain::value_objects::FollowTarget::Handle(handle) => Ok(SocialIdentity::Remote {
actor_url: handle.clone(),
}),
}
}
}
#[async_trait]
impl ResolvedFollow for Stub {
async fn follow_resolved(
&self,
_: &domain::value_objects::UserId,
_: &SocialIdentity,
) -> Result<(), DomainError> {
Ok(())
}
}
#[test]
fn blanket_impl_yields_a_trait_object() {
let local: Arc<dyn LocalSocial> = Arc::new(Stub);
// Reachable through each supertrait without a separate Arc.
let _: &dyn SocialCommand = local.as_ref();
let _: &dyn FollowGraphQuery = local.as_ref();
let _: &dyn BlockQuery = local.as_ref();
let _: &dyn FollowTargetResolver = local.as_ref();
let _: &dyn ResolvedFollow = local.as_ref();
}

View File

@@ -0,0 +1,6 @@
#[path = "services/insights_analyzer_test.rs"]
mod insights_analyzer_test;
#[path = "services/review_history_test.rs"]
mod review_history_test;
#[path = "services/wrapup_analyzer_test.rs"]
mod wrapup_analyzer_test;

View File

@@ -0,0 +1,435 @@
use chrono::{Datelike, FixedOffset, NaiveDate, Utc, Weekday};
use uuid::Uuid;
use domain::models::{Insight, WatchRow};
use domain::services::insights_analyzer::build_insights;
fn watch(genres: &[&str], rating: u8, date: NaiveDate, hour: u32) -> WatchRow {
WatchRow {
movie_id: Uuid::new_v4(),
rating,
watched_at: date.and_hms_opt(hour, 30, 0).unwrap(),
watch_medium: Some("streaming".to_string()),
genres: genres.iter().map(|g| g.to_string()).collect(),
}
}
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).unwrap()
}
fn kinds(insights: &[Insight]) -> Vec<&str> {
insights
.iter()
.map(|i| match i {
Insight::RatingLift { .. } => "rating_lift",
Insight::Seasonality { .. } => "seasonality",
Insight::MediumTiming { .. } => "medium_timing",
Insight::Rhythm { .. } => "rhythm",
})
.collect()
}
// ── Rating lift ──────────────────────────────────────────────────────────────
#[test]
fn rating_lift_compares_genre_to_personal_average() {
// 12 thrillers rated 5, 12 comedies rated 2 — user average 3.5.
let mut rows: Vec<WatchRow> = (0..12)
.map(|i| watch(&["Thriller"], 5, date(2024, 1, 1 + i), 20))
.collect();
rows.extend((0..12).map(|i| watch(&["Comedy"], 2, date(2024, 3, 1 + i), 20)));
let insights = build_insights(&rows, &Utc);
let thrill = insights.iter().find_map(|i| match i {
Insight::RatingLift {
genre,
delta,
sample_count,
..
} if genre == "Thriller" => Some((*delta, *sample_count)),
_ => None,
});
let (delta, count) = thrill.expect("Thriller lift expected");
assert!((delta - 1.5).abs() < 1e-9, "delta was {delta}");
assert_eq!(count, 12);
let comedy = insights.iter().find_map(|i| match i {
Insight::RatingLift { genre, delta, .. } if genre == "Comedy" => Some(*delta),
_ => None,
});
assert!((comedy.expect("Comedy lift expected") + 1.5).abs() < 1e-9);
}
#[test]
fn rating_lift_ignores_rewatches() {
// 10 first-time dramas rated 3 (baseline), 5 thrillers rated 5…
let mut rows: Vec<WatchRow> = (0..10)
.map(|i| watch(&["Drama"], 3, date(2024, 1, 1 + i), 20))
.collect();
let mut thriller_ids: Vec<uuid::Uuid> = Vec::new();
for i in 0..5 {
let row = watch(&["Thriller"], 5, date(2024, 2, 1 + i), 20);
thriller_ids.push(row.movie_id);
rows.push(row);
}
// …and 3 rewatches of those same thrillers rated 1, which must not count.
for (i, id) in thriller_ids.iter().enumerate() {
let mut rewatch = watch(&["Thriller"], 1, date(2024, 6, 1 + i as u32), 20);
rewatch.movie_id = *id;
rows.push(rewatch);
}
let insights = build_insights(&rows, &Utc);
let thrill = insights.iter().find_map(|i| match i {
Insight::RatingLift {
genre,
delta,
sample_count,
..
} if genre == "Thriller" => Some((*delta, *sample_count)),
_ => None,
});
let (delta, count) = thrill.expect("Thriller lift expected");
// 15 first-time rows: user_avg = (10*3 + 5*5)/15 = 55/15 ≈ 3.67,
// genre_avg = 5.0, delta ≈ 1.33.
assert!((delta - 1.33).abs() < 0.01, "delta was {delta}");
assert_eq!(count, 5, "rewatches must not join the genre sample");
}
#[test]
fn rating_lift_below_sample_threshold_is_absent() {
let mut rows: Vec<WatchRow> = (0..10)
.map(|i| watch(&["Drama"], 3, date(2024, 1, 1 + i), 20))
.collect();
// Only 4 thriller ratings — below MIN_GENRE_RATINGS, so no matter how
// high they are rated, the genre must not yield an insight.
rows.extend((0..4).map(|i| watch(&["Thriller"], 5, date(2024, 2, 1 + i), 20)));
let insights = build_insights(&rows, &Utc);
assert!(
!insights
.iter()
.any(|i| matches!(i, Insight::RatingLift { genre, .. } if genre == "Thriller")),
"a genre with 4 ratings must not yield a lift"
);
}
#[test]
fn rating_lift_below_delta_threshold_is_absent() {
// 5 thrillers rated 4; dramas mix to put the personal average at 3.85,
// so the thriller delta is 0.15 < 0.3.
let mut rows: Vec<WatchRow> = (0..12)
.map(|i| watch(&["Drama"], 4, date(2024, 1, 1 + i % 28), 20))
.collect();
rows.extend((0..3).map(|i| watch(&["Drama"], 3, date(2024, 1, 13 + i), 20)));
rows.extend((0..5).map(|i| watch(&["Thriller"], 4, date(2024, 2, 1 + i), 20)));
let insights = build_insights(&rows, &Utc);
assert!(!kinds(&insights).contains(&"rating_lift"));
}
// ── Seasonality ──────────────────────────────────────────────────────────────
#[test]
fn seasonality_detects_month_peak_against_own_monthly_baseline() {
// 16 horrors: 10 in October across two years, 6 scattered.
let mut rows: Vec<WatchRow> = Vec::new();
for i in 0..10u32 {
let year = if i < 5 { 2023 } else { 2024 };
rows.push(watch(&["Horror"], 4, date(year, 10, 1 + i % 27), 21));
}
for (m, d) in [(1u32, 3u32), (2, 7), (4, 11), (6, 15), (8, 19), (12, 21)] {
rows.push(watch(&["Horror"], 4, date(2024, m, d), 21));
}
// A perfectly spread genre (2 watches per month) must NOT appear.
rows.extend((0..24).map(|i| watch(&["Drama"], 3, date(2024, 1 + i % 12, 5), 21)));
let insights = build_insights(&rows, &Utc);
let horror = insights.iter().find_map(|i| match i {
Insight::Seasonality {
genre,
month,
multiplier,
sample_count,
..
} if genre == "Horror" => Some((*month, *multiplier, *sample_count)),
_ => None,
});
let (month, multiplier, count) = horror.expect("Horror seasonality expected");
assert_eq!(month, 10);
// Horror: 10 of 16 watches in October (62.5%); the whole diary puts
// 12 of 40 watches there (30%) → 0.625 / 0.3 ≈ 2.08× the user's own share.
assert!(
(multiplier - 2.08).abs() < 1e-9,
"multiplier was {multiplier}"
);
assert_eq!(count, 16);
assert!(
!insights
.iter()
.any(|i| matches!(i, Insight::Seasonality { genre, .. } if genre == "Drama")),
"a spread-out genre must not yield a seasonality insight"
);
}
#[test]
fn seasonality_ignores_when_the_diary_itself_is_concentrated() {
// A diary started in October: every genre peaks in Oct/Nov only because
// the user logged nothing else. Relative to their own months, nothing is
// seasonal.
let mut rows: Vec<WatchRow> = Vec::new();
for i in 0..20u32 {
let month = if i % 2 == 0 { 10 } else { 11 };
rows.push(watch(&["Drama"], 3, date(2024, month, 1 + i), 21));
rows.push(watch(&["Comedy"], 3, date(2024, month, 1 + i), 21));
}
assert!(!kinds(&build_insights(&rows, &Utc)).contains(&"seasonality"));
}
#[test]
fn seasonality_below_sample_threshold_is_absent() {
let rows: Vec<WatchRow> = (0..14)
.map(|i| watch(&["Horror"], 4, date(2024, 10, 1 + i), 21))
.collect();
assert!(!kinds(&build_insights(&rows, &Utc)).contains(&"seasonality"));
}
// ── Medium timing ────────────────────────────────────────────────────────────
#[test]
fn medium_timing_detects_weekend_lift() {
let saturday = date(2024, 3, 9); // a Saturday
assert_eq!(saturday.weekday(), Weekday::Sat);
let friday = date(2024, 3, 8);
let mut rows: Vec<WatchRow> = Vec::new();
// 6 cinema watches, 5 of them on Saturdays.
let cinema_days = [
saturday,
saturday.with_day(16).unwrap(),
saturday.with_day(23).unwrap(),
saturday.with_day(30).unwrap(),
date(2024, 4, 6), // next month's first Saturday
friday,
];
for day in cinema_days {
let mut row = watch(&["Drama"], 4, day, 19);
row.watch_medium = Some("cinema".to_string());
rows.push(row);
}
// 10 streaming watches on weekdays only (two Mon–Fri weeks) to keep the
// baseline weekend share driven by cinema alone.
for i in 0..10u32 {
let mut row = watch(&["Drama"], 4, friday, 21);
row.watch_medium = Some("streaming".to_string());
row.watched_at = date(2024, 3, 4 + i % 5 + 7 * (i / 5))
.and_hms_opt(21, 0, 0)
.unwrap();
rows.push(row);
}
let insights = build_insights(&rows, &Utc);
let cinema = insights.iter().find_map(|i| match i {
Insight::MediumTiming {
medium,
multiplier,
sample_count,
..
} if medium == "cinema" => Some((*multiplier, *sample_count)),
_ => None,
});
let (multiplier, count) = cinema.expect("cinema timing expected");
// Baseline weekend share = 5/16; cinema share = 5/6 → lift = (5/6)/(5/16) ≈ 2.67.
assert!(
(multiplier - 2.67).abs() < 0.01,
"multiplier was {multiplier}"
);
assert_eq!(count, 6);
}
#[test]
fn medium_timing_skipped_when_medium_mostly_missing() {
let mut rows: Vec<WatchRow> = Vec::new();
let first_saturday = date(2024, 3, 9);
for i in 0..6u32 {
let mut row = watch(
&["Drama"],
4,
first_saturday + chrono::Duration::weeks(i64::from(i)),
20,
);
row.watch_medium = Some("cinema".to_string());
rows.push(row);
}
for i in 0..10u32 {
let mut row = watch(&["Drama"], 4, date(2024, 3, 1 + i), 20);
row.watch_medium = None; // imports without a medium
rows.push(row);
}
assert!(!kinds(&build_insights(&rows, &Utc)).contains(&"medium_timing"));
}
// ── Rhythm ───────────────────────────────────────────────────────────────────
#[test]
fn rhythm_finds_peak_window_and_days() {
// 12 watches at 22:30: five Tuesdays, four Wednesdays, three Thursdays.
let first_tuesday = date(2024, 3, 5);
assert_eq!(first_tuesday.weekday(), Weekday::Tue);
let mut rows: Vec<WatchRow> = Vec::new();
for day in 0..3u32 {
let count = 5 - day; // Tue ×5, Wed ×4, Thu ×3
for week in 0..count {
rows.push(watch(
&["Drama"],
4,
first_tuesday
+ chrono::Duration::days(i64::from(day))
+ chrono::Duration::weeks(i64::from(week)),
22,
));
}
}
let insights = build_insights(&rows, &Utc);
let rhythm = insights.iter().find_map(|i| match i {
Insight::Rhythm {
peak_hour_start,
peak_hour_end,
top_days,
sample_count,
} => Some((
*peak_hour_start,
*peak_hour_end,
top_days.clone(),
*sample_count,
)),
_ => None,
});
let (start, end, days, count) = rhythm.expect("rhythm expected");
// All watches sit at hour 22; the earliest 3-hour window that covers
// them starts at 20 (20, 21, 22).
assert_eq!(start, 20);
assert_eq!(end, 23);
// Every day carries ≥60% of the busiest day (5): Tue(5) Wed(4) Thu(3).
assert_eq!(days, vec![2, 3, 4], "Tue=2, Wed=3, Thu=4");
assert_eq!(count, 12);
}
#[test]
fn rhythm_buckets_in_the_viewers_time_zone() {
// 12 watches stored at 22:30 UTC on Tue/Wed/Thu are 00:30 the next day
// for a viewer at UTC+2.
let first_tuesday = date(2024, 3, 5);
let mut rows: Vec<WatchRow> = Vec::new();
for day in 0..3u32 {
for week in 0..(5 - day) {
rows.push(watch(
&["Drama"],
4,
first_tuesday
+ chrono::Duration::days(i64::from(day))
+ chrono::Duration::weeks(i64::from(week)),
22,
));
}
}
let plus_two = FixedOffset::east_opt(2 * 3600).unwrap();
let insights = build_insights(&rows, &plus_two);
let (start, end, days) = insights
.iter()
.find_map(|i| match i {
Insight::Rhythm {
peak_hour_start,
peak_hour_end,
top_days,
..
} => Some((*peak_hour_start, *peak_hour_end, top_days.clone())),
_ => None,
})
.expect("rhythm expected");
// Local hour 0; the scan starts at 0, so the first window covering it is 0–3.
assert_eq!((start, end), (0, 3));
assert_eq!(days, vec![3, 4, 5], "Wed=3, Thu=4, Fri=5 locally");
}
#[test]
fn rhythm_import_noise_detected_before_zone_shift() {
// Date-only imports are stored at 00:00 UTC; at UTC+2 they read 02:00,
// but they are still noise.
let rows: Vec<WatchRow> = (0..12u32)
.map(|i| WatchRow {
movie_id: Uuid::new_v4(),
rating: 4,
watched_at: date(2024, 1, 1 + i).and_hms_opt(0, 0, 0).unwrap(),
watch_medium: None,
genres: vec!["Drama".to_string()],
})
.collect();
let plus_two = FixedOffset::east_opt(2 * 3600).unwrap();
assert!(!kinds(&build_insights(&rows, &plus_two)).contains(&"rhythm"));
}
#[test]
fn rhythm_skipped_when_timestamps_are_import_noise() {
let mut rows: Vec<WatchRow> = Vec::new();
for i in 0..12u32 {
// Exactly midnight — the signature of a CSV import with no time data.
let row = WatchRow {
movie_id: Uuid::new_v4(),
rating: 4,
watched_at: date(2024, 1, 1 + i).and_hms_opt(0, 0, 0).unwrap(),
watch_medium: None,
genres: vec!["Drama".to_string()],
};
rows.push(row);
}
assert!(!kinds(&build_insights(&rows, &Utc)).contains(&"rhythm"));
}
// ── Overall ──────────────────────────────────────────────────────────────────
#[test]
fn empty_diary_yields_nothing() {
assert!(build_insights(&[], &Utc).is_empty());
}
#[test]
fn small_diary_yields_nothing() {
let rows: Vec<WatchRow> = (0..4)
.map(|i| watch(&["Drama"], 4, date(2024, 1, 1 + i), 20))
.collect();
assert!(build_insights(&rows, &Utc).is_empty());
}
#[test]
fn max_three_insights_per_category() {
// Six genres, all with a strong rating lift — only the three strongest survive.
let mut rows: Vec<WatchRow> = Vec::new();
let genres = [
("Thriller", 5u8),
("Horror", 5),
("Sci-Fi", 5),
("Comedy", 5),
("Romance", 5),
("Documentary", 5),
];
for (genre, rating) in genres {
rows.extend((0..5).map(|i| watch(&[genre], rating, date(2024, 1, 1 + i), 20)));
}
// Baseline dramas rated 1 make every lift large.
rows.extend((0..10).map(|i| watch(&["Drama"], 1, date(2024, 3, 1 + i), 20)));
let insights = build_insights(&rows, &Utc);
let rating_lifts = insights
.iter()
.filter(|i| matches!(i, Insight::RatingLift { .. }))
.count();
assert_eq!(rating_lifts, 3);
}

View File

@@ -0,0 +1,69 @@
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
use domain::models::{Review, ReviewHistory};
use domain::services::review_history::{ReviewHistoryAnalyzer, Trend};
use domain::testing::test_movie_with_year;
use domain::value_objects::{MovieId, Rating, UserId};
fn dt(year: i32, month: u32, day: u32) -> NaiveDateTime {
NaiveDateTime::new(
NaiveDate::from_ymd_opt(year, month, day).unwrap(),
NaiveTime::from_hms_opt(12, 0, 0).unwrap(),
)
}
fn review_with_rating(movie_id: &MovieId, rating: u8, watched_at: NaiveDateTime) -> Review {
let user_id = UserId::generate();
Review::new(
movie_id.clone(),
user_id,
Rating::new(rating).unwrap(),
None,
watched_at,
None,
false,
)
.unwrap()
}
#[test]
fn neutral_when_empty() {
let movie = test_movie_with_year("Test", 2024);
let history = ReviewHistory::new(movie, vec![]);
let trend = ReviewHistoryAnalyzer::rating_trend(&history).unwrap();
assert_eq!(trend, Trend::Neutral);
}
#[test]
fn neutral_when_single_review() {
let movie = test_movie_with_year("Test", 2024);
let r = review_with_rating(movie.id(), 4, dt(2024, 1, 1));
let history = ReviewHistory::new(movie, vec![r]);
let trend = ReviewHistoryAnalyzer::rating_trend(&history).unwrap();
assert_eq!(trend, Trend::Neutral);
}
#[test]
fn improved_when_latest_above_average() {
let movie = test_movie_with_year("Test", 2024);
let viewings = vec![
review_with_rating(movie.id(), 2, dt(2024, 1, 1)),
review_with_rating(movie.id(), 3, dt(2024, 2, 1)),
review_with_rating(movie.id(), 5, dt(2024, 3, 1)),
];
let history = ReviewHistory::new(movie, viewings);
let trend = ReviewHistoryAnalyzer::rating_trend(&history).unwrap();
assert_eq!(trend, Trend::Improved);
}
#[test]
fn declined_when_latest_below_average() {
let movie = test_movie_with_year("Test", 2024);
let viewings = vec![
review_with_rating(movie.id(), 5, dt(2024, 1, 1)),
review_with_rating(movie.id(), 4, dt(2024, 2, 1)),
review_with_rating(movie.id(), 2, dt(2024, 3, 1)),
];
let history = ReviewHistory::new(movie, viewings);
let trend = ReviewHistoryAnalyzer::rating_trend(&history).unwrap();
assert_eq!(trend, Trend::Declined);
}

View File

@@ -0,0 +1,106 @@
use chrono::NaiveDate;
use uuid::Uuid;
use domain::models::WrapUpMovieRow;
use domain::models::wrapup::{DateRange, WrapUpScope};
use domain::services::wrapup_analyzer::build_report;
fn row(title: &str, rating: u8, ym: &str) -> WrapUpMovieRow {
WrapUpMovieRow {
movie_id: Uuid::new_v4(),
title: title.to_string(),
release_year: 2024,
director: Some("Dir".to_string()),
poster_path: None,
rating,
watched_at: chrono::NaiveDateTime::parse_from_str(
&format!("{ym}-01 12:00:00"),
"%Y-%m-%d %H:%M:%S",
)
.unwrap(),
user_id: Uuid::new_v4(),
runtime_minutes: Some(100),
budget_usd: None,
original_language: Some("en".to_string()),
watch_medium: None,
genres: vec!["Action".to_string()],
keywords: vec![],
cast_names: vec![],
cast_profile_paths: vec![],
}
}
fn range() -> DateRange {
DateRange::new(
NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
)
.unwrap()
}
#[test]
fn empty_rows_returns_zero_totals() {
let report = build_report(WrapUpScope::Global, range(), &[]);
assert_eq!(report.total_movies, 0);
assert!(report.avg_rating.is_none());
assert_eq!(report.rating_distribution, [0u32; 5]);
}
#[test]
fn total_movies_matches_row_count() {
let rows = vec![row("A", 5, "2024-01"), row("B", 3, "2024-02")];
let report = build_report(WrapUpScope::Global, range(), &rows);
assert_eq!(report.total_movies, 2);
}
#[test]
fn avg_rating_is_correct() {
let rows = vec![row("A", 4, "2024-01"), row("B", 2, "2024-01")];
let report = build_report(WrapUpScope::Global, range(), &rows);
let avg = report.avg_rating.unwrap();
assert!((avg - 3.0).abs() < f64::EPSILON);
}
#[test]
fn rating_distribution_counts_correctly() {
let rows = vec![
row("A", 5, "2024-01"),
row("B", 5, "2024-02"),
row("C", 3, "2024-03"),
];
let report = build_report(WrapUpScope::Global, range(), &rows);
assert_eq!(report.rating_distribution[4], 2);
assert_eq!(report.rating_distribution[2], 1);
}
#[test]
fn movies_per_month_sorted_chronologically() {
let rows = vec![
row("A", 3, "2024-03"),
row("B", 3, "2024-01"),
row("C", 3, "2024-02"),
];
let report = build_report(WrapUpScope::Global, range(), &rows);
let yms: Vec<&str> = report
.movies_per_month
.iter()
.map(|m| m.year_month.as_str())
.collect();
assert_eq!(yms, ["2024-01", "2024-02", "2024-03"]);
}
#[test]
fn global_scope_populates_global_fields() {
let rows = vec![row("A", 4, "2024-01")];
let report = build_report(WrapUpScope::Global, range(), &rows);
assert!(report.total_users_active.is_some());
}
#[test]
fn user_scope_leaves_global_fields_empty() {
let rows = vec![row("A", 4, "2024-01")];
let report = build_report(WrapUpScope::User(Uuid::new_v4()), range(), &rows);
assert!(report.total_users_active.is_none());
assert!(report.most_active_user.is_none());
}

View File

@@ -0,0 +1,2 @@
#[path = "testing/in_memory_store_test.rs"]
mod in_memory_store_test;

View File

@@ -0,0 +1,390 @@
use std::sync::Arc;
use chrono::Utc;
use uuid::Uuid;
use domain::errors::DomainError;
use domain::models::{
CastMember, CrewMember, Genre, Keyword, Movie, MovieProfile, ProfileField, RefreshSession,
UserSettings, WatchEvent, WatchEventSource, WatchlistEntry, WebhookToken,
};
use domain::ports::{
GoalCommand, MovieCommand, MovieProfileRepository, RefreshSessionRepository, ReviewRepository,
SocialCommand, UserProfileFieldsRepository, UserRepository, UserSettingsRepository,
WatchEventCommand, WatchEventQuery, WatchlistRepository, WebhookTokenRepository,
};
use domain::testing::{InMemoryStore, MOVIE_CASCADE_TABLES, USER_CASCADE_TABLES};
use domain::testing::{
test_goal, test_movie, test_movie_with_external_id, test_review, test_user, test_watched_at,
};
use domain::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, UserId) {
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, user.id().clone())
}
#[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, user_id) = seed_movie_with_dependents(&store, false).await;
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 = domain::models::User::new(
domain::value_objects::Email::new("other@example.com".into()).unwrap(),
domain::value_objects::Username::new("alice".into()).unwrap(),
domain::value_objects::PasswordHash::new("hashed".into()).unwrap(),
domain::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 = domain::models::User::new(
domain::value_objects::Email::new("alice@example.com".into()).unwrap(),
domain::value_objects::Username::new("alice2".into()).unwrap(),
domain::value_objects::PasswordHash::new("hashed".into()).unwrap(),
domain::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(_))));
}

View File

@@ -0,0 +1,2 @@
#[path = "value_objects/value_objects_test.rs"]
mod value_objects_test;

View File

@@ -0,0 +1,295 @@
use domain::value_objects::*;
#[test]
fn movie_id_generate_unique() {
let a = MovieId::generate();
let b = MovieId::generate();
assert_ne!(a, b);
}
#[test]
fn rating_valid_range() {
assert!(Rating::new(1).is_ok());
assert!(Rating::new(5).is_ok());
assert_eq!(Rating::new(3).unwrap().value(), 3);
}
#[test]
fn rating_invalid() {
assert!(Rating::new(0).is_err());
assert!(Rating::new(6).is_err());
assert!(Rating::new(255).is_err());
}
#[test]
fn movie_title_valid() {
let t = MovieTitle::new("Test".into());
assert!(t.is_ok());
assert_eq!(t.unwrap().value(), "Test");
}
#[test]
fn movie_title_empty_rejected() {
assert!(MovieTitle::new("".into()).is_err());
assert!(MovieTitle::new(" ".into()).is_err());
}
#[test]
fn release_year_valid() {
assert!(ReleaseYear::new(2024).is_ok());
assert_eq!(ReleaseYear::new(1888).unwrap().value(), 1888);
}
#[test]
fn release_year_too_early() {
assert!(ReleaseYear::new(1887).is_err());
}
#[test]
fn email_valid() {
let e = Email::new("a@b.com".into());
assert!(e.is_ok());
assert_eq!(e.unwrap().value(), "a@b.com");
}
#[test]
fn email_invalid() {
assert!(Email::new("invalid".into()).is_err());
assert!(Email::new("".into()).is_err());
}
#[test]
fn username_valid() {
let u = Username::new("test".into());
assert!(u.is_ok());
assert_eq!(u.unwrap().value(), "test");
}
#[test]
fn username_lowercases() {
assert_eq!(Username::new("Alice".into()).unwrap().value(), "alice");
}
#[test]
fn username_rejects_too_short() {
assert!(Username::new("a".into()).is_err());
}
#[test]
fn username_rejects_special_chars() {
assert!(Username::new("no spaces".into()).is_err());
assert!(Username::new("no@at".into()).is_err());
}
#[test]
fn poster_path_valid() {
let p = PosterPath::new("path/to/poster".into());
assert!(p.is_ok());
assert_eq!(p.unwrap().value(), "path/to/poster");
}
#[test]
fn poster_path_empty_rejected() {
assert!(PosterPath::new("".into()).is_err());
}
#[test]
fn comment_valid() {
let c = Comment::new("nice movie".into());
assert!(c.is_ok());
assert_eq!(c.unwrap().value(), "nice movie");
}
#[test]
fn comment_empty_is_ok() {
// empty comment allowed — only max-length checked
assert!(Comment::new("".into()).is_ok());
}
#[test]
fn external_metadata_id_valid() {
let e = ExternalMetadataId::new("tt1234567".into());
assert!(e.is_ok());
assert_eq!(e.unwrap().value(), "tt1234567");
}
#[test]
fn external_metadata_id_empty_rejected() {
assert!(ExternalMetadataId::new("".into()).is_err());
assert!(ExternalMetadataId::new(" ".into()).is_err());
}
#[test]
fn password_hash_valid() {
assert!(PasswordHash::new("hash".into()).is_ok());
}
#[test]
fn password_hash_empty_rejected() {
assert!(PasswordHash::new("".into()).is_err());
}
#[test]
fn poster_url_valid() {
let u = PosterUrl::new("https://img.com/poster.jpg".into());
assert!(u.is_ok());
assert_eq!(u.unwrap().value(), "https://img.com/poster.jpg");
}
#[test]
fn poster_url_empty_rejected() {
assert!(PosterUrl::new("".into()).is_err());
}
#[test]
fn password_min_length_enforced() {
assert!(Password::new("short".to_string()).is_err());
assert!(Password::new("1234567".to_string()).is_err()); // 7 chars
}
#[test]
fn password_valid_at_eight_chars() {
let p = Password::new("12345678".to_string());
assert!(p.is_ok());
assert_eq!(p.unwrap().value(), "12345678");
}
#[test]
fn password_value_preserves_content() {
let raw = "supersecret!".to_string();
assert_eq!(Password::new(raw.clone()).unwrap().value(), raw);
}
#[test]
fn watch_medium_parses_valid_strings() {
let cases = [
("cinema", WatchMedium::Cinema),
("streaming", WatchMedium::Streaming),
("tv", WatchMedium::TV),
("physical_media", WatchMedium::PhysicalMedia),
("download", WatchMedium::Download),
("media_server", WatchMedium::MediaServer),
("other", WatchMedium::Other),
];
for (input, expected) in cases {
let parsed: WatchMedium = input.parse().unwrap();
assert_eq!(parsed, expected);
}
}
#[test]
fn watch_medium_rejects_invalid() {
assert!("nonsense".parse::<WatchMedium>().is_err());
assert!("".parse::<WatchMedium>().is_err());
}
#[test]
fn watch_medium_display_round_trips() {
let variants = [
WatchMedium::Cinema,
WatchMedium::Streaming,
WatchMedium::TV,
WatchMedium::PhysicalMedia,
WatchMedium::Download,
WatchMedium::MediaServer,
WatchMedium::Other,
];
for v in variants {
let s = v.to_string();
let parsed: WatchMedium = s.parse().unwrap();
assert_eq!(parsed, v);
}
}
// ── InstanceIdentity ────────────────────────────────────────────────────────
fn instance() -> InstanceIdentity {
InstanceIdentity::new("https://md.example")
}
#[test]
fn instance_normalizes_trailing_slash() {
assert_eq!(
InstanceIdentity::new("https://md.example/").base_url(),
"https://md.example"
);
assert_eq!(
InstanceIdentity::new("https://md.example").base_url(),
"https://md.example"
);
}
#[test]
fn instance_host_strips_scheme_and_path() {
assert_eq!(instance().host(), "md.example");
assert_eq!(
InstanceIdentity::new("http://localhost:3000").host(),
"localhost:3000"
);
}
#[test]
fn instance_builds_actor_url_for_local_user() {
let uid = UserId::from_uuid(uuid::Uuid::nil());
assert_eq!(
instance().actor_url_for(&uid),
format!("https://md.example/users/{}", uuid::Uuid::nil())
);
}
#[test]
fn instance_builds_handle_and_image_url() {
assert_eq!(instance().handle_for("gabriel"), "@gabriel@md.example");
assert_eq!(
instance().image_url_for("avatars/a.webp"),
"https://md.example/images/avatars/a.webp"
);
}
#[test]
fn instance_identifies_own_actor_url_as_local() {
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
let url = instance().actor_url_for(&uid);
assert_eq!(instance().identify(&url), SocialIdentity::Local(uid));
}
#[test]
fn instance_identifies_foreign_actor_url_as_remote() {
let url = "https://other.example/users/bob";
assert_eq!(
instance().identify(url),
SocialIdentity::Remote {
actor_url: url.to_string()
}
);
}
#[test]
fn instance_identifies_own_host_with_bad_uuid_as_remote() {
let url = "https://md.example/users/not-a-uuid";
assert_eq!(
instance().identify(url),
SocialIdentity::Remote {
actor_url: url.to_string()
}
);
}
#[test]
fn instance_actor_url_round_trips_both_variants() {
let i = instance();
let local = SocialIdentity::Local(UserId::from_uuid(uuid::Uuid::new_v4()));
let remote = SocialIdentity::Remote {
actor_url: "https://other.example/users/bob".into(),
};
assert_eq!(i.identify(&i.actor_url_of(&local)), local);
assert_eq!(i.identify(&i.actor_url_of(&remote)), remote);
}
#[test]
fn instance_rejects_prefix_collision() {
// A different instance whose base_url merely starts the same must not read as local.
let i = InstanceIdentity::new("https://md.example");
let url = "https://md.example.evil.test/users/00000000-0000-0000-0000-000000000000";
assert!(matches!(i.identify(url), SocialIdentity::Remote { .. }));
}