refactor: split monolithic handlers + testing into domain-grouped modules
Some checks failed
CI / Check / Test (push) Has been cancelled

handlers/api.rs (1706 LOC) + html.rs (1735 LOC) → 12 domain files:
auth, diary, movies, users, search, watchlist, goals, social,
integrations, helpers + existing import/webhook/wrapup/images/rss.

domain/testing.rs (1309 LOC) → testing/ module:
in_memory, fakes, noops, panics, wrapup.

Update README + architecture.mmd with goals feature.
This commit is contained in:
2026-06-08 23:59:23 +02:00
parent 988e15eac6
commit a7a11dde08
33 changed files with 5066 additions and 4891 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,153 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use chrono::Utc;
use uuid::Uuid;
use crate::{
errors::DomainError,
models::{
DiaryEntry, DiaryFilter, FeedEntry, Movie, MovieStats, Review, ReviewHistory,
collections::{PageParams, Paginated},
},
ports::{
AuthService, DiaryRepository, FeedSortBy, FollowingFilter, GeneratedToken, MetadataClient,
MetadataSearchCriteria, PasswordHasher,
},
value_objects::{ExternalMetadataId, MovieId, PasswordHash, PosterUrl, UserId},
};
// ── FakeAuthService ───────────────────────────────────────────────────────────
pub struct FakeAuthService;
#[async_trait]
impl AuthService for FakeAuthService {
async fn generate_token(&self, user_id: &UserId) -> Result<GeneratedToken, DomainError> {
Ok(GeneratedToken {
token: user_id.value().to_string(),
expires_at: Utc::now() + chrono::Duration::hours(24),
})
}
async fn validate_token(&self, token: &str) -> Result<UserId, DomainError> {
Uuid::parse_str(token)
.map(UserId::from_uuid)
.map_err(|_| DomainError::Unauthorized("invalid token".into()))
}
}
// ── FakePasswordHasher ────────────────────────────────────────────────────────
pub struct FakePasswordHasher;
#[async_trait]
impl PasswordHasher for FakePasswordHasher {
async fn hash(&self, plain_password: &str) -> Result<PasswordHash, DomainError> {
PasswordHash::new(format!("hashed:{plain_password}"))
}
async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result<bool, DomainError> {
Ok(hash.value() == format!("hashed:{plain_password}"))
}
}
// ── FakeMetadataClient ────────────────────────────────────────────────────────
pub struct FakeMetadataClient;
#[async_trait]
impl MetadataClient for FakeMetadataClient {
async fn fetch_movie_metadata(
&self,
_criteria: &MetadataSearchCriteria,
) -> Result<Movie, DomainError> {
Err(DomainError::InfrastructureError(
"fake metadata client".into(),
))
}
async fn get_poster_url(
&self,
_external_metadata_id: &ExternalMetadataId,
) -> Result<Option<PosterUrl>, DomainError> {
Ok(None)
}
}
// ── FakeDiaryRepository ───────────────────────────────────────────────────────
pub struct FakeDiaryRepository {
histories: Mutex<HashMap<Uuid, (Movie, Vec<Review>)>>,
}
impl FakeDiaryRepository {
pub fn new() -> Arc<Self> {
Arc::new(Self {
histories: Mutex::new(HashMap::new()),
})
}
pub fn seed_history(&self, movie: Movie, reviews: Vec<Review>) {
self.histories
.lock()
.unwrap()
.insert(movie.id().value(), (movie, reviews));
}
}
#[async_trait]
impl DiaryRepository for FakeDiaryRepository {
async fn query_diary(
&self,
_filter: &DiaryFilter,
) -> Result<Paginated<DiaryEntry>, DomainError> {
unimplemented!("FakeDiaryRepository::query_diary")
}
async fn query_activity_feed(
&self,
_page: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
unimplemented!("FakeDiaryRepository::query_activity_feed")
}
async fn query_activity_feed_filtered(
&self,
_page: &PageParams,
_sort_by: &FeedSortBy,
_search: Option<&str>,
_following: Option<&FollowingFilter>,
) -> Result<Paginated<FeedEntry>, DomainError> {
unimplemented!("FakeDiaryRepository::query_activity_feed_filtered")
}
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError> {
let histories = self.histories.lock().unwrap();
let (movie, reviews) = histories
.get(&movie_id.value())
.ok_or_else(|| DomainError::NotFound(format!("movie {}", movie_id.value())))?;
Ok(ReviewHistory::new(movie.clone(), reviews.clone()))
}
async fn get_user_history(&self, _user_id: &UserId) -> Result<Vec<DiaryEntry>, DomainError> {
unimplemented!("FakeDiaryRepository::get_user_history")
}
async fn get_movie_stats(&self, _movie_id: &MovieId) -> Result<MovieStats, DomainError> {
unimplemented!("FakeDiaryRepository::get_movie_stats")
}
async fn get_movie_social_feed(
&self,
_movie_id: &MovieId,
_page: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
unimplemented!("FakeDiaryRepository::get_movie_social_feed")
}
async fn count_local_posts(&self) -> Result<u64, DomainError> {
unimplemented!("FakeDiaryRepository::count_local_posts")
}
}

