Compare commits

...

2 Commits

Author SHA1 Message Date
f80d3b5983 docs/ci: note unit tests in README, gate release build to master
Some checks failed
CI / Check / Test (push) Failing after 43s
CI / Release build (push) Has been skipped
2026-05-14 00:43:07 +02:00
edc1f6c850 feat: domain mocks, TestContextBuilder, use case tests, factory pattern
- Add test-helpers feature to domain crate with in-memory mocks and panic stubs for all ports
- Add TestContextBuilder to application crate for zero-database test setup
- Add unit tests for log_review, register, login, add_to_watchlist, delete_review use cases
- Extract DatabaseAdapters factory and build_* helpers into presentation/src/factory.rs
- Refactor wire_dependencies() in main.rs to use factory module
2026-05-14 00:41:25 +02:00
16 changed files with 1485 additions and 98 deletions

View File

@@ -11,7 +11,7 @@ env:
jobs:
ci:
name: Check / Test / Build
name: Check / Test
runs-on: ubuntu-latest
steps:
@@ -38,5 +38,27 @@ jobs:
- name: test
run: cargo test
release-build:
name: Release build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
needs: ci
steps:
- uses: actions/checkout@v4
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }}
restore-keys: ${{ runner.os }}-cargo-release-
- name: build (release)
run: cargo build --release -p presentation -p worker

View File

