use std::sync::Arc; use application::{ config::AppConfig, context::{AppContext, Repositories, Services}, }; use async_trait::async_trait; use axum::{ Router, body::Body, http::{Request, StatusCode}, }; use domain::{ errors::DomainError, events::DomainEvent, models::{ EntityType, ExternalPersonId, IndexableDocument, Movie, Person, PersonCredits, PersonId, SearchQuery, SearchResults, User, }, ports::{ AuthService, EventPublisher, GeneratedToken, MetadataClient, MetadataSearchCriteria, ObjectStorage, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient, SearchCommand, SearchPort, UserRepository, }, value_objects::{Email, ExternalMetadataId, PasswordHash, PosterUrl, UserId}, }; use http_body_util::BodyExt; use presentation::{routes, state::AppState}; use rss::RssAdapter; use sqlite::SqliteMovieRepository; use sqlx::SqlitePool; use tower::ServiceExt; struct NoopEventPublisher; #[async_trait] impl EventPublisher for NoopEventPublisher { async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> { Ok(()) } } struct PanicMeta; #[async_trait] impl MetadataClient for PanicMeta { async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result { panic!("metadata not wired in tests") } async fn get_poster_url( &self, _: &ExternalMetadataId, ) -> Result, DomainError> { panic!() } } struct PanicFetcher; #[async_trait] impl PosterFetcherClient for PanicFetcher { async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result, DomainError> { panic!() } } struct PanicObjectStorage; #[async_trait] impl ObjectStorage for PanicObjectStorage { async fn store(&self, _: &str, _: &[u8]) -> Result { panic!() } async fn get(&self, _: &str) -> Result, DomainError> { panic!() } async fn get_stream( &self, _: &str, ) -> Result>, DomainError> { panic!() } async fn delete(&self, _: &str) -> Result<(), DomainError> { panic!() } } struct PanicHasher; #[async_trait] impl PasswordHasher for PanicHasher { async fn hash(&self, _: &str) -> Result { panic!() } async fn verify(&self, _: &str, _: &PasswordHash) -> Result { panic!() } } struct PanicAuth; #[async_trait] impl AuthService for PanicAuth { async fn generate_token(&self, _: &UserId) -> Result { panic!() } async fn validate_token(&self, _: &str) -> Result { panic!() } } struct NobodyUserRepo; #[async_trait] impl UserRepository for NobodyUserRepo { async fn find_by_email(&self, _: &Email) -> Result, DomainError> { Ok(None) } async fn find_by_username( &self, _: &domain::value_objects::Username, ) -> Result, DomainError> { Ok(None) } async fn save(&self, _: &User) -> Result<(), DomainError> { panic!() } async fn find_by_id(&self, _: &UserId) -> Result, DomainError> { panic!() } async fn list_with_stats(&self) -> Result, DomainError> { panic!() } async fn update_profile( &self, _: &UserId, _: &domain::models::UserProfile, ) -> Result<(), DomainError> { Ok(()) } } struct PanicProfileFields; #[async_trait] impl domain::ports::UserProfileFieldsRepository for PanicProfileFields { async fn get_fields( &self, _: &UserId, ) -> Result, DomainError> { Ok(vec![]) } async fn set_fields( &self, _: &UserId, _: Vec, ) -> Result<(), DomainError> { panic!() } } struct PanicExporter; #[async_trait] impl domain::ports::DiaryExporter for PanicExporter { async fn serialize_entries( &self, _: &[domain::models::DiaryEntry], _: domain::models::ExportFormat, ) -> Result, DomainError> { panic!() } } struct PanicImportSession; #[async_trait] impl domain::ports::ImportSessionRepository for PanicImportSession { async fn create(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { panic!() } async fn get( &self, _: &domain::value_objects::ImportSessionId, _: &UserId, ) -> Result, DomainError> { panic!() } async fn update(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { panic!() } async fn delete(&self, _: &domain::value_objects::ImportSessionId) -> Result<(), DomainError> { panic!() } async fn delete_expired(&self) -> Result { panic!() } async fn delete_expired_for_user(&self, _: &UserId) -> Result<(), DomainError> { panic!() } } struct PanicDocumentParser; impl domain::ports::DocumentParser for PanicDocumentParser { fn parse( &self, _: &[u8], _: domain::models::FileFormat, ) -> Result { panic!("DocumentParser not wired in tests") } fn apply_mapping( &self, _: &domain::models::ParsedFile, _: &[domain::models::FieldMapping], ) -> Vec { panic!("DocumentParser not wired in tests") } } struct PanicImportProfile; struct PanicMovieProfile; #[async_trait] impl domain::ports::MovieProfileRepository for PanicMovieProfile { async fn upsert(&self, _: &domain::models::MovieProfile) -> Result<(), DomainError> { panic!() } async fn get_by_movie_id( &self, _: &domain::value_objects::MovieId, ) -> Result, DomainError> { Ok(None) } async fn list_stale( &self, ) -> Result, DomainError> { Ok(vec![]) } } #[async_trait] impl domain::ports::ImportProfileRepository for PanicImportProfile { async fn save(&self, _: &domain::models::ImportProfile) -> Result<(), DomainError> { panic!() } async fn list_for_user( &self, _: &UserId, ) -> Result, DomainError> { panic!() } async fn get( &self, _: &domain::value_objects::ImportProfileId, _: &UserId, ) -> Result, DomainError> { panic!() } async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { panic!() } } struct PanicWatchlist; #[async_trait] impl domain::ports::WatchlistRepository for PanicWatchlist { async fn add(&self, _: &domain::models::WatchlistEntry) -> Result<(), DomainError> { panic!() } async fn remove( &self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId, ) -> Result<(), DomainError> { panic!() } async fn remove_if_present( &self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId, ) -> Result { Ok(false) } async fn get_for_user( &self, _: &domain::value_objects::UserId, _: &domain::models::collections::PageParams, ) -> Result< domain::models::collections::Paginated, DomainError, > { panic!() } async fn contains( &self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId, ) -> Result { Ok(false) } } struct PanicPersonCommand; #[async_trait] impl PersonCommand for PanicPersonCommand { async fn upsert_batch(&self, _: &[Person]) -> Result<(), DomainError> { panic!() } } struct PanicPersonQuery; #[async_trait] impl PersonQuery for PanicPersonQuery { async fn get_by_id(&self, _: &PersonId) -> Result, DomainError> { panic!() } async fn get_by_external_id( &self, _: &ExternalPersonId, ) -> Result, DomainError> { panic!() } async fn get_credits(&self, _: &PersonId) -> Result { panic!() } async fn list_orphaned_persons(&self) -> Result, DomainError> { panic!() } } struct PanicSearchPort; #[async_trait] impl SearchPort for PanicSearchPort { async fn search(&self, _: &SearchQuery) -> Result { panic!() } } struct PanicSearchCommand; #[async_trait] impl SearchCommand for PanicSearchCommand { async fn index(&self, _: IndexableDocument) -> Result<(), DomainError> { panic!() } async fn remove(&self, _: EntityType, _: &str) -> Result<(), DomainError> { panic!() } } #[cfg(feature = "federation")] struct PanicSocialQuery; #[cfg(feature = "federation")] struct PanicRemoteWatchlist; #[cfg(feature = "federation")] #[async_trait::async_trait] impl domain::ports::RemoteWatchlistRepository for PanicRemoteWatchlist { async fn save(&self, _: domain::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, DomainError> { Ok(vec![]) } async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> { Ok(()) } async fn get_by_derived_uuid( &self, _: uuid::Uuid, ) -> Result, DomainError> { Ok(vec![]) } } #[cfg(feature = "federation")] #[async_trait::async_trait] impl domain::ports::SocialQueryPort for PanicSocialQuery { async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result, DomainError> { panic!() } async fn list_all_followed_remote_actors( &self, ) -> Result, DomainError> { panic!() } async fn count_following(&self, _: uuid::Uuid) -> Result { panic!() } async fn count_accepted_followers(&self, _: uuid::Uuid) -> Result { panic!() } async fn get_pending_followers( &self, _: uuid::Uuid, ) -> Result, DomainError> { panic!() } } async fn test_app() -> Router { let pool = SqlitePool::connect("sqlite::memory:") .await .expect("in-memory SQLite failed"); let repo = SqliteMovieRepository::new(pool); repo.migrate().await.expect("migration failed"); let repo = Arc::new(repo); let state = AppState { app_ctx: AppContext { repos: Repositories { movie: Arc::clone(&repo) as _, review: Arc::clone(&repo) as _, diary: Arc::clone(&repo) as _, stats: Arc::clone(&repo) as _, user: Arc::new(NobodyUserRepo), import_session: Arc::new(PanicImportSession), import_profile: Arc::new(PanicImportProfile), movie_profile: Arc::new(PanicMovieProfile), watchlist: Arc::new(PanicWatchlist), watch_event: Arc::new(domain::testing::PanicWatchEventRepository), webhook_token: Arc::new(domain::testing::PanicWebhookTokenRepository), profile_fields: Arc::new(PanicProfileFields), person_command: Arc::new(PanicPersonCommand), person_query: Arc::new(PanicPersonQuery), search_port: Arc::new(PanicSearchPort), search_command: Arc::new(PanicSearchCommand), remote_watchlist: Arc::new(PanicRemoteWatchlist), social_query: Arc::new(PanicSocialQuery), wrapup_stats: Arc::new(domain::testing::PanicWrapUpStatsQuery) as _, wrapup_repo: Arc::new(domain::testing::PanicWrapUpRepository) as _, }, services: Services { auth: Arc::new(PanicAuth), password_hasher: Arc::new(PanicHasher), metadata: Arc::new(PanicMeta), poster_fetcher: Arc::new(PanicFetcher), object_storage: Arc::new(PanicObjectStorage), event_publisher: Arc::new(NoopEventPublisher), diary_exporter: Arc::new(PanicExporter), document_parser: Arc::new(PanicDocumentParser), video_renderer: None, }, config: AppConfig { allow_registration: false, base_url: "http://localhost:3000".to_string(), rate_limit: 20, wrapup: application::config::WrapUpConfig { font_path: None, logo_path: None, bg_dir: None, ffmpeg_path: "ffmpeg".into(), max_concurrent_renders: 2, }, }, }, rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())), #[cfg(feature = "federation")] ap_service: Arc::new(activitypub::NoopActivityPubService), #[cfg(feature = "federation")] social_query: Arc::new(PanicSocialQuery), }; routes::build_router(state, axum::Router::new()) } /// Inject a fake peer IP so the GovernorLayer can extract ConnectInfo. fn with_ip(req: Request) -> Request { let addr: std::net::SocketAddr = "127.0.0.1:12345".parse().unwrap(); let mut req = req; req.extensions_mut() .insert(axum::extract::ConnectInfo::(addr)); req } #[tokio::test] async fn get_api_diary_returns_empty_list() { let app = test_app().await; let response = app .oneshot(with_ip( Request::builder() .uri("/api/v1/diary") .body(Body::empty()) .unwrap(), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let bytes = response.into_body().collect().await.unwrap().to_bytes(); let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(json["total_count"], 0); assert_eq!(json["items"], serde_json::json!([])); assert_eq!(json["limit"], 5); assert_eq!(json["offset"], 0); } #[tokio::test] async fn post_api_reviews_without_auth_returns_401() { let app = test_app().await; let response = app .oneshot(with_ip( Request::builder() .method("POST") .uri("/api/v1/reviews") .header("content-type", "application/json") .body(Body::from( r#"{"rating":4,"watched_at":"2026-01-01T20:00:00","manual_title":"Dune","manual_release_year":2021}"#, )) .unwrap(), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn post_api_auth_login_unknown_user_returns_401() { let app = test_app().await; let response = app .oneshot(with_ip( Request::builder() .method("POST") .uri("/api/v1/auth/login") .header("content-type", "application/json") .body(Body::from(r#"{"email":"a@b.com","password":"x"}"#)) .unwrap(), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn get_api_movie_detail_returns_404_for_unknown_id() { let app = test_app().await; let response = app .oneshot(with_ip( Request::builder() .uri("/api/v1/movies/00000000-0000-0000-0000-000000000000") .body(Body::empty()) .unwrap(), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn tags_moviesdiary_redirects_to_home() { let app = test_app().await; let response = app .oneshot(with_ip( Request::builder() .uri("/tags/moviesdiary") .body(Body::empty()) .unwrap(), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); assert_eq!(response.headers().get("location").unwrap(), "/"); } #[tokio::test] async fn tags_other_redirects_to_search() { let app = test_app().await; let response = app .oneshot(with_ip( Request::builder() .uri("/tags/batman") .body(Body::empty()) .unwrap(), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); assert_eq!( response.headers().get("location").unwrap(), "/?search=batman" ); } #[tokio::test] async fn get_movie_detail_html_returns_404_for_unknown_id() { let app = test_app().await; let response = app .oneshot(with_ip( Request::builder() .uri("/movies/00000000-0000-0000-0000-000000000000") .body(Body::empty()) .unwrap(), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); }