View File

@@ -0,0 +1,312 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use uuid::Uuid;
use crate::{
errors::DomainError,
events::DomainEvent,
models::{
Movie, MovieFilter, MovieSummary, Review, User, UserSummary, WatchlistEntry,
WatchlistWithMovie,
collections::{PageParams, Paginated},
},
ports::{MovieRepository, ReviewRepository, UserRepository, WatchlistRepository},
value_objects::{
Email, ExternalMetadataId, MovieId, MovieTitle, ReleaseYear, ReviewId, UserId, Username,
},
};
// ── InMemoryMovieRepository ───────────────────────────────────────────────────
pub struct InMemoryMovieRepository {
pub store: Mutex<HashMap<Uuid, Movie>>,
}
impl InMemoryMovieRepository {
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 MovieRepository for InMemoryMovieRepository {
async fn get_movie_by_external_id(
&self,
external_metadata_id: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
let store = self.store.lock().unwrap();
Ok(store
.values()
.find(|m| {
m.external_metadata_id()
.map(|e| e.value() == external_metadata_id.value())
.unwrap_or(false)
})
.cloned())
}
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
Ok(self.store.lock().unwrap().get(&movie_id.value()).cloned())
}
async fn get_movies_by_title_and_year(
&self,
title: &MovieTitle,
year: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
let store = self.store.lock().unwrap();
Ok(store
.values()
.filter(|m| m.title() == title && m.release_year() == year)
.cloned()
.collect())
}
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> {
self.store
.lock()
.unwrap()
.insert(movie.id().value(), movie.clone());
Ok(())
}
async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> {
self.store.lock().unwrap().remove(&movie_id.value());
Ok(())
}
async fn existing_external_ids(
&self,
ids: &[ExternalMetadataId],
) -> Result<std::collections::HashSet<String>, DomainError> {
let store = self.store.lock().unwrap();
let known: std::collections::HashSet<String> = store
.values()
.filter_map(|m| m.external_metadata_id().map(|e| e.value().to_string()))
.collect();
Ok(ids
.iter()
.map(|id| id.value().to_string())
.filter(|v| known.contains(v))
.collect())
}
async fn existing_title_year_pairs(
&self,
pairs: &[(MovieTitle, ReleaseYear)],
) -> Result<std::collections::HashSet<(String, u16)>, DomainError> {
let store = self.store.lock().unwrap();
let known: std::collections::HashSet<(String, u16)> = store
.values()
.map(|m| (m.title().value().to_string(), m.release_year().value()))
.collect();
Ok(pairs
.iter()
.map(|(t, y)| (t.value().to_string(), y.value()))
.filter(|p| known.contains(p))
.collect())
}
async fn list_movies(
&self,
_page: &PageParams,
_filter: &MovieFilter,
) -> Result<Paginated<MovieSummary>, DomainError> {
Ok(Paginated {
items: vec![],
total_count: 0,
limit: 10,
offset: 0,
})
}
}
// ── InMemoryReviewRepository ──────────────────────────────────────────────────
pub struct InMemoryReviewRepository {
store: Mutex<HashMap<Uuid, Review>>,
}
impl InMemoryReviewRepository {
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 ReviewRepository for InMemoryReviewRepository {
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError> {
self.store
.lock()
.unwrap()
.insert(review.id().value(), review.clone());
Ok(DomainEvent::ReviewLogged {
review_id: review.id().clone(),
movie_id: review.movie_id().clone(),
user_id: review.user_id().clone(),
rating: review.rating().clone(),
watched_at: *review.watched_at(),
})
}
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
Ok(self.store.lock().unwrap().get(&review_id.value()).cloned())
}
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> {
self.store.lock().unwrap().remove(&review_id.value());
Ok(())
}
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError> {
let store = self.store.lock().unwrap();
Ok(store
.values()
.filter(|r| r.user_id() == user_id)
.cloned()
.collect())
}
}
// ── InMemoryUserRepository ────────────────────────────────────────────────────
pub struct InMemoryUserRepository {
pub store: Mutex<HashMap<Uuid, User>>,
}
impl InMemoryUserRepository {
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 UserRepository for InMemoryUserRepository {
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
let store = self.store.lock().unwrap();
Ok(store
.values()
.find(|u| u.email().value() == email.value())
.cloned())
}
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
let store = self.store.lock().unwrap();
Ok(store
.values()
.find(|u| u.username().value() == username.value())
.cloned())
}
async fn save(&self, user: &User) -> Result<(), DomainError> {
self.store
.lock()
.unwrap()
.insert(user.id().value(), user.clone());
Ok(())
}
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
Ok(self.store.lock().unwrap().get(&id.value()).cloned())
}
async fn list_with_stats(&self) -> Result<Vec<UserSummary>, DomainError> {
Ok(vec![])
}
async fn update_profile(
&self,
_user_id: &UserId,
_profile: &crate::models::UserProfile,
) -> Result<(), DomainError> {
Ok(())
}
}
// ── InMemoryWatchlistRepository ───────────────────────────────────────────────
pub struct InMemoryWatchlistRepository {
store: Mutex<HashMap<(Uuid, Uuid), WatchlistEntry>>,
}
impl InMemoryWatchlistRepository {
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 WatchlistRepository for InMemoryWatchlistRepository {
async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> {
let key = (entry.user_id.value(), entry.movie_id.value());
self.store
.lock()
.unwrap()
.entry(key)
.or_insert_with(|| entry.clone());
Ok(())
}
async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> {
let key = (user_id.value(), movie_id.value());
self.store
.lock()
.unwrap()
.remove(&key)
.ok_or_else(|| DomainError::NotFound("watchlist entry".into()))?;
Ok(())
}
async fn remove_if_present(
&self,
user_id: &UserId,
movie_id: &MovieId,
) -> Result<bool, DomainError> {
let key = (user_id.value(), movie_id.value());
Ok(self.store.lock().unwrap().remove(&key).is_some())
}
async fn get_for_user(
&self,
_user_id: &UserId,
_page: &PageParams,
) -> Result<Paginated<WatchlistWithMovie>, DomainError> {
Ok(Paginated {
items: vec![],
total_count: 0,
limit: 10,
offset: 0,
})
}
async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result<bool, DomainError> {
let key = (user_id.value(), movie_id.value());
Ok(self.store.lock().unwrap().contains_key(&key))
}
}

View File

@@ -0,0 +1,11 @@
mod fakes;
mod in_memory;
mod noops;
mod panics;
mod wrapup;
pub use fakes::*;
pub use in_memory::*;
pub use noops::*;
pub use panics::*;
pub use wrapup::*;

View File

@@ -0,0 +1,191 @@
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use crate::{
errors::DomainError,
events::DomainEvent,
ports::{EventPublisher, ObjectStorage},
value_objects::UserId,
};
// ── NoopEventPublisher ────────────────────────────────────────────────────────
pub struct NoopEventPublisher {
pub events: Mutex<Vec<DomainEvent>>,
}
impl NoopEventPublisher {
pub fn new() -> Arc<Self> {
Arc::new(Self {
events: Mutex::new(vec![]),
})
}
pub fn published(&self) -> Vec<DomainEvent> {
self.events.lock().unwrap().clone()
}
}
#[async_trait]
impl EventPublisher for NoopEventPublisher {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
self.events.lock().unwrap().push(event.clone());
Ok(())
}
}
// ── NoopObjectStorage ──────────────────────────────────────────────────────────
pub struct NoopObjectStorage;
#[async_trait]
impl ObjectStorage for NoopObjectStorage {
async fn store(&self, key: &str, _image_bytes: &[u8]) -> Result<String, DomainError> {
Ok(format!("noop://{key}"))
}
async fn get(&self, _key: &str) -> Result<Vec<u8>, DomainError> {
Ok(vec![])
}
async fn get_stream(
&self,
_key: &str,
) -> Result<futures::stream::BoxStream<'static, Result<bytes::Bytes, DomainError>>, DomainError>
{
Ok(Box::pin(futures::stream::empty()))
}
async fn delete(&self, _key: &str) -> Result<(), DomainError> {
Ok(())
}
}
// ── NoopRemoteWatchlistRepository ─────────────────────────────────────────────
pub struct NoopRemoteWatchlistRepository;
#[async_trait]
impl crate::ports::RemoteWatchlistRepository for NoopRemoteWatchlistRepository {
async fn save(&self, _: crate::models::RemoteWatchlistEntry) -> Result<(), DomainError> {
Ok(())
}
async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> {
Ok(())
}
async fn get_by_actor_url(
&self,
_: &str,
) -> Result<Vec<crate::models::RemoteWatchlistEntry>, DomainError> {
Ok(vec![])
}
async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> {
Ok(())
}
async fn get_by_derived_uuid(
&self,
_: uuid::Uuid,
) -> Result<Vec<crate::models::RemoteWatchlistEntry>, DomainError> {
Ok(vec![])
}
}
// ── NoopSocialQueryPort ───────────────────────────────────────────────────────
pub struct NoopSocialQueryPort;
#[async_trait]
impl crate::ports::SocialQueryPort for NoopSocialQueryPort {
async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result<Vec<String>, DomainError> {
Ok(vec![])
}
async fn list_all_followed_remote_actors(
&self,
) -> Result<Vec<crate::ports::RemoteActorInfo>, DomainError> {
Ok(vec![])
}
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
Ok(0)
}
async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
Ok(0)
}
async fn get_pending_followers(
&self,
_: uuid::Uuid,
) -> Result<Vec<crate::ports::PendingFollowerInfo>, DomainError> {
Ok(vec![])
}
}
// ── NoopGoalRepository ────────────────────────────────────────────────────────
pub struct NoopGoalRepository;
#[async_trait]
impl crate::ports::GoalRepository for NoopGoalRepository {
async fn save(&self, _: &crate::models::Goal) -> Result<(), DomainError> {
Ok(())
}
async fn update(&self, _: &crate::models::Goal) -> Result<(), DomainError> {
Ok(())
}
async fn delete(
&self,
_: &crate::value_objects::GoalId,
_: &UserId,
) -> Result<(), DomainError> {
Ok(())
}
async fn find_by_user_and_year(
&self,
_: &UserId,
_: u16,
) -> Result<Option<crate::models::Goal>, DomainError> {
Ok(None)
}
async fn list_for_user(&self, _: &UserId) -> Result<Vec<crate::models::Goal>, DomainError> {
Ok(vec![])
}
async fn count_reviews_in_year(&self, _: &UserId, _: u16) -> Result<u32, DomainError> {
Ok(0)
}
}
// ── NoopUserSettingsRepository ────────────────────────────────────────────────
pub struct NoopUserSettingsRepository;
#[async_trait]
impl crate::ports::UserSettingsRepository for NoopUserSettingsRepository {
async fn get(&self, user_id: &UserId) -> Result<crate::models::UserSettings, DomainError> {
Ok(crate::models::UserSettings::new(user_id.clone()))
}
async fn save(&self, _: &crate::models::UserSettings) -> Result<(), DomainError> {
Ok(())
}
}
// ── NoopRemoteGoalRepository ──────────────────────────────────────────────────
pub struct NoopRemoteGoalRepository;
#[async_trait]
impl crate::ports::RemoteGoalRepository for NoopRemoteGoalRepository {
async fn save(&self, _: crate::models::RemoteGoalEntry) -> Result<(), DomainError> {
Ok(())
}
async fn update_by_ap_id(&self, _: &str, _: u32, _: u32) -> Result<(), DomainError> {
Ok(())
}
async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> {
Ok(())
}
async fn get_by_actor_url(
&self,
_: &str,
) -> Result<Vec<crate::models::RemoteGoalEntry>, DomainError> {
Ok(vec![])
}
}