@@ -153,9 +153,12 @@ Supports review logging, bulk CSV import (column order matches the export format
## Test
```bash
cargo test
cargo test # full workspace (requires DATABASE_URL for sqlx offline checks)
cargo test -p application # domain-level unit tests only — no database required
```
The `application` crate has unit tests for core use cases (`log_review`, `register`, `login`, `add_to_watchlist`, `delete_review`) backed by in-memory fakes from `domain`'s `test-helpers` feature. These run without a database and are the fastest feedback loop for business logic changes.
## Docker
The image contains both `presentation` (HTTP server) and `worker` (event processor). Run them as separate containers sharing the same data volume:

View File

@@ -18,3 +18,4 @@ federation = []
[dev-dependencies]
tokio = { workspace = true }
domain = { workspace = true, features = ["test-helpers"] }

View File

@@ -10,5 +10,8 @@ pub mod search_cleanup;
pub mod use_cases;
pub mod worker;
#[cfg(test)]
pub mod test_helpers;
pub use movie_discovery_indexer::MovieDiscoveryIndexer;
pub use search_cleanup::SearchCleanupHandler;

View File

@@ -0,0 +1,148 @@
use std::sync::Arc;
use domain::{
ports::{
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, ImageStorage,
ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository,
MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository,
UserRepository, WatchlistRepository,
},
testing::{
FakeAuthService, FakeDiaryRepository, FakeMetadataClient, FakePasswordHasher,
InMemoryMovieRepository, InMemoryReviewRepository, InMemoryUserRepository,
InMemoryWatchlistRepository, NoopEventPublisher, NoopImageStorage, PanicDiaryExporter,
PanicDiaryRepository, PanicDocumentParser, PanicImportProfileRepository,
PanicImportSessionRepository, PanicMovieProfileRepository, PanicPersonCommand,
PanicPersonQuery, PanicPosterFetcher, PanicProfileFieldsRepo, PanicSearchCommand,
PanicSearchPort, PanicStatsRepository,
},
};
use crate::{
config::AppConfig,
context::AppContext,
};
pub struct TestContextBuilder {
pub movie_repo: Arc<dyn MovieRepository>,
pub review_repo: Arc<dyn ReviewRepository>,
pub diary_repo: Arc<dyn DiaryRepository>,
pub diary_exporter: Arc<dyn DiaryExporter>,
pub document_parser: Arc<dyn DocumentParser>,
pub stats_repo: Arc<dyn StatsRepository>,
pub metadata_client: Arc<dyn MetadataClient>,
pub poster_fetcher: Arc<dyn PosterFetcherClient>,
pub image_storage: Arc<dyn ImageStorage>,
pub event_publisher: Arc<dyn EventPublisher>,
pub auth_service: Arc<dyn AuthService>,
pub password_hasher: Arc<dyn PasswordHasher>,
pub user_repo: Arc<dyn UserRepository>,
pub import_session_repo: Arc<dyn ImportSessionRepository>,
pub import_profile_repo: Arc<dyn ImportProfileRepository>,
pub movie_profile_repo: Arc<dyn MovieProfileRepository>,
pub watchlist_repo: Arc<dyn WatchlistRepository>,
pub profile_fields_repo: Arc<dyn UserProfileFieldsRepository>,
pub person_command: Arc<dyn PersonCommand>,
pub person_query: Arc<dyn PersonQuery>,
pub search_port: Arc<dyn SearchPort>,
pub search_command: Arc<dyn SearchCommand>,
pub config: AppConfig,
}
impl TestContextBuilder {
pub fn new() -> Self {
Self {
movie_repo: InMemoryMovieRepository::new(),
review_repo: InMemoryReviewRepository::new(),
diary_repo: Arc::new(PanicDiaryRepository),
diary_exporter: Arc::new(PanicDiaryExporter),
document_parser: Arc::new(PanicDocumentParser),
stats_repo: Arc::new(PanicStatsRepository),
metadata_client: Arc::new(FakeMetadataClient),
poster_fetcher: Arc::new(PanicPosterFetcher),
image_storage: Arc::new(NoopImageStorage),
event_publisher: NoopEventPublisher::new(),
auth_service: Arc::new(FakeAuthService),
password_hasher: Arc::new(FakePasswordHasher),
user_repo: InMemoryUserRepository::new(),
import_session_repo: Arc::new(PanicImportSessionRepository),
import_profile_repo: Arc::new(PanicImportProfileRepository),
movie_profile_repo: Arc::new(PanicMovieProfileRepository),
watchlist_repo: InMemoryWatchlistRepository::new(),
profile_fields_repo: Arc::new(PanicProfileFieldsRepo),
person_command: Arc::new(PanicPersonCommand),
person_query: Arc::new(PanicPersonQuery),
search_port: Arc::new(PanicSearchPort),
search_command: Arc::new(PanicSearchCommand),
config: AppConfig {
allow_registration: true,
base_url: "http://localhost:3000".into(),
rate_limit: 20,
},
}
}
pub fn with_movies(mut self, r: Arc<dyn MovieRepository>) -> Self {
self.movie_repo = r;
self
}
pub fn with_reviews(mut self, r: Arc<dyn ReviewRepository>) -> Self {
self.review_repo = r;
self
}
pub fn with_users(mut self, r: Arc<dyn UserRepository>) -> Self {
self.user_repo = r;
self
}
pub fn with_watchlist(mut self, r: Arc<dyn WatchlistRepository>) -> Self {
self.watchlist_repo = r;
self
}
pub fn with_diary(mut self, r: Arc<dyn DiaryRepository>) -> Self {
self.diary_repo = r;
self
}
pub fn with_event_publisher(mut self, p: Arc<dyn EventPublisher>) -> Self {
self.event_publisher = p;
self
}
pub fn with_config(mut self, config: AppConfig) -> Self {
self.config = config;
self
}
pub fn build(self) -> AppContext {
AppContext {
movie_repository: self.movie_repo,
review_repository: self.review_repo,
diary_repository: self.diary_repo,
diary_exporter: self.diary_exporter,
document_parser: self.document_parser,
stats_repository: self.stats_repo,
metadata_client: self.metadata_client,
poster_fetcher: self.poster_fetcher,
image_storage: self.image_storage,
event_publisher: self.event_publisher,
auth_service: self.auth_service,
password_hasher: self.password_hasher,
user_repository: self.user_repo,
import_session_repository: self.import_session_repo,
import_profile_repository: self.import_profile_repo,
movie_profile_repository: self.movie_profile_repo,
watchlist_repository: self.watchlist_repo,
profile_fields_repository: self.profile_fields_repo,
person_command: self.person_command,
person_query: self.person_query,
search_port: self.search_port,
search_command: self.search_command,
config: self.config,
}
}
}

View File

@@ -60,3 +60,95 @@ pub async fn execute(ctx: &AppContext, cmd: AddToWatchlistCommand) -> Result<(),
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use domain::{
models::Movie,
ports::MovieRepository,
value_objects::{MovieTitle, ReleaseYear},
testing::{InMemoryMovieRepository, InMemoryWatchlistRepository},
};
use crate::{
commands::{AddToWatchlistCommand, MovieInput},
test_helpers::TestContextBuilder,
use_cases::add_to_watchlist,
};
#[tokio::test]
async fn test_add_to_watchlist_resolves_and_saves() {
let movies = InMemoryMovieRepository::new();
let watchlist = InMemoryWatchlistRepository::new();
let movie = Movie::new(
None,
MovieTitle::new("The Thing".into()).unwrap(),
ReleaseYear::new(1982).unwrap(),
None,
None,
);
let movie_uuid = movie.id().value();
movies.upsert_movie(&movie).await.unwrap();
let ctx = TestContextBuilder::new()
.with_movies(Arc::clone(&movies) as _)
.with_watchlist(Arc::clone(&watchlist) as _)
.build();
let cmd = AddToWatchlistCommand {
user_id: uuid::Uuid::new_v4(),
input: MovieInput {
movie_id: Some(movie_uuid),
external_metadata_id: None,
manual_title: None,
manual_release_year: None,
manual_director: None,
},
};
add_to_watchlist::execute(&ctx, cmd).await.unwrap();
assert_eq!(watchlist.count(), 1);
}
#[tokio::test]
async fn test_add_to_watchlist_already_present_is_idempotent() {
let movies = InMemoryMovieRepository::new();
let watchlist = InMemoryWatchlistRepository::new();
let movie = Movie::new(
None,
MovieTitle::new("RoboCop".into()).unwrap(),
ReleaseYear::new(1987).unwrap(),
None,
None,
);
let movie_uuid = movie.id().value();
let user_id = uuid::Uuid::new_v4();
movies.upsert_movie(&movie).await.unwrap();
let ctx = TestContextBuilder::new()
.with_movies(Arc::clone(&movies) as _)
.with_watchlist(Arc::clone(&watchlist) as _)
.build();
let make_cmd = || AddToWatchlistCommand {
user_id,
input: MovieInput {
movie_id: Some(movie_uuid),
external_metadata_id: None,
manual_title: None,
manual_release_year: None,
manual_director: None,
},
};
add_to_watchlist::execute(&ctx, make_cmd()).await.unwrap();
add_to_watchlist::execute(&ctx, make_cmd()).await.unwrap();
assert_eq!(watchlist.count(), 1, "idempotent add should not duplicate");
}
}

