add 400+ unit tests for domain and application layers
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:
2026-06-09 02:07:26 +02:00
parent 30a6200b5b
commit d867a14b28
122 changed files with 7033 additions and 151 deletions

View File

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

View File

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