View File

@@ -0,0 +1,405 @@
use async_trait::async_trait;
use crate::{
errors::DomainError,
models::{
AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId,
FeedEntry, FieldMapping, FileFormat, ImportError, ImportProfile, ImportSession,
IndexableDocument, MovieProfile, MovieStats, ParsedFile, Person, PersonCredits, PersonId,
ReviewHistory, SearchQuery, SearchResults, UserStats, UserTrends,
collections::{PageParams, Paginated},
},
ports::{
DiaryExporter, DiaryRepository, DocumentParser, FeedSortBy, FollowingFilter,
ImportProfileRepository, ImportSessionRepository, MovieProfileRepository, PersonCommand,
PersonQuery, PosterFetcherClient, SearchCommand, SearchPort, StatsRepository,
UserProfileFieldsRepository,
},
value_objects::{ImportProfileId, ImportSessionId, MovieId, PosterUrl, UserId},
};
// ── PanicDiaryRepository ──────────────────────────────────────────────────────
pub struct PanicDiaryRepository;
#[async_trait]
impl DiaryRepository for PanicDiaryRepository {
async fn query_diary(&self, _: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> {
panic!("PanicDiaryRepository called")
}
async fn query_activity_feed(
&self,
_: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
panic!("PanicDiaryRepository called")
}
async fn query_activity_feed_filtered(
&self,
_: &PageParams,
_: &FeedSortBy,
_: Option<&str>,
_: Option<&FollowingFilter>,
) -> Result<Paginated<FeedEntry>, DomainError> {
panic!("PanicDiaryRepository called")
}
async fn get_review_history(&self, _: &MovieId) -> Result<ReviewHistory, DomainError> {
panic!("PanicDiaryRepository called")
}
async fn get_user_history(&self, _: &UserId) -> Result<Vec<DiaryEntry>, DomainError> {
panic!("PanicDiaryRepository called")
}
async fn get_movie_stats(&self, _: &MovieId) -> Result<MovieStats, DomainError> {
panic!("PanicDiaryRepository called")
}
async fn get_movie_social_feed(
&self,
_: &MovieId,
_: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
panic!("PanicDiaryRepository called")
}
async fn count_local_posts(&self) -> Result<u64, DomainError> {
panic!("PanicDiaryRepository called")
}
}
pub struct PanicStatsRepository;
#[async_trait]
impl StatsRepository for PanicStatsRepository {
async fn get_user_stats(&self, _: &UserId) -> Result<UserStats, DomainError> {
panic!("PanicStatsRepository called")
}
async fn get_user_trends(&self, _: &UserId) -> Result<UserTrends, DomainError> {
panic!("PanicStatsRepository called")
}
}
pub struct PanicImportSessionRepository;
#[async_trait]
impl ImportSessionRepository for PanicImportSessionRepository {
async fn create(&self, _: &ImportSession) -> Result<(), DomainError> {
panic!("PanicImportSessionRepository called")
}
async fn get(
&self,
_: &ImportSessionId,
_: &UserId,
) -> Result<Option<ImportSession>, DomainError> {
panic!("PanicImportSessionRepository called")
}
async fn update(&self, _: &ImportSession) -> Result<(), DomainError> {
panic!("PanicImportSessionRepository called")
}
async fn delete(&self, _: &ImportSessionId) -> Result<(), DomainError> {
panic!("PanicImportSessionRepository called")
}
async fn delete_expired(&self) -> Result<u64, DomainError> {
panic!("PanicImportSessionRepository called")
}
async fn delete_expired_for_user(&self, _: &UserId) -> Result<(), DomainError> {
panic!("PanicImportSessionRepository called")
}
}
pub struct PanicImportProfileRepository;
#[async_trait]
impl ImportProfileRepository for PanicImportProfileRepository {
async fn save(&self, _: &ImportProfile) -> Result<(), DomainError> {
panic!("PanicImportProfileRepository called")
}
async fn list_for_user(&self, _: &UserId) -> Result<Vec<ImportProfile>, DomainError> {
panic!("PanicImportProfileRepository called")
}
async fn get(
&self,
_: &ImportProfileId,
_: &UserId,
) -> Result<Option<ImportProfile>, DomainError> {
panic!("PanicImportProfileRepository called")
}
async fn delete(&self, _: &ImportProfileId) -> Result<(), DomainError> {
panic!("PanicImportProfileRepository called")
}
}
pub struct PanicMovieProfileRepository;
#[async_trait]
impl MovieProfileRepository for PanicMovieProfileRepository {
async fn upsert(&self, _: &MovieProfile) -> Result<(), DomainError> {
panic!("PanicMovieProfileRepository called")
}
async fn get_by_movie_id(&self, _: &MovieId) -> Result<Option<MovieProfile>, DomainError> {
panic!("PanicMovieProfileRepository called")
}
async fn list_stale(&self) -> Result<Vec<(MovieId, String)>, DomainError> {
panic!("PanicMovieProfileRepository called")
}
}
pub struct PanicPersonCommand;
#[async_trait]
impl PersonCommand for PanicPersonCommand {
async fn upsert_batch(&self, _: &[Person]) -> Result<(), DomainError> {
panic!("PanicPersonCommand called")
}
async fn backfill_from_credits_batch(&self, _: u32) -> Result<(u64, bool), DomainError> {
panic!("PanicPersonCommand called")
}
}
pub struct PanicPersonQuery;
#[async_trait]
impl PersonQuery for PanicPersonQuery {
async fn get_by_id(&self, _: &PersonId) -> Result<Option<Person>, DomainError> {
panic!("PanicPersonQuery called")
}
async fn get_by_external_id(
&self,
_: &ExternalPersonId,
) -> Result<Option<Person>, DomainError> {
panic!("PanicPersonQuery called")
}
async fn get_credits(&self, _: &PersonId) -> Result<PersonCredits, DomainError> {
panic!("PanicPersonQuery called")
}
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError> {
panic!("PanicPersonQuery called")
}
async fn list_page(&self, _: u32, _: u32) -> Result<Vec<Person>, DomainError> {
panic!("PanicPersonQuery called")
}
}
pub struct PanicSearchPort;
#[async_trait]
impl SearchPort for PanicSearchPort {
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,
},
})
}
}
pub struct PanicSearchCommand;
#[async_trait]
impl SearchCommand for PanicSearchCommand {
async fn index(&self, _: IndexableDocument) -> Result<(), DomainError> {
panic!("PanicSearchCommand called")
}
async fn remove(&self, _: EntityType, _: &str) -> Result<(), DomainError> {
panic!("PanicSearchCommand called")
}
}
pub struct PanicPosterFetcher;
#[async_trait]
impl PosterFetcherClient for PanicPosterFetcher {
async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result<Vec<u8>, DomainError> {
panic!("PanicPosterFetcher called")
}
}
pub struct PanicDiaryExporter;
#[async_trait]
impl DiaryExporter for PanicDiaryExporter {
async fn serialize_entries(
&self,
_: &[DiaryEntry],
_: ExportFormat,
) -> Result<Vec<u8>, DomainError> {
panic!("PanicDiaryExporter called")
}
}
pub struct PanicDocumentParser;
impl DocumentParser for PanicDocumentParser {
fn parse(&self, _: &[u8], _: FileFormat) -> Result<ParsedFile, ImportError> {
panic!("PanicDocumentParser called")
}
fn apply_mapping(&self, _: &ParsedFile, _: &[FieldMapping]) -> Vec<AnnotatedRow> {
panic!("PanicDocumentParser called")
}
}
pub struct PanicRemoteWatchlistRepository;
#[async_trait]
impl crate::ports::RemoteWatchlistRepository for PanicRemoteWatchlistRepository {
async fn save(&self, _: crate::models::RemoteWatchlistEntry) -> Result<(), DomainError> {
panic!("PanicRemoteWatchlistRepository called")
}
async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> {
panic!("PanicRemoteWatchlistRepository called")
}
async fn get_by_actor_url(
&self,
_: &str,
) -> Result<Vec<crate::models::RemoteWatchlistEntry>, DomainError> {
panic!("PanicRemoteWatchlistRepository called")
}
async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> {
panic!("PanicRemoteWatchlistRepository called")
}
async fn get_by_derived_uuid(
&self,
_: uuid::Uuid,
) -> Result<Vec<crate::models::RemoteWatchlistEntry>, DomainError> {
panic!("PanicRemoteWatchlistRepository called")
}
}
pub struct PanicProfileFieldsRepo;
#[async_trait]
impl UserProfileFieldsRepository for PanicProfileFieldsRepo {
async fn get_fields(
&self,
_: &UserId,
) -> Result<Vec<crate::models::ProfileField>, DomainError> {
panic!("PanicProfileFieldsRepo called")
}
async fn set_fields(
&self,
_: &UserId,
_: Vec<crate::models::ProfileField>,
) -> Result<(), DomainError> {
panic!("PanicProfileFieldsRepo called")
}
}
pub struct PanicSocialQueryPort;
#[async_trait]
impl crate::ports::SocialQueryPort for PanicSocialQueryPort {
async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result<Vec<String>, DomainError> {
panic!("PanicSocialQueryPort called")
}
async fn list_all_followed_remote_actors(
&self,
) -> Result<Vec<crate::ports::RemoteActorInfo>, DomainError> {
panic!("PanicSocialQueryPort called")
}
async fn count_following(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
panic!("PanicSocialQueryPort called")
}
async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result<usize, DomainError> {
panic!("PanicSocialQueryPort called")
}
async fn get_pending_followers(
&self,
_: uuid::Uuid,
) -> Result<Vec<crate::ports::PendingFollowerInfo>, DomainError> {
panic!("PanicSocialQueryPort called")
}
}
pub struct PanicWatchEventRepository;
#[async_trait]
impl crate::ports::WatchEventRepository for PanicWatchEventRepository {
async fn save(&self, _: &crate::models::WatchEvent) -> Result<(), DomainError> {
panic!("PanicWatchEventRepository called")
}
async fn update_status(
&self,
_: &crate::value_objects::WatchEventId,
_: crate::models::WatchEventStatus,
) -> Result<(), DomainError> {
panic!("PanicWatchEventRepository called")
}
async fn list_pending(
&self,
_: &UserId,
) -> Result<Vec<crate::models::WatchEvent>, DomainError> {
panic!("PanicWatchEventRepository called")
}
async fn get_by_id(
&self,
_: &crate::value_objects::WatchEventId,
) -> Result<Option<crate::models::WatchEvent>, DomainError> {
panic!("PanicWatchEventRepository called")
}
async fn get_by_ids(
&self,
_: &[crate::value_objects::WatchEventId],
) -> Result<Vec<crate::models::WatchEvent>, DomainError> {
panic!("PanicWatchEventRepository called")
}
async fn update_status_batch(
&self,
_: &[crate::value_objects::WatchEventId],
_: crate::models::WatchEventStatus,
) -> Result<u64, DomainError> {
panic!("PanicWatchEventRepository called")
}
async fn find_duplicate(
&self,
_: &UserId,
_: &str,
_: chrono::NaiveDateTime,
) -> Result<bool, DomainError> {
panic!("PanicWatchEventRepository called")
}
async fn delete_non_pending_older_than(
&self,
_: chrono::NaiveDateTime,
) -> Result<u64, DomainError> {
panic!("PanicWatchEventRepository called")
}
}
pub struct PanicWebhookTokenRepository;
#[async_trait]
impl crate::ports::WebhookTokenRepository for PanicWebhookTokenRepository {
async fn save(&self, _: &crate::models::WebhookToken) -> Result<(), DomainError> {
panic!("PanicWebhookTokenRepository called")
}
async fn find_by_token_hash(
&self,
_: &str,
) -> Result<Option<crate::models::WebhookToken>, DomainError> {
panic!("PanicWebhookTokenRepository called")
}
async fn list_by_user(
&self,
_: &UserId,
) -> Result<Vec<crate::models::WebhookToken>, DomainError> {
panic!("PanicWebhookTokenRepository called")
}
async fn delete(
&self,
_: &crate::value_objects::WebhookTokenId,
_: &UserId,
) -> Result<(), DomainError> {
panic!("PanicWebhookTokenRepository called")
}
async fn touch_last_used(
&self,
_: &crate::value_objects::WebhookTokenId,
) -> Result<(), DomainError> {
panic!("PanicWebhookTokenRepository called")
}
}