View File

@@ -52,3 +52,108 @@ pub async fn execute(ctx: &AppContext, cmd: DeleteReviewCommand) -> Result<(), D
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use chrono::Utc;
use domain::{
models::{Movie, Review},
ports::{MovieRepository, ReviewRepository},
value_objects::{MovieId, MovieTitle, Rating, ReleaseYear, UserId},
testing::{
FakeDiaryRepository, InMemoryMovieRepository, InMemoryReviewRepository,
NoopEventPublisher,
},
};
use crate::{
commands::DeleteReviewCommand,
test_helpers::TestContextBuilder,
use_cases::delete_review,
};
fn make_movie() -> Movie {
Movie::new(
None,
MovieTitle::new("Terminator".into()).unwrap(),
ReleaseYear::new(1984).unwrap(),
None,
None,
)
}
fn make_review(movie_id: MovieId, user_id: UserId) -> Review {
Review::new(movie_id, user_id, Rating::new(4).unwrap(), None, Utc::now().naive_utc())
.unwrap()
}
#[tokio::test]
async fn test_delete_review_removes_it() {
let movies = InMemoryMovieRepository::new();
let reviews = InMemoryReviewRepository::new();
let diary = FakeDiaryRepository::new();
let events = NoopEventPublisher::new();
let movie = make_movie();
let user_id = UserId::from_uuid(uuid::Uuid::new_v4());
let review = make_review(movie.id().clone(), user_id.clone());
movies.upsert_movie(&movie).await.unwrap();
reviews.save_review(&review).await.unwrap();
diary.seed_history(movie.clone(), vec![]);
let ctx = TestContextBuilder::new()
.with_movies(Arc::clone(&movies) as _)
.with_reviews(Arc::clone(&reviews) as _)
.with_diary(Arc::clone(&diary) as _)
.with_event_publisher(Arc::clone(&events) as _)
.build();
delete_review::execute(
&ctx,
DeleteReviewCommand {
review_id: review.id().value(),
requesting_user_id: user_id.value(),
},
)
.await
.unwrap();
assert_eq!(reviews.count(), 0, "review should be deleted");
assert!(
movies.get_movie_by_id(movie.id()).await.unwrap().is_none(),
"movie should be deleted when no reviews remain"
);
}
#[tokio::test]
async fn test_delete_review_wrong_user_is_unauthorized() {
let reviews = InMemoryReviewRepository::new();
let movie_id = MovieId::from_uuid(uuid::Uuid::new_v4());
let owner_id = UserId::from_uuid(uuid::Uuid::new_v4());
let other_id = uuid::Uuid::new_v4();
let review = make_review(movie_id, owner_id);
reviews.save_review(&review).await.unwrap();
let ctx = TestContextBuilder::new()
.with_reviews(Arc::clone(&reviews) as _)
.build();
let result = delete_review::execute(
&ctx,
DeleteReviewCommand {
review_id: review.id().value(),
requesting_user_id: other_id,
},
)
.await;
assert!(result.is_err(), "wrong user should not be able to delete");
assert_eq!(reviews.count(), 1, "review should still exist");
}
}

View File

