add 400+ unit tests for domain and application layers
Some checks failed
CI / Check / Test (push) Has been cancelled
Some checks failed
CI / Check / Test (push) Has been cancelled
Extract ReviewLogger trait to decouple import/integrations from diary::log_review (cross-module coupling smell). Add in-memory fakes for all repository ports, enabling isolated testing of every use case module without a database. Coverage: domain+application 22% → 80%, 427 tests.
This commit is contained in:
@@ -109,3 +109,98 @@ impl GoalWithProgress {
|
||||
self.current_count >= self.goal.target_count
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::value_objects::UserId;
|
||||
|
||||
fn make_goal(year: u16, target: u32) -> Result<Goal, DomainError> {
|
||||
Goal::new(UserId::generate(), year, target, GoalType::Movies)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_goal_valid() {
|
||||
let g = make_goal(2024, 52);
|
||||
assert!(g.is_ok());
|
||||
let g = g.unwrap();
|
||||
assert_eq!(g.year(), 2024);
|
||||
assert_eq!(g.target_count(), 52);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_goal_rejects_year_before_2020() {
|
||||
assert!(make_goal(2019, 10).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_goal_rejects_zero_target() {
|
||||
assert!(make_goal(2024, 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_target_valid() {
|
||||
let mut g = make_goal(2024, 10).unwrap();
|
||||
assert!(g.update_target(50).is_ok());
|
||||
assert_eq!(g.target_count(), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_target_rejects_zero() {
|
||||
let mut g = make_goal(2024, 10).unwrap();
|
||||
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 = make_goal(2024, 100).unwrap();
|
||||
let wp = GoalWithProgress {
|
||||
goal: g,
|
||||
current_count: 50,
|
||||
};
|
||||
assert!((wp.percentage() - 50.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percentage_caps_at_100() {
|
||||
let g = make_goal(2024, 10).unwrap();
|
||||
let wp = GoalWithProgress {
|
||||
goal: g,
|
||||
current_count: 20,
|
||||
};
|
||||
assert!((wp.percentage() - 100.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_complete() {
|
||||
let g = make_goal(2024, 10).unwrap();
|
||||
let wp = GoalWithProgress {
|
||||
goal: g,
|
||||
current_count: 10,
|
||||
};
|
||||
assert!(wp.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_not_complete() {
|
||||
let g = make_goal(2024, 10).unwrap();
|
||||
let wp = GoalWithProgress {
|
||||
goal: g,
|
||||
current_count: 9,
|
||||
};
|
||||
assert!(!wp.is_complete());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,3 +111,62 @@ pub struct CrewCredit {
|
||||
pub department: String,
|
||||
pub poster_path: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn person_new() {
|
||||
let ext = ExternalPersonId::new("tmdb:12345");
|
||||
let pid = PersonId::from_external(&ext);
|
||||
let p = Person::new(
|
||||
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::new(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,3 +36,197 @@ fn update_profile_clears_with_none() {
|
||||
assert_eq!(user.bio(), None);
|
||||
assert_eq!(user.avatar_path(), None);
|
||||
}
|
||||
|
||||
// ── Movie ────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn make_movie() -> Movie {
|
||||
Movie::new(
|
||||
Some(crate::value_objects::ExternalMetadataId::new("tt1234567".into()).unwrap()),
|
||||
crate::value_objects::MovieTitle::new("Blade Runner".into()).unwrap(),
|
||||
crate::value_objects::ReleaseYear::new(1982).unwrap(),
|
||||
Some("Ridley Scott".into()),
|
||||
Some(crate::value_objects::PosterPath::new("/poster.jpg".into()).unwrap()),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movie_new_sets_fields() {
|
||||
let m = make_movie();
|
||||
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 = make_movie();
|
||||
let new_poster = crate::value_objects::PosterPath::new("/new.jpg".into()).unwrap();
|
||||
m.update_poster(new_poster);
|
||||
assert_eq!(m.poster_path().unwrap().value(), "/new.jpg");
|
||||
}
|
||||
|
||||
// ── Review ───────────────────────────────────────────────────────────────────
|
||||
|
||||
use crate::value_objects::{Comment, MovieId, Rating, ReviewId};
|
||||
|
||||
fn make_review() -> Review {
|
||||
Review::new(
|
||||
MovieId::generate(),
|
||||
UserId::generate(),
|
||||
Rating::new(4).unwrap(),
|
||||
Some(Comment::new("great".into()).unwrap()),
|
||||
chrono::Utc::now().naive_utc(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_new_sets_local_source() {
|
||||
let r = make_review();
|
||||
assert_eq!(*r.source(), ReviewSource::Local);
|
||||
assert!(!r.is_remote());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_stars() {
|
||||
let r = make_review(); // rating=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(),
|
||||
},
|
||||
});
|
||||
assert_eq!(*r.id(), id);
|
||||
assert!(r.is_remote());
|
||||
assert_eq!(r.comment(), None);
|
||||
}
|
||||
|
||||
// ── 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 = make_user();
|
||||
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(make_movie(), make_review());
|
||||
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 = make_movie();
|
||||
let title = crate::value_objects::MovieTitle::new("Blade Runner".into()).unwrap();
|
||||
let year = crate::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 = make_movie();
|
||||
let title = crate::value_objects::MovieTitle::new("Blade Runner".into()).unwrap();
|
||||
let year = crate::value_objects::ReleaseYear::new(1982).unwrap();
|
||||
assert!(!m.is_manual_match(&title, &year, Some("Denis Villeneuve")));
|
||||
}
|
||||
|
||||
@@ -234,3 +234,143 @@ pub struct ParsedPlaybackEvent {
|
||||
pub tmdb_id: Option<String>,
|
||||
pub imdb_id: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,3 +51,83 @@ impl ReviewHistoryAnalyzer {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::{Movie, Review, ReviewHistory};
|
||||
use crate::value_objects::{MovieId, MovieTitle, Rating, ReleaseYear, UserId};
|
||||
use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
|
||||
|
||||
fn make_movie() -> Movie {
|
||||
Movie::new(
|
||||
None,
|
||||
MovieTitle::new("Test".into()).unwrap(),
|
||||
ReleaseYear::new(2024).unwrap(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_when_empty() {
|
||||
let movie = make_movie();
|
||||
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 = make_movie();
|
||||
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 = make_movie();
|
||||
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 = make_movie();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,16 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
DiaryEntry, DiaryFilter, FeedEntry, Movie, MovieStats, Review, ReviewHistory,
|
||||
AnnotatedRow, DiaryEntry, DiaryFilter, ExternalPersonId, FeedEntry, FieldMapping,
|
||||
FileFormat, ImportError, ImportRow, Movie, MovieProfile, MovieStats, ParsedFile, Person,
|
||||
PersonCredits, PersonId, Review, ReviewHistory, RowResult, SearchQuery, SearchResults,
|
||||
UserStats, UserTrends,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::{
|
||||
AuthService, DiaryRepository, FeedSortBy, FollowingFilter, GeneratedToken, MetadataClient,
|
||||
MetadataSearchCriteria, PasswordHasher,
|
||||
AuthService, DiaryRepository, DocumentParser, FeedSortBy, FollowingFilter, GeneratedToken,
|
||||
MetadataClient, MetadataSearchCriteria, MovieEnrichmentClient, PasswordHasher, PersonQuery,
|
||||
PosterFetcherClient, SearchCommand, SearchPort, StatsRepository,
|
||||
},
|
||||
value_objects::{ExternalMetadataId, MovieId, PasswordHash, PosterUrl, UserId},
|
||||
};
|
||||
@@ -103,14 +107,24 @@ impl DiaryRepository for FakeDiaryRepository {
|
||||
&self,
|
||||
_filter: &DiaryFilter,
|
||||
) -> Result<Paginated<DiaryEntry>, DomainError> {
|
||||
unimplemented!("FakeDiaryRepository::query_diary")
|
||||
Ok(Paginated {
|
||||
items: vec![],
|
||||
total_count: 0,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn query_activity_feed(
|
||||
&self,
|
||||
_page: &PageParams,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
unimplemented!("FakeDiaryRepository::query_activity_feed")
|
||||
Ok(Paginated {
|
||||
items: vec![],
|
||||
total_count: 0,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn query_activity_feed_filtered(
|
||||
@@ -120,7 +134,12 @@ impl DiaryRepository for FakeDiaryRepository {
|
||||
_search: Option<&str>,
|
||||
_following: Option<&FollowingFilter>,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
unimplemented!("FakeDiaryRepository::query_activity_feed_filtered")
|
||||
Ok(Paginated {
|
||||
items: vec![],
|
||||
total_count: 0,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError> {
|
||||
@@ -132,11 +151,16 @@ impl DiaryRepository for FakeDiaryRepository {
|
||||
}
|
||||
|
||||
async fn get_user_history(&self, _user_id: &UserId) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||
unimplemented!("FakeDiaryRepository::get_user_history")
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn get_movie_stats(&self, _movie_id: &MovieId) -> Result<MovieStats, DomainError> {
|
||||
unimplemented!("FakeDiaryRepository::get_movie_stats")
|
||||
Ok(MovieStats {
|
||||
total_count: 0,
|
||||
avg_rating: None,
|
||||
federated_count: 0,
|
||||
rating_histogram: [0; 5],
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_movie_social_feed(
|
||||
@@ -144,10 +168,186 @@ impl DiaryRepository for FakeDiaryRepository {
|
||||
_movie_id: &MovieId,
|
||||
_page: &PageParams,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
unimplemented!("FakeDiaryRepository::get_movie_social_feed")
|
||||
Ok(Paginated {
|
||||
items: vec![],
|
||||
total_count: 0,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> Result<u64, DomainError> {
|
||||
unimplemented!("FakeDiaryRepository::count_local_posts")
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
// ── FakeStatsRepository ─────────────────────────────────────────────────────
|
||||
|
||||
pub struct FakeStatsRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl StatsRepository for FakeStatsRepository {
|
||||
async fn get_user_stats(&self, _: &UserId) -> Result<UserStats, DomainError> {
|
||||
Ok(UserStats {
|
||||
total_movies: 0,
|
||||
avg_rating: None,
|
||||
favorite_director: None,
|
||||
most_active_month: None,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_user_trends(&self, _: &UserId) -> Result<UserTrends, DomainError> {
|
||||
Ok(UserTrends {
|
||||
monthly_ratings: vec![],
|
||||
top_directors: vec![],
|
||||
max_director_count: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── FakePersonQuery ─────────────────────────────────────────────────────────
|
||||
|
||||
pub struct FakePersonQuery;
|
||||
|
||||
#[async_trait]
|
||||
impl PersonQuery for FakePersonQuery {
|
||||
async fn get_by_id(&self, _: &PersonId) -> Result<Option<Person>, DomainError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn get_by_external_id(
|
||||
&self,
|
||||
_: &ExternalPersonId,
|
||||
) -> Result<Option<Person>, DomainError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn get_credits(&self, id: &PersonId) -> Result<PersonCredits, DomainError> {
|
||||
let dummy = Person::new(
|
||||
id.clone(),
|
||||
ExternalPersonId::new("tmdb:0"),
|
||||
"Unknown".into(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
Ok(PersonCredits {
|
||||
person: dummy,
|
||||
cast: vec![],
|
||||
crew: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn list_page(&self, _: u32, _: u32) -> Result<Vec<Person>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
// ── FakeSearchPort ──────────────────────────────────────────────────────────
|
||||
|
||||
pub struct FakeSearchPort;
|
||||
|
||||
#[async_trait]
|
||||
impl SearchPort for FakeSearchPort {
|
||||
async fn search(&self, _: &SearchQuery) -> Result<SearchResults, DomainError> {
|
||||
Ok(SearchResults {
|
||||
movies: Paginated {
|
||||
items: vec![],
|
||||
total_count: 0,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
people: Paginated {
|
||||
items: vec![],
|
||||
total_count: 0,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── FakeSearchCommand ───────────────────────────────────────────────────────
|
||||
|
||||
pub struct FakeSearchCommand;
|
||||
|
||||
#[async_trait]
|
||||
impl SearchCommand for FakeSearchCommand {
|
||||
async fn index(&self, _: crate::models::IndexableDocument) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove(&self, _: crate::models::EntityType, _: &str) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── FakeDocumentParser ──────────────────────────────────────────────────────
|
||||
|
||||
pub struct FakeDocumentParser;
|
||||
|
||||
impl DocumentParser for FakeDocumentParser {
|
||||
fn parse(&self, _: &[u8], _: FileFormat) -> Result<ParsedFile, ImportError> {
|
||||
Ok(ParsedFile {
|
||||
columns: vec!["title".into()],
|
||||
rows: vec![vec!["Test Movie".into()]],
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_mapping(&self, _: &ParsedFile, _: &[FieldMapping]) -> Vec<AnnotatedRow> {
|
||||
vec![AnnotatedRow {
|
||||
result: RowResult::Valid(ImportRow {
|
||||
title: Some("Test Movie".into()),
|
||||
..ImportRow::default()
|
||||
}),
|
||||
is_duplicate: false,
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
// ── FakePosterFetcher ───────────────────────────────────────────────────────
|
||||
|
||||
pub struct FakePosterFetcher;
|
||||
|
||||
#[async_trait]
|
||||
impl PosterFetcherClient for FakePosterFetcher {
|
||||
async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result<Vec<u8>, DomainError> {
|
||||
Ok(vec![1, 2, 3])
|
||||
}
|
||||
}
|
||||
|
||||
// ── FakeMovieEnrichmentClient ───────────────────────────────────────────────
|
||||
|
||||
pub struct FakeMovieEnrichmentClient;
|
||||
|
||||
#[async_trait]
|
||||
impl MovieEnrichmentClient for FakeMovieEnrichmentClient {
|
||||
async fn fetch_profile(
|
||||
&self,
|
||||
movie_id: MovieId,
|
||||
_external_metadata_id: &str,
|
||||
) -> Result<MovieProfile, DomainError> {
|
||||
Ok(MovieProfile {
|
||||
movie_id,
|
||||
tmdb_id: 0,
|
||||
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![],
|
||||
keywords: vec![],
|
||||
cast: vec![],
|
||||
crew: vec![],
|
||||
enriched_at: Utc::now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,25 @@ use std::sync::{Arc, Mutex};
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
use crate::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{
|
||||
Movie, MovieFilter, MovieSummary, Review, User, UserSummary, WatchlistEntry,
|
||||
WatchlistWithMovie,
|
||||
Goal, ImportProfile, ImportSession, Movie, MovieFilter, MovieProfile, MovieSummary,
|
||||
ProfileField, Review, User, UserSettings, UserSummary, WatchEvent, WatchEventStatus,
|
||||
WatchlistEntry, WatchlistWithMovie, WebhookToken,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::{MovieRepository, ReviewRepository, UserRepository, WatchlistRepository},
|
||||
ports::{
|
||||
GoalRepository, ImportProfileRepository, ImportSessionRepository, MovieProfileRepository,
|
||||
MovieRepository, ReviewRepository, UserProfileFieldsRepository, UserRepository,
|
||||
UserSettingsRepository, WatchEventRepository, WatchlistRepository, WebhookTokenRepository,
|
||||
},
|
||||
value_objects::{
|
||||
Email, ExternalMetadataId, MovieId, MovieTitle, ReleaseYear, ReviewId, UserId, Username,
|
||||
Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
|
||||
ReleaseYear, ReviewId, UserId, Username, WatchEventId, WebhookTokenId,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -310,3 +318,462 @@ impl WatchlistRepository for InMemoryWatchlistRepository {
|
||||
Ok(self.store.lock().unwrap().contains_key(&key))
|
||||
}
|
||||
}
|
||||
|
||||
// ── InMemoryGoalRepository ──────────────────────────────────────────────────
|
||||
|
||||
pub struct InMemoryGoalRepository {
|
||||
store: Mutex<HashMap<Uuid, Goal>>,
|
||||
review_counts: Mutex<HashMap<(Uuid, u16), u32>>,
|
||||
}
|
||||
|
||||
impl InMemoryGoalRepository {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
store: Mutex::new(HashMap::new()),
|
||||
review_counts: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn count(&self) -> usize {
|
||||
self.store.lock().unwrap().len()
|
||||
}
|
||||
|
||||
pub fn set_review_count(&self, user_id: Uuid, year: u16, count: u32) {
|
||||
self.review_counts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert((user_id, year), count);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GoalRepository for InMemoryGoalRepository {
|
||||
async fn save(&self, goal: &Goal) -> Result<(), DomainError> {
|
||||
self.store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(goal.id().value(), goal.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update(&self, goal: &Goal) -> Result<(), DomainError> {
|
||||
let mut store = self.store.lock().unwrap();
|
||||
match store.entry(goal.id().value()) {
|
||||
std::collections::hash_map::Entry::Occupied(mut e) => {
|
||||
e.insert(goal.clone());
|
||||
Ok(())
|
||||
}
|
||||
std::collections::hash_map::Entry::Vacant(_) => {
|
||||
Err(DomainError::NotFound("goal".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &GoalId, _user_id: &UserId) -> Result<(), DomainError> {
|
||||
self.store.lock().unwrap().remove(&id.value());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_by_user_and_year(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
year: u16,
|
||||
) -> Result<Option<Goal>, DomainError> {
|
||||
let store = self.store.lock().unwrap();
|
||||
Ok(store
|
||||
.values()
|
||||
.find(|g| g.user_id().value() == user_id.value() && g.year() == year)
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError> {
|
||||
let store = self.store.lock().unwrap();
|
||||
Ok(store
|
||||
.values()
|
||||
.filter(|g| g.user_id().value() == user_id.value())
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
|
||||
let counts = self.review_counts.lock().unwrap();
|
||||
Ok(counts.get(&(user_id.value(), year)).copied().unwrap_or(0))
|
||||
}
|
||||
}
|
||||
|
||||
// ── InMemoryUserSettingsRepository ──────────────────────────────────────────
|
||||
|
||||
pub struct InMemoryUserSettingsRepository {
|
||||
store: Mutex<HashMap<Uuid, UserSettings>>,
|
||||
}
|
||||
|
||||
impl InMemoryUserSettingsRepository {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
store: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn count(&self) -> usize {
|
||||
self.store.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserSettingsRepository for InMemoryUserSettingsRepository {
|
||||
async fn get(&self, user_id: &UserId) -> Result<UserSettings, DomainError> {
|
||||
let store = self.store.lock().unwrap();
|
||||
Ok(store
|
||||
.get(&user_id.value())
|
||||
.cloned()
|
||||
.unwrap_or_else(|| UserSettings::new(user_id.clone())))
|
||||
}
|
||||
|
||||
async fn save(&self, settings: &UserSettings) -> Result<(), DomainError> {
|
||||
self.store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(settings.user_id().value(), settings.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── InMemoryWebhookTokenRepository ──────────────────────────────────────────
|
||||
|
||||
pub struct InMemoryWebhookTokenRepository {
|
||||
store: Mutex<Vec<WebhookToken>>,
|
||||
}
|
||||
|
||||
impl InMemoryWebhookTokenRepository {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
store: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn count(&self) -> usize {
|
||||
self.store.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WebhookTokenRepository for InMemoryWebhookTokenRepository {
|
||||
async fn save(&self, token: &WebhookToken) -> Result<(), DomainError> {
|
||||
self.store.lock().unwrap().push(token.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_by_token_hash(&self, hash: &str) -> Result<Option<WebhookToken>, DomainError> {
|
||||
let store = self.store.lock().unwrap();
|
||||
Ok(store.iter().find(|t| t.token_hash() == hash).cloned())
|
||||
}
|
||||
|
||||
async fn list_by_user(&self, user_id: &UserId) -> Result<Vec<WebhookToken>, DomainError> {
|
||||
let store = self.store.lock().unwrap();
|
||||
Ok(store
|
||||
.iter()
|
||||
.filter(|t| t.user_id().value() == user_id.value())
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &WebhookTokenId, _user_id: &UserId) -> Result<(), DomainError> {
|
||||
self.store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|t| t.id().value() != id.value());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn touch_last_used(&self, _id: &WebhookTokenId) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── InMemoryWatchEventRepository ────────────────────────────────────────────
|
||||
|
||||
pub struct InMemoryWatchEventRepository {
|
||||
store: Mutex<Vec<WatchEvent>>,
|
||||
}
|
||||
|
||||
impl InMemoryWatchEventRepository {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
store: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn count(&self) -> usize {
|
||||
self.store.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WatchEventRepository for InMemoryWatchEventRepository {
|
||||
async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> {
|
||||
self.store.lock().unwrap().push(event.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_status(
|
||||
&self,
|
||||
_id: &WatchEventId,
|
||||
_status: WatchEventStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_pending(&self, user_id: &UserId) -> Result<Vec<WatchEvent>, DomainError> {
|
||||
let store = self.store.lock().unwrap();
|
||||
Ok(store
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
e.user_id().value() == user_id.value() && *e.status() == WatchEventStatus::Pending
|
||||
})
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_by_id(&self, id: &WatchEventId) -> Result<Option<WatchEvent>, DomainError> {
|
||||
let store = self.store.lock().unwrap();
|
||||
Ok(store.iter().find(|e| e.id().value() == id.value()).cloned())
|
||||
}
|
||||
|
||||
async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result<Vec<WatchEvent>, DomainError> {
|
||||
let id_vals: Vec<Uuid> = ids.iter().map(|id| id.value()).collect();
|
||||
let store = self.store.lock().unwrap();
|
||||
Ok(store
|
||||
.iter()
|
||||
.filter(|e| id_vals.contains(&e.id().value()))
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn update_status_batch(
|
||||
&self,
|
||||
ids: &[WatchEventId],
|
||||
_status: WatchEventStatus,
|
||||
) -> Result<u64, DomainError> {
|
||||
Ok(ids.len() as u64)
|
||||
}
|
||||
|
||||
async fn find_duplicate(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
external_id: &str,
|
||||
after: NaiveDateTime,
|
||||
) -> Result<bool, DomainError> {
|
||||
let store = self.store.lock().unwrap();
|
||||
Ok(store.iter().any(|e| {
|
||||
e.user_id().value() == user_id.value()
|
||||
&& e.external_metadata_id() == Some(external_id)
|
||||
&& *e.watched_at() > after
|
||||
}))
|
||||
}
|
||||
|
||||
async fn delete_non_pending_older_than(
|
||||
&self,
|
||||
before: NaiveDateTime,
|
||||
) -> Result<u64, DomainError> {
|
||||
let mut store = self.store.lock().unwrap();
|
||||
let before_len = store.len();
|
||||
store.retain(|e| *e.status() == WatchEventStatus::Pending || *e.created_at() >= before);
|
||||
Ok((before_len - store.len()) as u64)
|
||||
}
|
||||
}
|
||||
|
||||
// ── InMemoryImportSessionRepository ─────────────────────────────────────────
|
||||
|
||||
pub struct InMemoryImportSessionRepository {
|
||||
store: Mutex<HashMap<Uuid, ImportSession>>,
|
||||
}
|
||||
|
||||
impl InMemoryImportSessionRepository {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
store: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn count(&self) -> usize {
|
||||
self.store.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ImportSessionRepository for InMemoryImportSessionRepository {
|
||||
async fn create(&self, session: &ImportSession) -> Result<(), DomainError> {
|
||||
self.store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(session.id.value(), session.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
id: &ImportSessionId,
|
||||
user_id: &UserId,
|
||||
) -> Result<Option<ImportSession>, DomainError> {
|
||||
let store = self.store.lock().unwrap();
|
||||
Ok(store
|
||||
.get(&id.value())
|
||||
.filter(|s| s.user_id.value() == user_id.value())
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn update(&self, session: &ImportSession) -> Result<(), DomainError> {
|
||||
self.store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(session.id.value(), session.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> {
|
||||
self.store.lock().unwrap().remove(&id.value());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
let mut store = self.store.lock().unwrap();
|
||||
let now = chrono::Utc::now().naive_utc();
|
||||
let before_len = store.len();
|
||||
store.retain(|_, s| s.expires_at > now);
|
||||
Ok((before_len - store.len()) as u64)
|
||||
}
|
||||
|
||||
async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError> {
|
||||
let mut store = self.store.lock().unwrap();
|
||||
let now = chrono::Utc::now().naive_utc();
|
||||
store.retain(|_, s| !(s.user_id.value() == user_id.value() && s.expires_at <= now));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── InMemoryImportProfileRepository ─────────────────────────────────────────
|
||||
|
||||
pub struct InMemoryImportProfileRepository {
|
||||
store: Mutex<HashMap<Uuid, ImportProfile>>,
|
||||
}
|
||||
|
||||
impl InMemoryImportProfileRepository {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
store: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn count(&self) -> usize {
|
||||
self.store.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ImportProfileRepository for InMemoryImportProfileRepository {
|
||||
async fn save(&self, profile: &ImportProfile) -> Result<(), DomainError> {
|
||||
self.store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(profile.id.value(), profile.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ImportProfile>, DomainError> {
|
||||
let store = self.store.lock().unwrap();
|
||||
Ok(store
|
||||
.values()
|
||||
.filter(|p| p.user_id.value() == user_id.value())
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
id: &ImportProfileId,
|
||||
user_id: &UserId,
|
||||
) -> Result<Option<ImportProfile>, DomainError> {
|
||||
let store = self.store.lock().unwrap();
|
||||
Ok(store
|
||||
.get(&id.value())
|
||||
.filter(|p| p.user_id.value() == user_id.value())
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> {
|
||||
self.store.lock().unwrap().remove(&id.value());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ── InMemoryMovieProfileRepository ──────────────────────────────────────────
|
||||
|
||||
pub struct InMemoryMovieProfileRepository {
|
||||
store: Mutex<HashMap<Uuid, MovieProfile>>,
|
||||
}
|
||||
|
||||
impl InMemoryMovieProfileRepository {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
store: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn count(&self) -> usize {
|
||||
self.store.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MovieProfileRepository for InMemoryMovieProfileRepository {
|
||||
async fn upsert(&self, profile: &MovieProfile) -> Result<(), DomainError> {
|
||||
self.store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(profile.movie_id.value(), profile.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_movie_id(&self, id: &MovieId) -> Result<Option<MovieProfile>, DomainError> {
|
||||
Ok(self.store.lock().unwrap().get(&id.value()).cloned())
|
||||
}
|
||||
|
||||
async fn list_stale(&self) -> Result<Vec<(MovieId, String)>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
// ── InMemoryProfileFieldsRepo ───────────────────────────────────────────────
|
||||
|
||||
pub struct InMemoryProfileFieldsRepo {
|
||||
store: Mutex<HashMap<Uuid, Vec<ProfileField>>>,
|
||||
}
|
||||
|
||||
impl InMemoryProfileFieldsRepo {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
store: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn count(&self) -> usize {
|
||||
self.store.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserProfileFieldsRepository for InMemoryProfileFieldsRepo {
|
||||
async fn get_fields(&self, user_id: &UserId) -> Result<Vec<ProfileField>, DomainError> {
|
||||
let store = self.store.lock().unwrap();
|
||||
Ok(store.get(&user_id.value()).cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn set_fields(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
fields: Vec<ProfileField>,
|
||||
) -> Result<(), DomainError> {
|
||||
self.store.lock().unwrap().insert(user_id.value(), fields);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,3 +251,148 @@ impl PosterUrl {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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(0).is_ok());
|
||||
assert!(Rating::new(5).is_ok());
|
||||
assert_eq!(Rating::new(3).unwrap().value(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rating_invalid() {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user