use std::sync::Arc; use application::{config::AppConfig, context::AppContext}; use async_trait::async_trait; use axum::{ Router, body::Body, http::{Request, StatusCode}, }; use domain::{ errors::DomainError, events::DomainEvent, models::{Movie, User, PersonId, Person, PersonCredits, EntityType, IndexableDocument, SearchQuery, SearchResults, ExternalPersonId}, ports::{ AuthService, EventPublisher, GeneratedToken, ImageStorage, MetadataClient, MetadataSearchCriteria, PasswordHasher, PosterFetcherClient, UserRepository, PersonCommand, PersonQuery, SearchPort, SearchCommand, }, 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 template_askama::AskamaHtmlRenderer; 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 PanicImageStorage; #[async_trait] impl ImageStorage for PanicImageStorage { async fn store(&self, _: &str, _: &[u8]) -> Result { panic!() } async fn get(&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, _: Option, _: Option, _: Option, _: Option) -> 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, 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 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 { movie_repository: Arc::clone(&repo) as _, review_repository: Arc::clone(&repo) as _, diary_repository: Arc::clone(&repo) as _, diary_exporter: Arc::new(PanicExporter), document_parser: Arc::new(PanicDocumentParser), stats_repository: Arc::clone(&repo) as _, metadata_client: Arc::new(PanicMeta), poster_fetcher: Arc::new(PanicFetcher), image_storage: Arc::new(PanicImageStorage), event_publisher: Arc::new(NoopEventPublisher), auth_service: Arc::new(PanicAuth), password_hasher: Arc::new(PanicHasher), user_repository: Arc::new(NobodyUserRepo), import_session_repository: Arc::new(PanicImportSession), import_profile_repository: Arc::new(PanicImportProfile), movie_profile_repository: Arc::new(PanicMovieProfile), watchlist_repository: Arc::new(PanicWatchlist), profile_fields_repository: Arc::new(PanicProfileFields), #[cfg(feature = "federation")] remote_watchlist_repository: Arc::new(PanicRemoteWatchlist), person_command: Arc::new(PanicPersonCommand), person_query: Arc::new(PanicPersonQuery), search_port: Arc::new(PanicSearchPort), search_command: Arc::new(PanicSearchCommand), config: AppConfig { allow_registration: false, base_url: "http://localhost:3000".to_string(), rate_limit: 20, }, }, html_renderer: Arc::new(AskamaHtmlRenderer::new()), 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); }