@@ -58,6 +58,121 @@ pub async fn execute(ctx: &AppContext, cmd: LogReviewCommand) -> Result<(), Doma
Ok(())
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use chrono::Utc;
use domain::{
models::Movie,
value_objects::{MovieId, MovieTitle, ReleaseYear},
};
use domain::ports::MovieRepository;
use domain::testing::{InMemoryMovieRepository, InMemoryReviewRepository, NoopEventPublisher};
use crate::{
commands::{LogReviewCommand, MovieInput},
test_helpers::TestContextBuilder,
use_cases::log_review,
};
fn movie_input_manual(title: &str, year: u16) -> MovieInput {
MovieInput {
movie_id: None,
external_metadata_id: None,
manual_title: Some(title.to_string()),
manual_release_year: Some(year),
manual_director: None,
}
}
fn movie_input_by_id(id: uuid::Uuid) -> MovieInput {
MovieInput {
movie_id: Some(id),
external_metadata_id: None,
manual_title: None,
manual_release_year: None,
manual_director: None,
}
}
#[tokio::test]
async fn test_log_review_creates_movie_and_review() {
let movies = InMemoryMovieRepository::new();
let reviews = InMemoryReviewRepository::new();
let events = NoopEventPublisher::new();
let ctx = TestContextBuilder::new()
.with_movies(Arc::clone(&movies) as _)
.with_reviews(Arc::clone(&reviews) as _)
.with_event_publisher(Arc::clone(&events) as _)
.build();
let user_id = uuid::Uuid::new_v4();
let cmd = LogReviewCommand {
user_id,
input: movie_input_manual("Blade Runner", 1982),
rating: 4,
comment: None,
watched_at: Utc::now().naive_utc(),
};
log_review::execute(&ctx, cmd).await.unwrap();
assert_eq!(reviews.count(), 1, "review should be saved");
assert!(!events.published().is_empty(), "events should be published");
}
#[tokio::test]
async fn test_log_review_reuses_existing_movie() {
let movies = InMemoryMovieRepository::new();
let reviews = InMemoryReviewRepository::new();
let existing_movie = Movie::new(
None,
MovieTitle::new("Alien".into()).unwrap(),
ReleaseYear::new(1979).unwrap(),
None,
None,
);
let movie_uuid = existing_movie.id().value();
movies.upsert_movie(&existing_movie).await.unwrap();
let ctx = TestContextBuilder::new()
.with_movies(Arc::clone(&movies) as _)
.with_reviews(Arc::clone(&reviews) as _)
.build();
let cmd = LogReviewCommand {
user_id: uuid::Uuid::new_v4(),
input: movie_input_by_id(movie_uuid),
rating: 5,
comment: None,
watched_at: Utc::now().naive_utc(),
};
log_review::execute(&ctx, cmd).await.unwrap();
assert_eq!(movies.count(), 1, "no duplicate movie");
assert_eq!(reviews.count(), 1);
}
#[tokio::test]
async fn test_log_review_with_invalid_rating_fails() {
let ctx = TestContextBuilder::new().build();
let cmd = LogReviewCommand {
user_id: uuid::Uuid::new_v4(),
input: movie_input_manual("Some Film", 2000),
rating: 6,
comment: None,
watched_at: Utc::now().naive_utc(),
};
let result = log_review::execute(&ctx, cmd).await;
assert!(result.is_err(), "rating > 5 should fail");
}
}
async fn publish_events(
ctx: &AppContext,
movie: &Movie,

View File

@@ -37,3 +37,92 @@ pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result<LoginResult,
expires_at: generated.expires_at,
})
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use domain::models::UserRole;
use domain::testing::InMemoryUserRepository;
use crate::{
commands::RegisterCommand,
queries::LoginQuery,
test_helpers::TestContextBuilder,
use_cases::{login, register},
};
async fn setup_user(ctx: &crate::context::AppContext, email: &str, password: &str) {
register::execute(
ctx,
RegisterCommand {
email: email.to_string(),
username: "testuser".to_string(),
password: password.to_string(),
role: UserRole::Standard,
},
)
.await
.unwrap();
}
#[tokio::test]
async fn test_login_valid_credentials_returns_token() {
let users = InMemoryUserRepository::new();
let ctx = TestContextBuilder::new()
.with_users(Arc::clone(&users) as _)
.build();
setup_user(&ctx, "carol@example.com", "secret123").await;
let result = login::execute(
&ctx,
LoginQuery {
email: "carol@example.com".into(),
password: "secret123".into(),
},
)
.await
.unwrap();
assert!(!result.token.is_empty());
assert_eq!(result.email, "carol@example.com");
}
#[tokio::test]
async fn test_login_wrong_password_fails() {
let users = InMemoryUserRepository::new();
let ctx = TestContextBuilder::new()
.with_users(Arc::clone(&users) as _)
.build();
setup_user(&ctx, "dave@example.com", "correct_password").await;
let result = login::execute(
&ctx,
LoginQuery {
email: "dave@example.com".into(),
password: "wrong_password".into(),
},
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_login_unknown_email_fails() {
let ctx = TestContextBuilder::new().build();
let result = login::execute(
&ctx,
LoginQuery {
email: "nobody@example.com".into(),
password: "anything".into(),
},
)
.await;
assert!(result.is_err());
}
}

View File

@@ -44,3 +44,55 @@ pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> Result<(), Domai
.save(&User::new(email, username, hash, cmd.role))
.await
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use domain::models::UserRole;
use domain::ports::UserRepository;
use domain::testing::InMemoryUserRepository;
use domain::value_objects::Email;
use crate::{
commands::RegisterCommand,
test_helpers::TestContextBuilder,
use_cases::register,
};
fn cmd(email: &str) -> RegisterCommand {
RegisterCommand {
email: email.to_string(),
username: "alice".to_string(),
password: "password123".to_string(),
role: UserRole::Standard,
}
}
#[tokio::test]
async fn test_register_creates_user() {
let users = InMemoryUserRepository::new();
let ctx = TestContextBuilder::new()
.with_users(Arc::clone(&users) as _)
.build();
register::execute(&ctx, cmd("alice@example.com")).await.unwrap();
let email = Email::new("alice@example.com".into()).unwrap();
let user = users.find_by_email(&email).await.unwrap().unwrap();
assert_eq!(user.email().value(), "alice@example.com");
assert!(user.password_hash().value().starts_with("hashed:"));
}
#[tokio::test]
async fn test_register_duplicate_email_fails() {
let users = InMemoryUserRepository::new();
let ctx = TestContextBuilder::new()
.with_users(Arc::clone(&users) as _)
.build();
register::execute(&ctx, cmd("bob@example.com")).await.unwrap();
let result = register::execute(&ctx, cmd("bob@example.com")).await;
assert!(result.is_err(), "duplicate email should fail");
}
}

View File