View File

@@ -0,0 +1,247 @@
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use uuid::Uuid;
use crate::{errors::DomainError, ports::WrapUpRepository, value_objects::WrapUpId};
// ── PanicWrapUpStatsQuery ───────────────────────────────────────────────────
pub struct PanicWrapUpStatsQuery;
#[async_trait]
impl crate::ports::WrapUpStatsQuery for PanicWrapUpStatsQuery {
async fn get_reviews_with_profiles(
&self,
_: &crate::models::wrapup::WrapUpScope,
_: &crate::models::wrapup::DateRange,
) -> Result<Vec<crate::ports::WrapUpMovieRow>, DomainError> {
unimplemented!("WrapUpStatsQuery not wired")
}
}
// ── InMemoryWrapUpStatsQuery ────────────────────────────────────────────────
pub struct InMemoryWrapUpStatsQuery {
pub rows: Mutex<Vec<crate::ports::WrapUpMovieRow>>,
}
impl InMemoryWrapUpStatsQuery {
pub fn new() -> Arc<Self> {
Arc::new(Self {
rows: Mutex::new(Vec::new()),
})
}
pub fn with_rows(rows: Vec<crate::ports::WrapUpMovieRow>) -> Arc<Self> {
Arc::new(Self {
rows: Mutex::new(rows),
})
}
}
#[async_trait]
impl crate::ports::WrapUpStatsQuery for InMemoryWrapUpStatsQuery {
async fn get_reviews_with_profiles(
&self,
scope: &crate::models::wrapup::WrapUpScope,
range: &crate::models::wrapup::DateRange,
) -> Result<Vec<crate::ports::WrapUpMovieRow>, DomainError> {
let rows = self.rows.lock().unwrap();
let filtered: Vec<_> = rows
.iter()
.filter(|r| {
let date = r.watched_at.date();
date >= range.start() && date < range.end()
})
.filter(|r| match scope {
crate::models::wrapup::WrapUpScope::User(uid) => r.user_id == *uid,
crate::models::wrapup::WrapUpScope::Global => true,
})
.cloned()
.collect();
Ok(filtered)
}
}
// ── InMemoryWrapUpRepository ────────────────────────────────────────────────
pub struct InMemoryWrapUpRepository {
pub store: Mutex<Vec<crate::models::wrapup::WrapUpRecord>>,
}
impl InMemoryWrapUpRepository {
pub fn new() -> Arc<Self> {
Arc::new(Self {
store: Mutex::new(Vec::new()),
})
}
}
#[async_trait]
impl WrapUpRepository for InMemoryWrapUpRepository {
async fn create(
&self,
record: &crate::models::wrapup::WrapUpRecord,
) -> Result<(), DomainError> {
self.store.lock().unwrap().push(record.clone());
Ok(())
}
async fn update_status(
&self,
id: &WrapUpId,
status: &crate::models::wrapup::WrapUpStatus,
error: Option<&str>,
) -> Result<(), DomainError> {
let mut store = self.store.lock().unwrap();
if let Some(rec) = store.iter_mut().find(|r| r.id == *id) {
rec.status = status.clone();
rec.error_message = error.map(|s| s.to_string());
Ok(())
} else {
Err(DomainError::NotFound("wrapup record".into()))
}
}
async fn set_complete(
&self,
id: &WrapUpId,
report: &crate::models::wrapup::WrapUpReport,
) -> Result<(), DomainError> {
let mut store = self.store.lock().unwrap();
if let Some(rec) = store.iter_mut().find(|r| r.id == *id) {
rec.status = crate::models::wrapup::WrapUpStatus::Ready;
rec.report = Some(report.clone());
rec.completed_at = Some(chrono::Utc::now().naive_utc());
Ok(())
} else {
Err(DomainError::NotFound("wrapup record".into()))
}
}
async fn get_by_id(
&self,
id: &WrapUpId,
) -> Result<Option<crate::models::wrapup::WrapUpRecord>, DomainError> {
Ok(self
.store
.lock()
.unwrap()
.iter()
.find(|r| r.id == *id)
.cloned())
}
async fn list_for_user(
&self,
user_id: Uuid,
) -> Result<Vec<crate::models::wrapup::WrapUpRecord>, DomainError> {
Ok(self
.store
.lock()
.unwrap()
.iter()
.filter(|r| r.user_id == Some(user_id))
.cloned()
.collect())
}
async fn list_global(&self) -> Result<Vec<crate::models::wrapup::WrapUpRecord>, DomainError> {
Ok(self
.store
.lock()
.unwrap()
.iter()
.filter(|r| r.user_id.is_none())
.cloned()
.collect())
}
async fn find_existing(
&self,
user_id: Option<Uuid>,
start: chrono::NaiveDate,
end: chrono::NaiveDate,
) -> Result<Option<crate::models::wrapup::WrapUpRecord>, DomainError> {
Ok(self
.store
.lock()
.unwrap()
.iter()
.find(|r| r.user_id == user_id && r.start_date == start && r.end_date == end)
.cloned())
}
async fn delete(&self, id: &WrapUpId) -> Result<(), DomainError> {
self.store.lock().unwrap().retain(|r| r.id != *id);
Ok(())
}
async fn delete_failed_older_than(
&self,
before: chrono::NaiveDateTime,
) -> Result<u64, DomainError> {
let mut store = self.store.lock().unwrap();
let before_len = store.len();
store.retain(|r| {
!(r.status == crate::models::wrapup::WrapUpStatus::Failed && r.created_at < before)
});
Ok((before_len - store.len()) as u64)
}
}
// ── PanicWrapUpRepository ──────────────────────────────────────────────────
pub struct PanicWrapUpRepository;
#[async_trait]
impl WrapUpRepository for PanicWrapUpRepository {
async fn create(&self, _: &crate::models::wrapup::WrapUpRecord) -> Result<(), DomainError> {
panic!("PanicWrapUpRepository called")
}
async fn update_status(
&self,
_: &WrapUpId,
_: &crate::models::wrapup::WrapUpStatus,
_: Option<&str>,
) -> Result<(), DomainError> {
panic!("PanicWrapUpRepository called")
}
async fn set_complete(
&self,
_: &WrapUpId,
_: &crate::models::wrapup::WrapUpReport,
) -> Result<(), DomainError> {
panic!("PanicWrapUpRepository called")
}
async fn get_by_id(
&self,
_: &WrapUpId,
) -> Result<Option<crate::models::wrapup::WrapUpRecord>, DomainError> {
panic!("PanicWrapUpRepository called")
}
async fn list_for_user(
&self,
_: Uuid,
) -> Result<Vec<crate::models::wrapup::WrapUpRecord>, DomainError> {
panic!("PanicWrapUpRepository called")
}
async fn list_global(&self) -> Result<Vec<crate::models::wrapup::WrapUpRecord>, DomainError> {
panic!("PanicWrapUpRepository called")
}
async fn find_existing(
&self,
_: Option<Uuid>,
_: chrono::NaiveDate,
_: chrono::NaiveDate,
) -> Result<Option<crate::models::wrapup::WrapUpRecord>, DomainError> {
panic!("PanicWrapUpRepository called")
}
async fn delete(&self, _: &WrapUpId) -> Result<(), DomainError> {
panic!("PanicWrapUpRepository called")
}
async fn delete_failed_older_than(&self, _: chrono::NaiveDateTime) -> Result<u64, DomainError> {
panic!("PanicWrapUpRepository called")
}
}