@@ -11,3 +11,6 @@ thiserror = { workspace = true }
futures = { workspace = true }
email_address = "0.2.9"
[features]
test-helpers = []

View File

@@ -4,3 +4,6 @@ pub mod models;
pub mod ports;
pub mod services;
pub mod value_objects;
#[cfg(any(test, feature = "test-helpers"))]
pub mod testing;

View File

@@ -0,0 +1,689 @@
#![cfg(any(test, feature = "test-helpers"))]
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use chrono::Utc;
use uuid::Uuid;
use crate::{
errors::DomainError,
events::DomainEvent,
models::{
DiaryEntry, DiaryFilter, ExportFormat, FeedEntry, FieldMapping, FileFormat, ImportError,
ImportProfile, ImportSession, IndexableDocument, Movie, MovieFilter, MovieProfile,
MovieStats, MovieSummary, ParsedFile, Person, PersonCredits, PersonId, ExternalPersonId,
Review, ReviewHistory, SearchQuery, SearchResults, User, UserStats, UserSummary,
UserTrends, WatchlistEntry, WatchlistWithMovie, AnnotatedRow, EntityType,
collections::{PageParams, Paginated},
},
ports::{
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
FeedSortBy, FollowingFilter, GeneratedToken, ImageStorage, ImportProfileRepository,
ImportSessionRepository, MetadataClient, MetadataSearchCriteria, MovieProfileRepository,
MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository,
UserRepository, WatchlistRepository,
},
value_objects::{
Email, ExternalMetadataId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
PasswordHash, PosterUrl, 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 list_movies(
&self,
_page: &crate::models::collections::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,
_bio: Option<String>,
_avatar_path: Option<String>,
_banner_path: Option<String>,
_also_known_as: Option<String>,
) -> 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))
}
}
// ── 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(())
}
}
// ── NoopImageStorage ──────────────────────────────────────────────────────────
pub struct NoopImageStorage;
#[async_trait]
impl ImageStorage for NoopImageStorage {
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 delete(&self, _key: &str) -> Result<(), DomainError> {
Ok(())
}
}
// ── 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")
}
}
// ── PanicDiaryRepository ──────────────────────────────────────────────────────
pub struct PanicDiaryRepository;
#[async_trait]
impl DiaryRepository for PanicDiaryRepository {
async fn query_diary(&self, _filter: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> {
panic!("PanicDiaryRepository called")
}
async fn query_activity_feed(
&self,
_page: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
panic!("PanicDiaryRepository called")
}
async fn query_activity_feed_filtered(
&self,
_page: &PageParams,
_sort_by: &FeedSortBy,
_search: Option<&str>,
_following: Option<&FollowingFilter>,
) -> Result<Paginated<FeedEntry>, DomainError> {
panic!("PanicDiaryRepository called")
}
async fn get_review_history(&self, _movie_id: &MovieId) -> Result<ReviewHistory, DomainError> {
panic!("PanicDiaryRepository called")
}
async fn get_user_history(&self, _user_id: &UserId) -> Result<Vec<DiaryEntry>, DomainError> {
panic!("PanicDiaryRepository called")
}
async fn get_movie_stats(&self, _movie_id: &MovieId) -> Result<MovieStats, DomainError> {
panic!("PanicDiaryRepository called")
}
async fn get_movie_social_feed(
&self,
_movie_id: &MovieId,
_page: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
panic!("PanicDiaryRepository called")
}
async fn count_local_posts(&self) -> Result<u64, DomainError> {
panic!("PanicDiaryRepository called")
}
}
// ── PanicStatsRepository ──────────────────────────────────────────────────────
pub struct PanicStatsRepository;
#[async_trait]
impl StatsRepository for PanicStatsRepository {
async fn get_user_stats(&self, _user_id: &UserId) -> Result<UserStats, DomainError> {
panic!("PanicStatsRepository called")
}
async fn get_user_trends(&self, _user_id: &UserId) -> Result<UserTrends, DomainError> {
panic!("PanicStatsRepository called")
}
}
// ── PanicImportSessionRepository ──────────────────────────────────────────────
pub struct PanicImportSessionRepository;
#[async_trait]
impl ImportSessionRepository for PanicImportSessionRepository {
async fn create(&self, _session: &ImportSession) -> Result<(), DomainError> {
panic!("PanicImportSessionRepository called")
}
async fn get(
&self,
_id: &ImportSessionId,
_user_id: &UserId,
) -> Result<Option<ImportSession>, DomainError> {
panic!("PanicImportSessionRepository called")
}
async fn update(&self, _session: &ImportSession) -> Result<(), DomainError> {
panic!("PanicImportSessionRepository called")
}
async fn delete(&self, _id: &ImportSessionId) -> Result<(), DomainError> {
panic!("PanicImportSessionRepository called")
}
async fn delete_expired(&self) -> Result<u64, DomainError> {
panic!("PanicImportSessionRepository called")
}
async fn delete_expired_for_user(&self, _user_id: &UserId) -> Result<(), DomainError> {
panic!("PanicImportSessionRepository called")
}
}
// ── PanicImportProfileRepository ──────────────────────────────────────────────
pub struct PanicImportProfileRepository;
#[async_trait]
impl ImportProfileRepository for PanicImportProfileRepository {
async fn save(&self, _profile: &ImportProfile) -> Result<(), DomainError> {
panic!("PanicImportProfileRepository called")
}
async fn list_for_user(&self, _user_id: &UserId) -> Result<Vec<ImportProfile>, DomainError> {
panic!("PanicImportProfileRepository called")
}
async fn get(
&self,
_id: &ImportProfileId,
_user_id: &UserId,
) -> Result<Option<ImportProfile>, DomainError> {
panic!("PanicImportProfileRepository called")
}
async fn delete(&self, _id: &ImportProfileId) -> Result<(), DomainError> {
panic!("PanicImportProfileRepository called")
}
}
// ── PanicMovieProfileRepository ───────────────────────────────────────────────
pub struct PanicMovieProfileRepository;
#[async_trait]
impl MovieProfileRepository for PanicMovieProfileRepository {
async fn upsert(&self, _profile: &MovieProfile) -> Result<(), DomainError> {
panic!("PanicMovieProfileRepository called")
}
async fn get_by_movie_id(&self, _id: &MovieId) -> Result<Option<MovieProfile>, DomainError> {
panic!("PanicMovieProfileRepository called")
}
async fn list_stale(&self) -> Result<Vec<(MovieId, String)>, DomainError> {
panic!("PanicMovieProfileRepository called")
}
}
// ── PanicPersonCommand ────────────────────────────────────────────────────────
pub struct PanicPersonCommand;
#[async_trait]
impl PersonCommand for PanicPersonCommand {
async fn upsert_batch(&self, _persons: &[Person]) -> Result<(), DomainError> {
panic!("PanicPersonCommand called")
}
}
// ── PanicPersonQuery ──────────────────────────────────────────────────────────
pub struct PanicPersonQuery;
#[async_trait]
impl PersonQuery for PanicPersonQuery {
async fn get_by_id(&self, _id: &PersonId) -> Result<Option<Person>, DomainError> {
panic!("PanicPersonQuery called")
}
async fn get_by_external_id(
&self,
_id: &ExternalPersonId,
) -> Result<Option<Person>, DomainError> {
panic!("PanicPersonQuery called")
}
async fn get_credits(&self, _id: &PersonId) -> Result<PersonCredits, DomainError> {
panic!("PanicPersonQuery called")
}
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError> {
panic!("PanicPersonQuery called")
}
}
// ── PanicSearchPort ───────────────────────────────────────────────────────────
pub struct PanicSearchPort;
#[async_trait]
impl SearchPort for PanicSearchPort {
async fn search(&self, _query: &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 },
})
}
}
// ── PanicSearchCommand ────────────────────────────────────────────────────────
pub struct PanicSearchCommand;
#[async_trait]
impl SearchCommand for PanicSearchCommand {
async fn index(&self, _doc: IndexableDocument) -> Result<(), DomainError> {
panic!("PanicSearchCommand called")
}
async fn remove(&self, _entity_type: EntityType, _id: &str) -> Result<(), DomainError> {
panic!("PanicSearchCommand called")
}
}
// ── PanicPosterFetcher ────────────────────────────────────────────────────────
pub struct PanicPosterFetcher;
#[async_trait]
impl PosterFetcherClient for PanicPosterFetcher {
async fn fetch_poster_bytes(&self, _poster_url: &PosterUrl) -> Result<Vec<u8>, DomainError> {
panic!("PanicPosterFetcher called")
}
}
// ── PanicDiaryExporter ────────────────────────────────────────────────────────
pub struct PanicDiaryExporter;
#[async_trait]
impl DiaryExporter for PanicDiaryExporter {
async fn serialize_entries(
&self,
_entries: &[crate::models::DiaryEntry],
_format: ExportFormat,
) -> Result<Vec<u8>, DomainError> {
panic!("PanicDiaryExporter called")
}
}
// ── PanicDocumentParser ───────────────────────────────────────────────────────
pub struct PanicDocumentParser;
impl DocumentParser for PanicDocumentParser {
fn parse(&self, _bytes: &[u8], _format: FileFormat) -> Result<ParsedFile, ImportError> {
panic!("PanicDocumentParser called")
}
fn apply_mapping(&self, _file: &ParsedFile, _mappings: &[FieldMapping]) -> Vec<AnnotatedRow> {
panic!("PanicDocumentParser called")
}
}
// ── PanicProfileFieldsRepo ────────────────────────────────────────────────────
pub struct PanicProfileFieldsRepo;
#[async_trait]
impl UserProfileFieldsRepository for PanicProfileFieldsRepo {
async fn get_fields(
&self,
_user_id: &UserId,
) -> Result<Vec<crate::models::ProfileField>, DomainError> {
panic!("PanicProfileFieldsRepo called")
}
async fn set_fields(
&self,
_user_id: &UserId,
_fields: Vec<crate::models::ProfileField>,
) -> Result<(), DomainError> {
panic!("PanicProfileFieldsRepo called")
}
}

View File

@@ -0,0 +1,127 @@
use std::sync::Arc;
use anyhow::Context;
use domain::ports::{
AuthService, DiaryRepository, ImageStorage, ImportProfileRepository,
ImportSessionRepository, MetadataClient, MovieProfileRepository, MovieRepository,
PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient, ReviewRepository,
SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository, UserRepository,
WatchlistRepository,
};
pub struct DatabaseAdapters {
pub movie_repo: Arc<dyn MovieRepository>,
pub review_repo: Arc<dyn ReviewRepository>,
pub diary_repo: Arc<dyn DiaryRepository>,
pub stats_repo: Arc<dyn StatsRepository>,
pub user_repo: Arc<dyn UserRepository>,
pub import_session_repo: Arc<dyn ImportSessionRepository>,
pub import_profile_repo: Arc<dyn ImportProfileRepository>,
pub movie_profile_repo: Arc<dyn MovieProfileRepository>,
pub watchlist_repo: Arc<dyn WatchlistRepository>,
pub person_command: Arc<dyn PersonCommand>,
pub person_query: Arc<dyn PersonQuery>,
pub search_port: Arc<dyn SearchPort>,
pub search_command: Arc<dyn SearchCommand>,
pub profile_fields_repo: Arc<dyn UserProfileFieldsRepository>,
pub db_pool: DbPool,
}
pub enum DbPool {
#[cfg(feature = "sqlite")]
Sqlite(sqlx::SqlitePool),
#[cfg(feature = "postgres")]
Postgres(sqlx::PgPool),
}
pub async fn build_database_adapters(
backend: &str,
url: &str,
) -> anyhow::Result<DatabaseAdapters> {
match backend {
#[cfg(feature = "postgres")]
"postgres" => {
let (pool, m, r, d, s, u, is, ip, mp, wl) = postgres::wire(url)
.await
.context("PostgreSQL connection failed")?;
let (pc, pq) = postgres::create_person_adapter(pool.clone());
let (sc, sp) = postgres_search::create_search_adapter(pool.clone());
let pf = postgres::create_profile_fields_repo(pool.clone());
Ok(DatabaseAdapters {
movie_repo: m,
review_repo: r,
diary_repo: d,
stats_repo: s,
user_repo: u,
import_session_repo: is,
import_profile_repo: ip,
movie_profile_repo: mp,
watchlist_repo: wl,
person_command: pc,
person_query: pq,
search_port: sp,
search_command: sc,
profile_fields_repo: pf,
db_pool: DbPool::Postgres(pool),
})
}
#[cfg(feature = "sqlite")]
_ => {
let (pool, m, r, d, s, u, is, ip, mp, wl) = sqlite::wire(url)
.await
.context("SQLite connection failed")?;
let (pc, pq) = sqlite::create_person_adapter(pool.clone());
let (sc, sp) = sqlite_search::create_search_adapter(pool.clone());
let pf = sqlite::create_profile_fields_repo(pool.clone());
Ok(DatabaseAdapters {
movie_repo: m,
review_repo: r,
diary_repo: d,
stats_repo: s,
user_repo: u,
import_session_repo: is,
import_profile_repo: ip,
movie_profile_repo: mp,
watchlist_repo: wl,
person_command: pc,
person_query: pq,
search_port: sp,
search_command: sc,
profile_fields_repo: pf,
db_pool: DbPool::Sqlite(pool),
})
}
#[cfg(not(feature = "sqlite"))]
_ => anyhow::bail!(
"DATABASE_BACKEND={backend} is not supported by this build (enable sqlite or postgres feature)"
),
}
}
pub fn build_auth_adapters() -> anyhow::Result<(Arc<dyn AuthService>, Arc<dyn PasswordHasher>)> {
auth::create()
}
pub fn build_metadata_client() -> anyhow::Result<Arc<dyn MetadataClient>> {
metadata::create()
}
pub fn build_poster_fetcher() -> anyhow::Result<Arc<dyn PosterFetcherClient>> {
poster_fetcher::create()
}
pub fn build_image_storage() -> anyhow::Result<Arc<dyn ImageStorage>> {
image_storage::create()
}
pub fn build_profile_fields_repo(pool: &DbPool) -> anyhow::Result<Arc<dyn UserProfileFieldsRepository>> {
match pool {
#[cfg(feature = "postgres")]
DbPool::Postgres(pool) => Ok(postgres::create_profile_fields_repo(pool.clone())),
#[cfg(feature = "sqlite")]
DbPool::Sqlite(pool) => Ok(sqlite::create_profile_fields_repo(pool.clone())),
#[cfg(not(feature = "sqlite"))]
_ => anyhow::bail!("no profile fields repo for this backend"),
}
}

View File

@@ -1,6 +1,7 @@
pub mod csrf;
pub mod errors;
pub mod extractors;
pub mod factory;
pub mod forms;
pub mod handlers;
pub mod openapi;

View File

@@ -11,11 +11,9 @@ use importer::ImporterDocumentParser;
use rss::RssAdapter;
use template_askama::AskamaHtmlRenderer;
use presentation::{openapi, routes, state::AppState};
use presentation::{factory, openapi, routes, state::AppState};
use domain::ports::{
DiaryExporter, DocumentParser, EventPublisher, ImportProfileRepository, ImportSessionRepository,
};
use domain::ports::{DiaryExporter, DocumentParser, EventPublisher};
#[cfg(feature = "postgres")]
use postgres_search;
@@ -55,85 +53,28 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
let backend = std::env::var("DATABASE_BACKEND").unwrap_or_else(|_| "sqlite".to_string());
let (auth_service, password_hasher) = auth::create()?;
let metadata_client = metadata::create()?;
let poster_fetcher = poster_fetcher::create()?;
let image_storage = image_storage::create()?;
let (auth_service, password_hasher) = factory::build_auth_adapters()?;
let metadata_client = factory::build_metadata_client()?;
let poster_fetcher = factory::build_poster_fetcher()?;
let image_storage = factory::build_image_storage()?;
let (
movie_repository,
review_repository,
diary_repository,
stats_repository,
user_repository,
import_session_repository,
import_profile_repository,
movie_profile_repository,
watchlist_repository,
person_command,
person_query,
search_command,
search_port,
db_pool,
) = match backend.as_str() {
#[cfg(feature = "postgres")]
"postgres" => {
let (pool, m, r, d, s, u, is, ip, mp, wl) = postgres::wire(&database_url).await?;
let (pc, pq) = postgres::create_person_adapter(pool.clone());
let (sc, sp) = postgres_search::create_search_adapter(pool.clone());
(
m,
r,
d,
s,
u,
is,
ip,
mp,
wl,
pc,
pq,
sc,
sp,
DbPool::Postgres(pool),
)
}
#[cfg(feature = "sqlite")]
_ => {
let (pool, m, r, d, s, u, is, ip, mp, wl) = sqlite::wire(&database_url).await?;
let (pc, pq) = sqlite::create_person_adapter(pool.clone());
let (sc, sp) = sqlite_search::create_search_adapter(pool.clone());
(
m,
r,
d,
s,
u,
is,
ip,
mp,
wl,
pc,
pq,
sc,
sp,
DbPool::Sqlite(pool),
)
}
#[cfg(not(feature = "sqlite"))]
_ => anyhow::bail!(
"DATABASE_BACKEND={backend} is not supported by this build (sqlite feature is not enabled)"
),
};
let db = factory::build_database_adapters(&backend, &database_url).await?;
let profile_fields_repo = match &db_pool {
#[cfg(feature = "postgres")]
DbPool::Postgres(pool) => postgres::create_profile_fields_repo(pool.clone()),
#[cfg(feature = "sqlite")]
DbPool::Sqlite(pool) => sqlite::create_profile_fields_repo(pool.clone()),
#[cfg(not(feature = "sqlite"))]
_ => anyhow::bail!("no profile fields repo for this backend"),
};
let movie_repository = db.movie_repo;
let review_repository = db.review_repo;
let diary_repository = db.diary_repo;
let stats_repository = db.stats_repo;
let user_repository = db.user_repo;
let import_session_repository = db.import_session_repo;
let import_profile_repository = db.import_profile_repo;
let movie_profile_repository = db.movie_profile_repo;
let watchlist_repository = db.watchlist_repo;
let person_command = db.person_command;
let person_query = db.person_query;
let search_port = db.search_port;
let search_command = db.search_command;
let profile_fields_repo = db.profile_fields_repo;
let db_pool = db.db_pool;
// Wire up event channel, federation service, and ap_router
let event_bus = EventBusBackend::from_env()?;
@@ -143,9 +84,9 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
let (federation_repo, social_query_arc, review_store, remote_watchlist_repo) =
match &db_pool {
#[cfg(feature = "postgres-federation")]
DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()),
factory::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()),
#[cfg(feature = "sqlite-federation")]
DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()),
factory::DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()),
#[cfg(not(feature = "sqlite-federation"))]
_ => anyhow::bail!(
"DATABASE_BACKEND={backend} federation is not supported by this build"
@@ -157,12 +98,12 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
tracing::info!("event bus: DB queue");
match &db_pool {
#[cfg(feature = "postgres")]
DbPool::Postgres(pool) => {
factory::DbPool::Postgres(pool) => {
postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone())
.await?
}
#[cfg(feature = "sqlite")]
DbPool::Sqlite(pool) => {
factory::DbPool::Sqlite(pool) => {
sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await?
}
}
@@ -207,11 +148,11 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
tracing::info!("event bus: DB queue");
match &db_pool {
#[cfg(feature = "postgres")]
DbPool::Postgres(pool) => {
factory::DbPool::Postgres(pool) => {
postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone()).await?
}
#[cfg(feature = "sqlite")]
DbPool::Sqlite(pool) => {
factory::DbPool::Sqlite(pool) => {
sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await?
}
#[cfg(not(feature = "sqlite"))]
@@ -245,8 +186,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
auth_service,
password_hasher,
user_repository,
import_session_repository: import_session_repository as Arc<dyn ImportSessionRepository>,
import_profile_repository: import_profile_repository as Arc<dyn ImportProfileRepository>,
import_session_repository,
import_profile_repository,
movie_profile_repository,
watchlist_repository,
profile_fields_repository: profile_fields_repo,
@@ -273,13 +214,6 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
Ok((state, ap_router))
}
enum DbPool {
#[cfg(feature = "sqlite")]
Sqlite(sqlx::SqlitePool),
#[cfg(feature = "postgres")]
Postgres(sqlx::PgPool),
}
#[derive(Clone, Copy)]
enum EventBusBackend {
Db,