diff --git a/Cargo.lock b/Cargo.lock index 754ef1e4..49d019f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4929,25 +4929,12 @@ name = "server" version = "0.1.0" dependencies = [ "anyhow", - "application", - "async-trait", "axum", "bootstrap", - "bytes", "config", - "domain", "dotenvy", - "futures", - "http-axum", - "http-body-util", - "rss 0.1.0", - "serde_json", - "sqlite", - "sqlx", "tokio", - "tower", "tracing", - "uuid", ] [[package]] diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml index c9e21232..bb15f275 100644 --- a/crates/bootstrap/Cargo.toml +++ b/crates/bootstrap/Cargo.toml @@ -43,7 +43,6 @@ sqlite-federation = { workspace = true, optional = true } [dev-dependencies] chrono = { workspace = true } -domain = { workspace = true, features = ["test-helpers"] } uuid = { workspace = true } serde_json = { workspace = true } tower = { version = "0.5", features = ["util"] } diff --git a/crates/bootstrap/src/lib.rs b/crates/bootstrap/src/lib.rs index 12533284..5ac674ef 100644 --- a/crates/bootstrap/src/lib.rs +++ b/crates/bootstrap/src/lib.rs @@ -21,8 +21,4 @@ pub use event_bus::EventBusBackend; pub use repositories::Repositories; pub use server::{ServerApp, build_server, build_server_on}; pub use tracing_setup::init_tracing; -pub use worker::{WorkerApp, build_worker}; - -#[cfg(test)] -#[path = "tests/build.rs"] -mod build_tests; +pub use worker::{WorkerApp, build_worker, build_worker_on}; diff --git a/crates/bootstrap/src/tests/build.rs b/crates/bootstrap/src/tests/build.rs deleted file mode 100644 index f88cf6b8..00000000 --- a/crates/bootstrap/src/tests/build.rs +++ /dev/null @@ -1,1074 +0,0 @@ -use std::sync::Arc; - -use application::config::AppConfig; -use domain::testing::{ - FakeAuthService, FakeDocumentParser, FakeMetadataClient, FakePasswordHasher, FakePersonQuery, - FakePosterFetcher, FakeSearchCommand, FakeSearchPort, FakeStatsRepository, - InMemoryInsightsQuery, InMemoryStore, InMemoryWrapUpRepository, InMemoryWrapUpStatsQuery, - NoopEventPublisher, NoopFederationAdminQuery, NoopObjectStorage, NoopRemoteGoalRepository, - NoopRemoteWatchlistRepository, PanicDiaryExporter, PanicPersonCommand, -}; - -use crate::{DatabaseOutput, Repositories, build::build_deps, build::build_worker_deps}; - -struct NoopReviewLogger; - -#[async_trait::async_trait] -impl application::ports::ReviewLogger for NoopReviewLogger { - async fn log_review( - &self, - _cmd: application::diary::commands::LogReviewCommand, - ) -> Result<(), domain::errors::DomainError> { - Ok(()) - } -} - -struct NoopMovieDeduplicator; - -#[async_trait::async_trait] -impl domain::ports::MovieDeduplicator for NoopMovieDeduplicator { - async fn merge_into_canonical( - &self, - _old_id: &domain::value_objects::MovieId, - _canonical: &domain::models::Movie, - ) -> Result { - Ok(0) - } -} - -struct NoopLocalApContentQuery; - -#[async_trait::async_trait] -impl domain::ports::LocalApContentQuery for NoopLocalApContentQuery { - async fn get_local_watchlist_for_user( - &self, - _user_id: &domain::value_objects::UserId, - ) -> Result, domain::errors::DomainError> { - Ok(vec![]) - } - - async fn get_local_reviews_for_movie( - &self, - _movie_id: &domain::value_objects::MovieId, - ) -> Result, domain::errors::DomainError> { - Ok(vec![]) - } - - async fn get_local_reviews_page( - &self, - _user_id: &domain::value_objects::UserId, - _before: Option, - _limit: usize, - ) -> Result, domain::errors::DomainError> { - Ok(vec![]) - } -} - -struct NoopImageRefCommand; - -#[async_trait::async_trait] -impl domain::ports::ImageRefCommand for NoopImageRefCommand { - async fn swap( - &self, - _old_key: &str, - _new_key: &str, - ) -> Result<(), domain::errors::DomainError> { - Ok(()) - } -} - -struct NoopImageRefQuery; - -#[async_trait::async_trait] -impl domain::ports::ImageRefQuery for NoopImageRefQuery { - async fn list_keys(&self) -> Result, domain::errors::DomainError> { - Ok(vec![]) - } -} - -/// `DatabaseOutput::follow_command`/`follow_query` need a fake independent of -/// `InMemoryStore` (domain::testing), which implements -/// `SocialCommand`/`FollowGraphQuery`/`BlockQuery` — the composite ports these -/// two feed, one level up — so it cannot stand in as their dependency. These -/// fixture fields are never exercised by `build_deps`/`build_worker_deps` -/// (neither wires them further), so the bodies just need to type-check. -struct NoopFollowCommand; - -#[async_trait::async_trait] -impl domain::ports::FollowCommand for NoopFollowCommand { - async fn add_follow( - &self, - _follower_id: uuid::Uuid, - _target_actor_url: &str, - _status: domain::value_objects::FollowStatus, - ) -> Result<(), domain::errors::DomainError> { - Ok(()) - } - - async fn update_follow_status( - &self, - _follower_id: uuid::Uuid, - _target_actor_url: &str, - _status: domain::value_objects::FollowStatus, - ) -> Result<(), domain::errors::DomainError> { - Ok(()) - } - - async fn remove_follow( - &self, - _follower_id: uuid::Uuid, - _target_actor_url: &str, - ) -> Result<(), domain::errors::DomainError> { - Ok(()) - } - - async fn add_follower( - &self, - _local_user_id: uuid::Uuid, - _follower_actor_url: &str, - _status: domain::value_objects::FollowStatus, - ) -> Result<(), domain::errors::DomainError> { - Ok(()) - } - - async fn update_follower_status( - &self, - _local_user_id: uuid::Uuid, - _follower_actor_url: &str, - _status: domain::value_objects::FollowStatus, - ) -> Result<(), domain::errors::DomainError> { - Ok(()) - } - - async fn remove_follower_record( - &self, - _local_user_id: uuid::Uuid, - _follower_actor_url: &str, - ) -> Result<(), domain::errors::DomainError> { - Ok(()) - } -} - -struct NoopFollowQuery; - -#[async_trait::async_trait] -impl domain::ports::FollowQuery for NoopFollowQuery { - async fn get_following( - &self, - _user_id: uuid::Uuid, - ) -> Result, domain::errors::DomainError> { - Ok(vec![]) - } - - async fn get_followers( - &self, - _user_id: uuid::Uuid, - ) -> Result, domain::errors::DomainError> { - Ok(vec![]) - } - - async fn get_pending_followers( - &self, - _user_id: uuid::Uuid, - ) -> Result, domain::errors::DomainError> { - Ok(vec![]) - } - - async fn get_pending_following( - &self, - _user_id: uuid::Uuid, - ) -> Result, domain::errors::DomainError> { - Ok(vec![]) - } - - async fn count_following( - &self, - _user_id: uuid::Uuid, - ) -> Result { - Ok(0) - } - - async fn count_followers( - &self, - _user_id: uuid::Uuid, - ) -> Result { - Ok(0) - } - - async fn count_pending_followers( - &self, - _user_id: uuid::Uuid, - ) -> Result { - Ok(0) - } - - async fn get_relation( - &self, - _viewer_id: uuid::Uuid, - _target_actor_url: &str, - ) -> Result { - // Empty/no-edge, matching every sibling method's empty-success shape above — - // unlike them this isn't exercised by any test, so there's no behavior to pin, - // just consistency for a future reader. - Ok(domain::value_objects::FollowRelation { - following: None, - followed_by: None, - }) - } -} - -fn test_repositories() -> Repositories { - let movies = InMemoryStore::new(); - let watch_events = Arc::clone(&movies); - let goals = Arc::clone(&movies); - let social = Arc::clone(&movies); - - Repositories { - movie_command: Arc::clone(&movies) as _, - movie_query: movies as _, - review: InMemoryStore::new(), - diary: domain::testing::FakeDiaryQuery::new(), - stats: FakeStatsRepository::new(), - user: InMemoryStore::new(), - import_session: InMemoryStore::new(), - import_profile: InMemoryStore::new(), - movie_profile: InMemoryStore::new(), - watchlist: InMemoryStore::new(), - watch_event_command: Arc::clone(&watch_events) as _, - watch_event_query: watch_events as _, - webhook_token: InMemoryStore::new(), - person_command: Arc::new(PanicPersonCommand), - person_query: Arc::new(FakePersonQuery), - search_port: Arc::new(FakeSearchPort), - search_command: Arc::new(FakeSearchCommand), - profile_fields: InMemoryStore::new(), - remote_watchlist: Arc::new(NoopRemoteWatchlistRepository), - social_command: Arc::clone(&social) as _, - follow_graph: Arc::clone(&social) as _, - block_query: Arc::clone(&social) as _, - federation_admin: Arc::new(NoopFederationAdminQuery), - wrapup_stats: InMemoryWrapUpStatsQuery::new(), - wrapup_repo: InMemoryWrapUpRepository::new(), - insights: InMemoryInsightsQuery::new(), - goal_command: Arc::clone(&goals) as _, - goal_query: goals as _, - user_settings: InMemoryStore::new(), - remote_goal: Arc::new(NoopRemoteGoalRepository), - refresh_session: InMemoryStore::new(), - federated_profile: None, - } -} - -/// A lazily-connected in-memory sqlite pool — never actually opened in this test, -/// just a value to satisfy `DatabaseOutput::db_pool`. -fn test_db_pool() -> crate::DbPool { - let pool = sqlx::sqlite::SqlitePoolOptions::new() - .connect_lazy("sqlite::memory:") - .expect("lazy sqlite pool"); - crate::DbPool::Sqlite(pool) -} - -fn test_database_output() -> DatabaseOutput { - let movies = InMemoryStore::new(); - let watch_events = Arc::clone(&movies); - let goals = Arc::clone(&movies); - let user_settings = Arc::clone(&movies); - - DatabaseOutput { - movie_command: Arc::clone(&movies) as _, - movie_query: movies as _, - review: InMemoryStore::new(), - diary: domain::testing::FakeDiaryQuery::new(), - stats: FakeStatsRepository::new(), - user: InMemoryStore::new(), - import_session: InMemoryStore::new(), - import_profile: InMemoryStore::new(), - movie_profile: InMemoryStore::new(), - watchlist: InMemoryStore::new(), - watch_event_command: Arc::clone(&watch_events) as _, - watch_event_query: watch_events as _, - webhook_token: InMemoryStore::new(), - person_command: Arc::new(PanicPersonCommand), - person_query: Arc::new(FakePersonQuery), - search_port: Arc::new(FakeSearchPort), - search_command: Arc::new(FakeSearchCommand), - profile_fields: InMemoryStore::new(), - ap_content: Arc::new(NoopLocalApContentQuery), - wrapup_stats: InMemoryWrapUpStatsQuery::new(), - wrapup_repo: InMemoryWrapUpRepository::new(), - insights: InMemoryInsightsQuery::new(), - goal_command: Arc::clone(&goals) as _, - goal_query: goals as _, - user_settings: Arc::clone(&user_settings) as _, - federation_settings: user_settings as _, - remote_goal: Arc::new(NoopRemoteGoalRepository), - refresh_session: InMemoryStore::new(), - deduplicator: Arc::new(NoopMovieDeduplicator), - image_ref_command: Arc::new(NoopImageRefCommand), - image_ref_query: Arc::new(NoopImageRefQuery), - follow_command: Arc::new(NoopFollowCommand), - follow_query: Arc::new(NoopFollowQuery), - db_pool: test_db_pool(), - } -} - -fn test_services() -> application::Services { - application::Services { - auth: Arc::new(FakeAuthService), - password_hasher: Arc::new(FakePasswordHasher), - metadata: Arc::new(FakeMetadataClient), - poster_fetcher: Arc::new(FakePosterFetcher), - object_storage: Arc::new(NoopObjectStorage), - event_publisher: NoopEventPublisher::new(), - diary_exporter: Arc::new(PanicDiaryExporter), - document_parser: Arc::new(FakeDocumentParser), - review_logger: Arc::new(NoopReviewLogger), - person_enrichment: None, - } -} - -fn test_worker_services() -> application::WorkerServices { - application::WorkerServices { - object_storage: Arc::new(NoopObjectStorage), - event_publisher: NoopEventPublisher::new(), - person_enrichment: None, - } -} - -fn test_config() -> AppConfig { - AppConfig { - allow_registration: true, - base_url: "http://localhost:3000".into(), - rate_limit: 20, - refresh_ttl_seconds: 2_592_000, - secure_cookies: false, - cors_origins: String::new(), - wrapup: application::config::WrapUpConfig { - font_path: None, - logo_path: None, - bg_dir: None, - }, - } -} - -/// The composition root must produce a Deps whose narrow structs point at the -/// same Arcs it was given. Proven by pointer identity on one representative -/// field per group boundary — if wiring is crossed, these differ. -#[test] -fn build_deps_wires_the_given_arcs() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - - let deps = build_deps(&repos, &services, &config, &instance); - - assert!( - std::sync::Arc::ptr_eq(&deps.users.get_local_profile.user, &repos.user), - "get_local_profile.user must be the repositories' user repo" - ); - assert!( - std::sync::Arc::ptr_eq(&deps.social.query.follow_graph, &repos.follow_graph), - "social.query.follow_graph must be the repositories' follow_graph" - ); - assert_eq!(deps.users.get_local_profile.instance, instance); - - assert!( - std::sync::Arc::ptr_eq( - &deps.social.command.event_publisher, - &services.event_publisher - ), - "social.command.event_publisher must be the services' event_publisher" - ); -} - -// The test above covers one representative field per group boundary. The tests below -// give every one of the 11 groups its own coverage: at least one `Arc::ptr_eq` per -// struct in the group, favoring fields most likely to get silently crossed in a future -// edit — same-prefixed fields reused across sibling structs in the group (e.g. -// `wrapup_repo`/`wrapup_stats`, `watch_event_command`/`watch_event_query`), and the two -// name-mismatched `social_query` fields that read like they should come from a -// `social_query` repo but must come from `repos.follow_graph`. - -#[test] -fn auth_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - assert!(Arc::ptr_eq(&deps.auth.login.user, &repos.user)); - assert!(Arc::ptr_eq(&deps.auth.login.auth, &services.auth)); - assert!(Arc::ptr_eq( - &deps.auth.login.refresh_session, - &repos.refresh_session - )); - assert!(Arc::ptr_eq( - &deps.auth.register.password_hasher, - &services.password_hasher - )); - // login and refresh both carry refresh_session + auth — confirm the second struct - // gets the same repo/service Arcs independently, not a stray clone of something else. - assert!(Arc::ptr_eq( - &deps.auth.refresh.refresh_session, - &repos.refresh_session - )); - assert!(Arc::ptr_eq(&deps.auth.refresh.auth, &services.auth)); - assert!(Arc::ptr_eq(&deps.auth.register_and_login.user, &repos.user)); - assert_eq!(deps.auth.login.config.base_url, config.base_url); - // logout shares refresh_session with login/refresh — confirm it's wired - // independently to the same repo Arc. - assert!(Arc::ptr_eq( - &deps.auth.logout.refresh_session, - &repos.refresh_session - )); -} - -#[test] -fn diary_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - assert!(Arc::ptr_eq(&deps.diary.delete_review.review, &repos.review)); - assert!(Arc::ptr_eq(&deps.diary.delete_review.diary, &repos.diary)); - assert!(Arc::ptr_eq( - &deps.diary.delete_review.movie_command, - &repos.movie_command - )); - assert!(Arc::ptr_eq( - &deps.diary.delete_review.event_publisher, - &services.event_publisher - )); - // edit_review also carries `review` — confirm it's independently wired, not left - // pointing at delete_review's clone by accident. - assert!(Arc::ptr_eq(&deps.diary.edit_review.review, &repos.review)); - assert!(Arc::ptr_eq( - &deps.diary.get_movie_social_page.movie_query, - &repos.movie_query - )); - assert!(Arc::ptr_eq( - &deps.diary.get_movie_social_page.movie_profile, - &repos.movie_profile - )); - // Name mismatch: the field is called `social_query` but there is no - // `repos.social_query` — it must come from `repos.follow_graph`. - assert!(Arc::ptr_eq( - &deps.diary.get_activity_feed.social_query, - &repos.follow_graph - )); - assert_eq!( - deps.diary.get_activity_feed.config.base_url, - config.base_url - ); - assert!(Arc::ptr_eq(&deps.diary.get_user_feed.user, &repos.user)); - assert!(Arc::ptr_eq(&deps.diary.get_user_feed.diary, &repos.diary)); - assert!(Arc::ptr_eq(&deps.diary.get_diary.diary, &repos.diary)); - assert!(Arc::ptr_eq( - &deps.diary.get_review_history.diary, - &repos.diary - )); - assert!(Arc::ptr_eq(&deps.diary.export_diary.diary, &repos.diary)); - assert!(Arc::ptr_eq( - &deps.diary.export_diary.diary_exporter, - &services.diary_exporter - )); -} - -#[test] -fn goals_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - assert!(Arc::ptr_eq( - &deps.goals.command.goal_command, - &repos.goal_command - )); - assert!(Arc::ptr_eq( - &deps.goals.command.event_publisher, - &services.event_publisher - )); - // goal_query and stats each appear in both command and query — confirm both - // structs land on the same underlying repo Arc rather than diverging. - assert!(Arc::ptr_eq( - &deps.goals.command.goal_query, - &repos.goal_query - )); - assert!(Arc::ptr_eq(&deps.goals.query.goal_query, &repos.goal_query)); - assert!(Arc::ptr_eq(&deps.goals.command.stats, &repos.stats)); - assert!(Arc::ptr_eq(&deps.goals.query.stats, &repos.stats)); -} - -#[test] -fn import_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - // document_parser (a service) is shared by create_session and apply_mapping — - // confirm both point at the same Arc, not two independently-sourced ones. - assert!(Arc::ptr_eq( - &deps.import.create_session.document_parser, - &services.document_parser - )); - assert!(Arc::ptr_eq( - &deps.import.apply_mapping.document_parser, - &services.document_parser - )); - assert!(Arc::ptr_eq( - &deps.import.apply_mapping.movie_query, - &repos.movie_query - )); - // import_session/import_profile recur across apply_profile and save_profile — - // confirm both fields land correctly in both structs. - assert!(Arc::ptr_eq( - &deps.import.apply_profile.import_profile, - &repos.import_profile - )); - assert!(Arc::ptr_eq( - &deps.import.apply_profile.import_session, - &repos.import_session - )); - assert!(Arc::ptr_eq( - &deps.import.save_profile.import_session, - &repos.import_session - )); - assert!(Arc::ptr_eq( - &deps.import.save_profile.import_profile, - &repos.import_profile - )); - assert!(Arc::ptr_eq( - &deps.import.execute_import.review_logger, - &services.review_logger - )); - - // The four stage-gating/orchestration use cases added alongside the - // handler-bypass removal — each field asserted individually since several - // recur across siblings above (import_session, movie_query, document_parser). - assert!(Arc::ptr_eq( - &deps.import.get_mapping_stage.import_session, - &repos.import_session - )); - assert!(Arc::ptr_eq( - &deps.import.get_preview_stage.import_session, - &repos.import_session - )); - assert!(Arc::ptr_eq( - &deps.import.get_session_state.import_session, - &repos.import_session - )); - assert!(Arc::ptr_eq( - &deps.import.apply_profile_and_map.import_profile, - &repos.import_profile - )); - assert!(Arc::ptr_eq( - &deps.import.apply_profile_and_map.import_session, - &repos.import_session - )); - assert!(Arc::ptr_eq( - &deps.import.apply_profile_and_map.document_parser, - &services.document_parser - )); - assert!(Arc::ptr_eq( - &deps.import.apply_profile_and_map.movie_query, - &repos.movie_query - )); - // import_profile recurs again here — confirm both new use cases are wired - // independently rather than sharing apply_profile's/save_profile's clone. - assert!(Arc::ptr_eq( - &deps.import.delete_profile.import_profile, - &repos.import_profile - )); - assert!(Arc::ptr_eq( - &deps.import.list_profiles.import_profile, - &repos.import_profile - )); -} - -#[test] -fn integrations_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - // watch_event_command and watch_event_query are the classic same-prefix pair most - // at risk of being swapped by a future reader. - assert!(Arc::ptr_eq( - &deps.integrations.ingest_watch_event.watch_event_command, - &repos.watch_event_command - )); - assert!(Arc::ptr_eq( - &deps.integrations.ingest_watch_event.watch_event_query, - &repos.watch_event_query - )); - assert!(Arc::ptr_eq( - &deps.integrations.ingest_watch_event.webhook_token, - &repos.webhook_token - )); - assert!(Arc::ptr_eq( - &deps.integrations.ingest_watch_event.event_publisher, - &services.event_publisher - )); - assert!(Arc::ptr_eq( - &deps.integrations.confirm_watch_events.watch_event_command, - &repos.watch_event_command - )); - assert!(Arc::ptr_eq( - &deps.integrations.confirm_watch_events.watch_event_query, - &repos.watch_event_query - )); - assert!(Arc::ptr_eq( - &deps.integrations.confirm_watch_events.review_logger, - &services.review_logger - )); - assert!(Arc::ptr_eq( - &deps.integrations.dismiss_watch_events.watch_event_command, - &repos.watch_event_command - )); - assert!(Arc::ptr_eq( - &deps.integrations.dismiss_watch_events.watch_event_query, - &repos.watch_event_query - )); - assert!(Arc::ptr_eq( - &deps.integrations.generate_webhook_token.webhook_token, - &repos.webhook_token - )); - assert!(Arc::ptr_eq( - &deps.integrations.get_watch_queue.watch_event_query, - &repos.watch_event_query - )); - assert!(Arc::ptr_eq( - &deps.integrations.get_webhook_tokens.webhook_token, - &repos.webhook_token - )); - assert!(Arc::ptr_eq( - &deps.integrations.revoke_webhook_token.webhook_token, - &repos.webhook_token - )); -} - -/// MoviesGroup was the reviewer's worked example of the risk this suite closes: 8 -/// same-shaped `.clone()` calls on one struct where a crossed pair would still compile. -/// Every field of `sync_poster` gets its own assertion for that reason. -#[test] -fn movies_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - assert!(Arc::ptr_eq( - &deps.movies.sync_poster.movie_command, - &repos.movie_command - )); - assert!(Arc::ptr_eq( - &deps.movies.sync_poster.movie_query, - &repos.movie_query - )); - assert!(Arc::ptr_eq( - &deps.movies.sync_poster.movie_profile, - &repos.movie_profile - )); - assert!(Arc::ptr_eq( - &deps.movies.sync_poster.metadata, - &services.metadata - )); - assert!(Arc::ptr_eq( - &deps.movies.sync_poster.poster_fetcher, - &services.poster_fetcher - )); - assert!(Arc::ptr_eq( - &deps.movies.sync_poster.object_storage, - &services.object_storage - )); - assert!(Arc::ptr_eq( - &deps.movies.sync_poster.event_publisher, - &services.event_publisher - )); - assert!(Arc::ptr_eq( - &deps.movies.sync_poster.search_command, - &repos.search_command - )); - // movie_profile/movie_query also recur in sync_poster above — confirm these two - // use cases are wired independently rather than reusing sync_poster's clone. - assert!(Arc::ptr_eq( - &deps.movies.get_movie_profile.movie_profile, - &repos.movie_profile - )); - assert!(Arc::ptr_eq( - &deps.movies.get_movies.movie, - &repos.movie_query - )); -} - -#[test] -fn person_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - assert!(Arc::ptr_eq( - &deps.person.get_person.person_query, - &repos.person_query - )); - assert!(Arc::ptr_eq( - &deps.person.get_person.event_publisher, - &services.event_publisher - )); -} - -#[test] -fn search_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - assert!(Arc::ptr_eq( - &deps.search.execute.search_port, - &repos.search_port - )); -} - -#[test] -fn social_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - assert!(Arc::ptr_eq( - &deps.social.command.social_command, - &repos.social_command - )); - assert!(Arc::ptr_eq( - &deps.social.command.event_publisher, - &services.event_publisher - )); - assert!(Arc::ptr_eq( - &deps.social.query.follow_graph, - &repos.follow_graph - )); - assert!(Arc::ptr_eq( - &deps.social.query.block_query, - &repos.block_query - )); -} - -#[test] -fn users_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - assert!(Arc::ptr_eq( - &deps.users.get_local_profile.stats, - &repos.stats - )); - assert!(Arc::ptr_eq( - &deps.users.get_local_profile.diary, - &repos.diary - )); - // Name mismatch: the field is called `social_query` but must come from - // `repos.follow_graph`, same as diary::get_activity_feed above. - assert!(Arc::ptr_eq( - &deps.users.get_local_profile.social_query, - &repos.follow_graph - )); - assert_eq!(deps.users.get_local_profile.instance, instance); - - assert!(Arc::ptr_eq( - &deps.users.get_federated_profile_stats.stats, - &repos.stats - )); - assert!(Arc::ptr_eq( - &deps.users.get_federated_profile_stats.diary, - &repos.diary - )); - assert!(Arc::ptr_eq( - &deps.users.get_federated_profile_stats.social_query, - &repos.follow_graph - )); - - assert!(Arc::ptr_eq(&deps.users.get_page_viewer.user, &repos.user)); - assert!(Arc::ptr_eq( - &deps.users.get_page_viewer.follow_graph, - &repos.follow_graph - )); - - assert!(Arc::ptr_eq(&deps.users.resolve_username.user, &repos.user)); - - assert!(Arc::ptr_eq( - &deps.users.get_profile_settings.user, - &repos.user - )); - assert!(Arc::ptr_eq( - &deps.users.get_profile_settings.profile_fields, - &repos.profile_fields - )); - assert_eq!(deps.users.get_profile_settings.instance, instance); - - // `user` recurs in several structs of this group — confirm each is wired - // independently rather than one struct silently sharing another's clone call. - assert!(Arc::ptr_eq(&deps.users.get_local_profile.user, &repos.user)); - assert!(Arc::ptr_eq(&deps.users.get_users_list.user, &repos.user)); - assert!(Arc::ptr_eq(&deps.users.update_profile.user, &repos.user)); - assert!(Arc::ptr_eq(&deps.users.delete_account.user, &repos.user)); - - assert!(Arc::ptr_eq( - &deps.users.get_users_list.federation_admin, - &repos.federation_admin - )); - assert!(Arc::ptr_eq( - &deps.users.update_profile.object_storage, - &services.object_storage - )); - // event_publisher recurs in update_profile and delete_account — confirm both. - assert!(Arc::ptr_eq( - &deps.users.update_profile.event_publisher, - &services.event_publisher - )); - assert!(Arc::ptr_eq( - &deps.users.delete_account.event_publisher, - &services.event_publisher - )); - - // `user` recurs again here — confirm get_current_profile is independently wired. - assert!(Arc::ptr_eq( - &deps.users.get_current_profile.user, - &repos.user - )); - assert!(Arc::ptr_eq( - &deps.users.update_profile_fields.profile_fields, - &repos.profile_fields - )); - assert!(Arc::ptr_eq( - &deps.users.update_profile_fields.event_publisher, - &services.event_publisher - )); - // user_settings recurs across get_settings and update_settings — confirm both - // land on the same repo Arc independently. - assert!(Arc::ptr_eq( - &deps.users.get_settings.user_settings, - &repos.user_settings - )); - assert!(Arc::ptr_eq( - &deps.users.update_settings.user_settings, - &repos.user_settings - )); - assert!(Arc::ptr_eq(&deps.users.authorize_admin.user, &repos.user)); - - // federated_profile is genuinely optional configuration (federation on/off), - // the same exemption as WorkerServices::person_enrichment (see - // worker_deps_wiring below) — the fixture leaves it None, so pointer - // identity doesn't apply; confirm it passes through unchanged instead. - assert_eq!( - deps.users.get_federated_profile.federated_profile.is_none(), - repos.federated_profile.is_none(), - "the optional federated_profile port must pass through as given" - ); -} - -#[test] -fn watchlist_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - assert!(Arc::ptr_eq( - &deps.watchlist.add.movie_command, - &repos.movie_command - )); - assert!(Arc::ptr_eq( - &deps.watchlist.add.movie_query, - &repos.movie_query - )); - assert!(Arc::ptr_eq( - &deps.watchlist.add.metadata, - &services.metadata - )); - assert!(Arc::ptr_eq(&deps.watchlist.add.watchlist, &repos.watchlist)); - assert!(Arc::ptr_eq( - &deps.watchlist.add.event_publisher, - &services.event_publisher - )); - assert!(Arc::ptr_eq( - &deps.watchlist.get_watchlist_for_owner.user, - &repos.user - )); - // `watchlist` also appears in `add` — confirm `get_watchlist_for_owner` is - // independently wired to the same repo Arc, not a stray clone. - assert!(Arc::ptr_eq( - &deps.watchlist.get_watchlist_for_owner.watchlist, - &repos.watchlist - )); - assert!(Arc::ptr_eq( - &deps.watchlist.get_watchlist_for_owner.remote_watchlist, - &repos.remote_watchlist - )); - assert!(Arc::ptr_eq( - &deps.watchlist.get_watchlist.watchlist, - &repos.watchlist - )); - assert!(Arc::ptr_eq( - &deps.watchlist.is_on_watchlist.watchlist, - &repos.watchlist - )); - assert!(Arc::ptr_eq( - &deps.watchlist.remove_from_watchlist.watchlist, - &repos.watchlist - )); - assert!(Arc::ptr_eq( - &deps.watchlist.remove_from_watchlist.event_publisher, - &services.event_publisher - )); -} - -#[test] -fn wrapup_group_wiring() { - let repos = test_repositories(); - let services = test_services(); - let config = test_config(); - let instance = domain::value_objects::InstanceIdentity::new("https://md.test"); - let deps = build_deps(&repos, &services, &config, &instance); - - assert!(Arc::ptr_eq( - &deps.wrapup.get_ready_report.wrapup_repo, - &repos.wrapup_repo - )); - assert!(Arc::ptr_eq( - &deps.wrapup.delete_wrapup.wrapup_repo, - &repos.wrapup_repo - )); - assert!(Arc::ptr_eq( - &deps.wrapup.generate.wrapup_repo, - &repos.wrapup_repo - )); - assert!(Arc::ptr_eq( - &deps.wrapup.generate.event_publisher, - &services.event_publisher - )); - assert!(Arc::ptr_eq( - &deps.wrapup.get_wrapup.wrapup_repo, - &repos.wrapup_repo - )); - assert!(Arc::ptr_eq( - &deps.wrapup.list_wrapups.wrapup_repo, - &repos.wrapup_repo - )); -} - -/// Covers the five groups that moved to `WorkerDeps` during worker unification: -/// `enrich_movie`, `reindex_search`, `merge_duplicates`, `enrich_person`, -/// `handle_requested`. Assertions here are the ones that used to live in -/// `movies_group_wiring`, `person_group_wiring`, and `wrapup_group_wiring` against -/// `build_deps` — they now drive `build_worker_deps` against `WorkerServices` instead. -#[tokio::test] -async fn worker_deps_wiring() { - let db = test_database_output(); - let services = test_worker_services(); - let worker_deps = build_worker_deps(&db, &services); - - // enrich_movie and reindex_search share movie_query/movie_profile/search_command - // with each other — confirm each struct is wired independently rather than - // reusing a stray clone from a sibling struct. - assert!(Arc::ptr_eq( - &worker_deps.enrich_movie.movie_query, - &db.movie_query - )); - assert!(Arc::ptr_eq( - &worker_deps.enrich_movie.movie_profile, - &db.movie_profile - )); - assert!(Arc::ptr_eq( - &worker_deps.enrich_movie.person_command, - &db.person_command - )); - assert!(Arc::ptr_eq( - &worker_deps.enrich_movie.search_command, - &db.search_command - )); - assert!(Arc::ptr_eq( - &worker_deps.reindex_search.movie_query, - &db.movie_query - )); - assert!(Arc::ptr_eq( - &worker_deps.reindex_search.movie_profile, - &db.movie_profile - )); - assert!(Arc::ptr_eq( - &worker_deps.reindex_search.search_command, - &db.search_command - )); - assert!(Arc::ptr_eq( - &worker_deps.reindex_search.person_command, - &db.person_command - )); - assert!(Arc::ptr_eq( - &worker_deps.reindex_search.person_query, - &db.person_query - )); - - assert!(Arc::ptr_eq( - &worker_deps.merge_duplicates.movie_query, - &db.movie_query - )); - assert!(Arc::ptr_eq( - &worker_deps.merge_duplicates.object_storage, - &services.object_storage - )); - assert!( - Arc::ptr_eq(&worker_deps.merge_duplicates.deduplicator, &db.deduplicator), - "merge_duplicates must carry the DatabaseOutput deduplicator — this field was \ - unconstructible before worker unification" - ); - - assert!(Arc::ptr_eq( - &worker_deps.enrich_person.person_query, - &db.person_query - )); - assert!(Arc::ptr_eq( - &worker_deps.enrich_person.person_command, - &db.person_command - )); - assert_eq!( - worker_deps.enrich_person.person_enrichment.is_none(), - services.person_enrichment.is_none(), - "the optional person_enrichment port must pass through as given" - ); - - // wrapup_repo and wrapup_stats are the same-prefix pair most at risk of a swap; - // both are different trait types, but a future reader "correcting" a name could - // still reach for the wrong one. - assert!(Arc::ptr_eq( - &worker_deps.handle_requested.wrapup_repo, - &db.wrapup_repo - )); - assert!(Arc::ptr_eq( - &worker_deps.handle_requested.wrapup_stats, - &db.wrapup_stats - )); - assert!(Arc::ptr_eq( - &worker_deps.handle_requested.event_publisher, - &services.event_publisher - )); -} diff --git a/crates/bootstrap/src/worker.rs b/crates/bootstrap/src/worker.rs index de35f6dd..5d9c244f 100644 --- a/crates/bootstrap/src/worker.rs +++ b/crates/bootstrap/src/worker.rs @@ -1,13 +1,13 @@ use std::sync::Arc; -use application::config::AppConfig as AppSettings; use application::{ MovieDiscoveryIndexer, SearchCleanupHandler, SearchReindexHandler, worker::WorkerService, }; use domain::ports::{EventHandler, MovieEnrichmentClient, PeriodicJob, PersonEnrichmentClient}; use domain::value_objects::InstanceIdentity; -use crate::{database, event_bus}; +use crate::database::{self, DatabaseOutput}; +use crate::event_bus; pub struct WorkerApp { pub service: WorkerService, @@ -17,9 +17,27 @@ pub struct WorkerApp { /// spawned) and the consumer. Run it with `service.run(shutdown)`. pub async fn build_worker(config: &config::AppConfig) -> anyhow::Result { database::warn_if_database_backend_set(); - let app_config = AppSettings::from(config); - let instance = InstanceIdentity::new(app_config.base_url.clone()); + let instance = InstanceIdentity::new(config.server.base_url.clone()); + let db = database::build_database_adapters(&config.database.url, &instance).await?; + assemble(config, db, instance).await +} +/// Builds the same worker as [`build_worker`] on a caller-provided pool. +/// Migrations run on it exactly as they do for the normal path. +pub async fn build_worker_on( + config: &config::AppConfig, + pool: sqlx::SqlitePool, +) -> anyhow::Result { + let instance = InstanceIdentity::new(config.server.base_url.clone()); + let db = database::build_database_adapters_on(pool, &instance).await?; + assemble(config, db, instance).await +} + +async fn assemble( + config: &config::AppConfig, + db: DatabaseOutput, + #[cfg_attr(not(feature = "federation"), allow(unused_variables))] instance: InstanceIdentity, +) -> anyhow::Result { let metadata_client = metadata::create(&config.metadata)?; let poster_fetcher = poster_fetcher::create(&config.metadata)?; let object_storage = object_storage::create(&config.storage)?; @@ -40,7 +58,6 @@ pub async fn build_worker(config: &config::AppConfig) -> anyhow::Result); - let db = database::build_database_adapters(&config.database.url, &instance).await?; let (event_publisher_arc, consumer_arc) = event_bus::create_channel(&db.db_pool, config).await?; diff --git a/crates/bootstrap/tests/api_test.rs b/crates/bootstrap/tests/api_test.rs new file mode 100644 index 00000000..eb7dd57f --- /dev/null +++ b/crates/bootstrap/tests/api_test.rs @@ -0,0 +1,390 @@ +//! The HTTP surface of the production app, built through `bootstrap` on an +//! in-memory database with real adapters. + +mod support; + +use axum::{ + Router, + body::Body, + http::{Request, StatusCode}, +}; +use sqlx::SqlitePool; +use tower::ServiceExt; +use uuid::Uuid; + +async fn test_app() -> Router { + support::server().await.0 +} + +/// An app with one registered user; returns its pool, id and bearer token. +async fn test_app_with_user() -> (Router, SqlitePool, Uuid, String) { + let (app, pool) = support::server().await; + let session = support::register(&app, "insights", "insights@example.com").await; + let user_id = session.user_id.parse().unwrap(); + (app, pool, user_id, session.token) +} + +/// 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 = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + 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); +} + +// ── Insights ───────────────────────────────────────────────────────────────── + +async fn seed_insights_diary(pool: &SqlitePool, user_id: Uuid) { + // 12 thrillers rated 5 late at night, 12 comedies rated 2 — enough for a + // rating lift once genres come from movie_genres. + for i in 0..12u32 { + let movie_id = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)") + .bind(&movie_id) + .bind(format!("Thriller {i}")) + .bind(2020) + .execute(pool) + .await + .unwrap(); + sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES (?, ?, ?)") + .bind(&movie_id) + .bind(44400 + i as i64) + .bind("Thriller") + .execute(pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at) \ + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(&movie_id) + .bind(user_id.to_string()) + .bind(5) + .bind("2024-01-05 22:30:00") + .bind("2024-01-05 22:30:00") + .execute(pool) + .await + .unwrap(); + + let movie_id = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)") + .bind(&movie_id) + .bind(format!("Comedy {i}")) + .bind(2015) + .execute(pool) + .await + .unwrap(); + sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES (?, ?, ?)") + .bind(&movie_id) + .bind(55300 + i as i64) + .bind("Comedy") + .execute(pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at) \ + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(&movie_id) + .bind(user_id.to_string()) + .bind(2) + .bind("2024-03-10 13:00:00") + .bind("2024-03-10 13:00:00") + .execute(pool) + .await + .unwrap(); + } +} + +#[tokio::test] +async fn get_api_insights_returns_rating_lift_for_own_user() { + let (app, pool, user_id, token) = test_app_with_user().await; + seed_insights_diary(&pool, user_id).await; + + let response = app + .oneshot(with_ip( + Request::builder() + .uri("/api/v1/insights") + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + )) + .await + .unwrap(); + + let (status, bytes) = ( + response.status(), + axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(), + ); + assert_eq!( + status, + StatusCode::OK, + "body: {}", + String::from_utf8(bytes.to_vec()).unwrap() + ); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + + let rating_lifts: Vec<&serde_json::Value> = json["insights"] + .as_array() + .unwrap() + .iter() + .filter(|i| i["kind"] == "rating_lift") + .collect(); + assert_eq!(rating_lifts.len(), 2, "thriller up, comedy down: {json}"); + + let thriller = rating_lifts + .iter() + .find(|i| i["genre"] == "Thriller") + .unwrap(); + assert_eq!(thriller["delta"], 1.5); + assert_eq!(thriller["sample_count"], 12); + // Timestamps are real → rhythm fires. Hours split evenly between 22 + // (thrillers) and 13 (comedies); every window covering either bucket ties, + // so the earliest one, 11:00–14:00, wins. Days tie too (Fri/Sun), sorted + // ascending. + let rhythm = json["insights"] + .as_array() + .unwrap() + .iter() + .find(|i| i["kind"] == "rhythm") + .expect("rhythm expected for real timestamps"); + assert_eq!(rhythm["peak_hour_start"], 11); + assert_eq!(rhythm["peak_hour_end"], 14); + assert_eq!(rhythm["top_days"], serde_json::json!([5, 7])); +} + +async fn get_insights_json( + app: Router, + token: &str, + query: &str, +) -> (StatusCode, serde_json::Value) { + let response = app + .oneshot(with_ip( + Request::builder() + .uri(format!("/api/v1/insights{query}")) + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + )) + .await + .unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, json) +} + +#[tokio::test] +async fn get_api_insights_ignores_remote_reviews() { + let (app, pool, user_id, token) = test_app_with_user().await; + seed_insights_diary(&pool, user_id).await; + // Federated rows stored under the same user_id are someone else's watches. + for i in 0..12u32 { + let movie_id = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)") + .bind(&movie_id) + .bind(format!("Horror {i}")) + .bind(2019) + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES (?, ?, ?)") + .bind(&movie_id) + .bind(66600 + i as i64) + .bind("Horror") + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at, remote_actor_url) \ + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(&movie_id) + .bind(user_id.to_string()) + .bind(5) + .bind("2024-05-05 09:00:00") + .bind("2024-05-05 09:00:00") + .bind("https://remote.example/users/someone") + .execute(&pool) + .await + .unwrap(); + } + + let (status, json) = get_insights_json(app, &token, "").await; + assert_eq!(status, StatusCode::OK); + let insights = json["insights"].as_array().unwrap(); + assert!( + !insights.iter().any(|i| i["genre"] == "Horror"), + "remote reviews must not feed insights: {json}" + ); + let thriller = insights + .iter() + .find(|i| i["kind"] == "rating_lift" && i["genre"] == "Thriller") + .unwrap(); + assert_eq!(thriller["delta"], 1.5); +} + +#[tokio::test] +async fn get_api_insights_buckets_rhythm_in_requested_time_zone() { + let (app, pool, user_id, token) = test_app_with_user().await; + seed_insights_diary(&pool, user_id).await; + + // January in Warsaw is UTC+1: 22:30 → 23:30 and 13:00 → 14:00, so the + // earliest tied window moves from 11–14 to 12–15. + let (status, json) = get_insights_json(app, &token, "?tz=Europe/Warsaw").await; + assert_eq!(status, StatusCode::OK); + let rhythm = json["insights"] + .as_array() + .unwrap() + .iter() + .find(|i| i["kind"] == "rhythm") + .expect("rhythm expected"); + assert_eq!(rhythm["peak_hour_start"], 12); + assert_eq!(rhythm["peak_hour_end"], 15); +} + +#[tokio::test] +async fn get_api_insights_rejects_unknown_time_zone() { + let (app, _pool, _user_id, token) = test_app_with_user().await; + let (status, _) = get_insights_json(app, &token, "?tz=Mars/Olympus").await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} diff --git a/crates/bootstrap/tests/bootstrap_test.rs b/crates/bootstrap/tests/bootstrap_test.rs new file mode 100644 index 00000000..33a2e027 --- /dev/null +++ b/crates/bootstrap/tests/bootstrap_test.rs @@ -0,0 +1,69 @@ +//! The composition root assembles on a fresh database, with every adapter real. + +mod support; + +#[tokio::test] +async fn server_builds_on_a_fresh_database() { + let pool = support::memory_pool().await; + let router = bootstrap::build_server_on(&support::test_config(), pool) + .await + .expect("server builds"); + + let (status, _, _) = support::send(&router, support::get("/health", None)).await; + assert_eq!(status, 200); +} + +#[tokio::test] +async fn worker_builds_on_a_fresh_database() { + let pool = support::memory_pool().await; + bootstrap::build_worker_on(&support::test_config(), pool) + .await + .expect("worker builds"); +} + +/// Without the federation feature, following another local user still works +/// (the request lands as pending for the target): +/// the local social ports are wired in place of the ActivityPub ones. +#[cfg(not(feature = "federation"))] +#[tokio::test] +async fn federation_off_build_wires_local_social() { + use axum::http::Method; + + let (router, _pool) = support::server().await; + let alice = support::register(&router, "alice", "alice@example.com").await; + let bob = support::register(&router, "bob", "bob@example.com").await; + + let (status, _, body) = support::send( + &router, + support::json_request( + Method::POST, + "/api/v1/social/follow", + Some(&alice.token), + serde_json::json!({ "handle": "bob@localhost:3000" }), + ), + ) + .await; + assert_eq!(status, 200, "follow: {body}"); + + let (status, _, body) = support::send( + &router, + axum::http::Request::builder() + .uri("/api/v1/social/followers/pending") + .header("authorization", format!("Bearer {}", bob.token)) + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(status, 200, "pending followers: {body}"); + let followers: serde_json::Value = serde_json::from_str(&body).unwrap(); + let actors = followers["actors"].as_array().unwrap(); + assert_eq!( + actors.len(), + 1, + "bob should have one pending follower: {body}" + ); + assert!( + body.contains(&alice.user_id) || body.contains("alice"), + "follower should be alice: {body}" + ); +} diff --git a/crates/bootstrap/tests/route_smoke_test.rs b/crates/bootstrap/tests/route_smoke_test.rs index d11d7c98..1d4efd2d 100644 --- a/crates/bootstrap/tests/route_smoke_test.rs +++ b/crates/bootstrap/tests/route_smoke_test.rs @@ -1,14 +1,15 @@ //! Characterization of the classic HTML routes, feeds and the OpenAPI surface, //! recorded before the HTTP layer moves. Statuses are what the app returns today. +mod support; + use axum::{ Router, body::Body, http::{Method, Request, header}, }; -use tower::ServiceExt; +use support::{get, json_request, send}; -const PASSWORD: &str = "correct-horse-battery"; const EMAIL: &str = "owner@example.com"; const USERNAME: &str = "owner"; @@ -83,135 +84,11 @@ struct Seed { owner_cookie: String, } -fn test_config() -> config::AppConfig { - config::AppConfig { - server: config::ServerConfig { - allow_registration: true, - base_url: "http://localhost:3000".into(), - ..Default::default() - }, - database: config::DatabaseConfig { - url: "sqlite::memory:".into(), - }, - auth: config::AuthConfig { - jwt_secret: Some("route-smoke-test-secret-0123456789abcdef".into()), - ..Default::default() - }, - events: Default::default(), - nats: Default::default(), - storage: Default::default(), - image_conversion: Default::default(), - // Required to build; never called (seeding logs reviews with manual titles). - metadata: config::MetadataConfig { - tmdb_api_key: Some("test-key".into()), - ..Default::default() - }, - federation: Default::default(), - wrapup: Default::default(), - } -} - -async fn send(router: &Router, mut req: Request) -> (u16, axum::http::HeaderMap, String) { - let addr: std::net::SocketAddr = "127.0.0.1:12345".parse().unwrap(); - req.extensions_mut() - .insert(axum::extract::ConnectInfo::(addr)); - let resp = router.clone().oneshot(req).await.unwrap(); - let status = resp.status().as_u16(); - let headers = resp.headers().clone(); - let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) - .await - .unwrap(); - ( - status, - headers, - String::from_utf8_lossy(&bytes).into_owned(), - ) -} - -fn get(uri: &str, cookie: Option<&str>) -> Request { - let mut b = Request::builder().method(Method::GET).uri(uri); - if let Some(c) = cookie { - b = b.header(header::COOKIE, c); - } - b.body(Body::empty()).unwrap() -} - -fn cookie_value(headers: &axum::http::HeaderMap, name: &str) -> Option { - headers - .get_all(header::SET_COOKIE) - .iter() - .filter_map(|v| v.to_str().ok()) - .find_map(|c| { - c.split(';') - .next()? - .trim() - .strip_prefix(&format!("{name}=")) - .map(str::to_string) - }) -} - -fn json_request( - method: Method, - uri: &str, - token: Option<&str>, - body: serde_json::Value, -) -> Request { - let mut b = Request::builder() - .method(method) - .uri(uri) - .header(header::CONTENT_TYPE, "application/json"); - if let Some(t) = token { - b = b.header(header::AUTHORIZATION, format!("Bearer {t}")); - } - b.body(Body::from(body.to_string())).unwrap() -} - /// One user (registered through the classic form), one movie with a spoiler review, one goal. async fn seed() -> Seed { - let pool = sqlx::sqlite::SqlitePoolOptions::new() - .max_connections(1) - .connect("sqlite::memory:") - .await - .unwrap(); - let router = bootstrap::build_server_on(&test_config(), pool.clone()) - .await - .unwrap(); - - // Register through the form: GET for the csrf cookie, then POST with the token. - let (status, headers, _) = send(&router, get("/register", None)).await; - assert_eq!(status, 200, "GET /register"); - let csrf = cookie_value(&headers, "csrf").expect("csrf cookie"); - let form = format!( - "email={}&username={USERNAME}&password={PASSWORD}&_csrf={csrf}", - EMAIL.replace('@', "%40") - ); - let req = Request::builder() - .method(Method::POST) - .uri("/register") - .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") - .header(header::COOKIE, format!("csrf={csrf}")) - .body(Body::from(form)) - .unwrap(); - let (status, headers, _) = send(&router, req).await; - assert_eq!(status, 303, "POST /register"); - let session = cookie_value(&headers, "token").expect("session cookie after register"); - let owner_cookie = format!("csrf={csrf}; token={session}"); - - // Bearer token for the JSON API. - let (status, _, body) = send( - &router, - json_request( - Method::POST, - "/api/v1/auth/login", - None, - serde_json::json!({ "email": EMAIL, "password": PASSWORD }), - ), - ) - .await; - assert_eq!(status, 200, "login: {body}"); - let login: serde_json::Value = serde_json::from_str(&body).unwrap(); - let token = login["token"].as_str().unwrap().to_string(); - let user_id = login["user_id"].as_str().unwrap().to_string(); + let (router, pool) = support::server().await; + let session = support::register(&router, USERNAME, EMAIL).await; + let (token, user_id, owner_cookie) = (session.token, session.user_id, session.cookie); let (status, _, body) = send( &router, diff --git a/crates/bootstrap/tests/support/mod.rs b/crates/bootstrap/tests/support/mod.rs new file mode 100644 index 00000000..9429d37e --- /dev/null +++ b/crates/bootstrap/tests/support/mod.rs @@ -0,0 +1,162 @@ +//! Shared helpers for the bootstrap integration tests: a production app built +//! through `bootstrap` on an in-memory database, driven over HTTP. +#![allow(dead_code)] + +use axum::{ + Router, + body::Body, + http::{Method, Request, header}, +}; +use tower::ServiceExt; + +pub const PASSWORD: &str = "correct-horse-battery"; + +pub fn test_config() -> config::AppConfig { + config::AppConfig { + server: config::ServerConfig { + allow_registration: true, + base_url: "http://localhost:3000".into(), + ..Default::default() + }, + database: config::DatabaseConfig { + url: "sqlite::memory:".into(), + }, + auth: config::AuthConfig { + jwt_secret: Some("route-smoke-test-secret-0123456789abcdef".into()), + ..Default::default() + }, + events: Default::default(), + nats: Default::default(), + storage: Default::default(), + image_conversion: Default::default(), + // Required to build; never called (seeding logs reviews with manual titles). + metadata: config::MetadataConfig { + tmdb_api_key: Some("test-key".into()), + ..Default::default() + }, + federation: Default::default(), + wrapup: Default::default(), + } +} + +pub async fn send(router: &Router, mut req: Request) -> (u16, axum::http::HeaderMap, String) { + let addr: std::net::SocketAddr = "127.0.0.1:12345".parse().unwrap(); + req.extensions_mut() + .insert(axum::extract::ConnectInfo::(addr)); + let resp = router.clone().oneshot(req).await.unwrap(); + let status = resp.status().as_u16(); + let headers = resp.headers().clone(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + ( + status, + headers, + String::from_utf8_lossy(&bytes).into_owned(), + ) +} + +pub fn get(uri: &str, cookie: Option<&str>) -> Request { + let mut b = Request::builder().method(Method::GET).uri(uri); + if let Some(c) = cookie { + b = b.header(header::COOKIE, c); + } + b.body(Body::empty()).unwrap() +} + +pub fn cookie_value(headers: &axum::http::HeaderMap, name: &str) -> Option { + headers + .get_all(header::SET_COOKIE) + .iter() + .filter_map(|v| v.to_str().ok()) + .find_map(|c| { + c.split(';') + .next()? + .trim() + .strip_prefix(&format!("{name}=")) + .map(str::to_string) + }) +} + +pub fn json_request( + method: Method, + uri: &str, + token: Option<&str>, + body: serde_json::Value, +) -> Request { + let mut b = Request::builder() + .method(method) + .uri(uri) + .header(header::CONTENT_TYPE, "application/json"); + if let Some(t) = token { + b = b.header(header::AUTHORIZATION, format!("Bearer {t}")); + } + b.body(Body::from(body.to_string())).unwrap() +} + +/// A single-connection in-memory pool (every connection would otherwise get its own database). +pub async fn memory_pool() -> sqlx::SqlitePool { + sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap() +} + +/// The production server router on a fresh in-memory database, plus its pool. +pub async fn server() -> (Router, sqlx::SqlitePool) { + let pool = memory_pool().await; + let router = bootstrap::build_server_on(&test_config(), pool.clone()) + .await + .unwrap(); + (router, pool) +} + +pub struct Session { + pub user_id: String, + /// Bearer token for the JSON API. + pub token: String, + /// Cookie header for the classic UI (csrf + session). + pub cookie: String, +} + +/// Registers through the classic form (GET for the csrf cookie, then POST with +/// the token), then logs in over the JSON API for a bearer token. +pub async fn register(router: &Router, username: &str, email: &str) -> Session { + let (status, headers, _) = send(router, get("/register", None)).await; + assert_eq!(status, 200, "GET /register"); + let csrf = cookie_value(&headers, "csrf").expect("csrf cookie"); + let form = format!( + "email={}&username={username}&password={PASSWORD}&_csrf={csrf}", + email.replace('@', "%40") + ); + let req = Request::builder() + .method(Method::POST) + .uri("/register") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(header::COOKIE, format!("csrf={csrf}")) + .body(Body::from(form)) + .unwrap(); + let (status, headers, _) = send(router, req).await; + assert_eq!(status, 303, "POST /register"); + let session = cookie_value(&headers, "token").expect("session cookie after register"); + let cookie = format!("csrf={csrf}; token={session}"); + + let (status, _, body) = send( + router, + json_request( + Method::POST, + "/api/v1/auth/login", + None, + serde_json::json!({ "email": email, "password": PASSWORD }), + ), + ) + .await; + assert_eq!(status, 200, "login: {body}"); + let login: serde_json::Value = serde_json::from_str(&body).unwrap(); + Session { + user_id: login["user_id"].as_str().unwrap().to_string(), + token: login["token"].as_str().unwrap().to_string(), + cookie, + } +} diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index ccbdb448..06e72338 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -21,18 +21,3 @@ tokio = { workspace = true } anyhow = { workspace = true } dotenvy = { workspace = true } tracing = { workspace = true } - -[dev-dependencies] -domain = { workspace = true, features = ["test-helpers"] } -application = { workspace = true } -http-axum = { path = "../adapters/http-axum", default-features = false } -rss = { workspace = true } -sqlite = { workspace = true } -sqlx = { workspace = true } -async-trait = { workspace = true } -bytes = { workspace = true } -futures = { workspace = true } -serde_json = { workspace = true } -uuid = { workspace = true } -tower = { version = "0.5", features = ["util"] } -http-body-util = "0.1" diff --git a/crates/server/tests/api_test.rs b/crates/server/tests/api_test.rs deleted file mode 100644 index c7cb1434..00000000 --- a/crates/server/tests/api_test.rs +++ /dev/null @@ -1,844 +0,0 @@ -use std::sync::Arc; - -use application::config::AppConfig; -use async_trait::async_trait; -use axum::{ - Router, - body::Body, - http::{Request, StatusCode}, -}; -use bootstrap::Repositories; -use domain::{ - errors::DomainError, - events::DomainEvent, - models::{ - EntityType, ExternalPersonId, IndexableDocument, MetadataSearchCriteria, Movie, Person, - PersonCredits, PersonEnrichmentData, PersonId, SearchQuery, SearchResults, User, - }, - ports::{ - AuthService, EventPublisher, MetadataClient, ObjectStorage, PasswordHasher, PersonCommand, - PersonQuery, PosterFetcherClient, SearchCommand, SearchPort, UserRepository, - }, - value_objects::{Email, ExternalMetadataId, PasswordHash, PosterUrl, UserId}, -}; -use http_axum::context::AppContext; -use http_axum::{router, state::AppState}; -use http_body_util::BodyExt; -use rss::RssAdapter; -use sqlite::{ - SqliteDiaryRepository, SqliteInsightsQuery, SqliteMovieRepository, SqliteReviewRepository, - SqliteStatsRepository, migrate as sqlite_migrate, -}; -use sqlx::SqlitePool; -use tower::ServiceExt; -use uuid::Uuid; - -struct NoopEventPublisher; -#[async_trait] -impl EventPublisher for NoopEventPublisher { - async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> { - Ok(()) - } -} - -struct PanicReviewLogger; -#[async_trait] -impl application::ports::ReviewLogger for PanicReviewLogger { - async fn log_review( - &self, - _: application::diary::commands::LogReviewCommand, - ) -> Result<(), DomainError> { - panic!("review_logger not wired in tests") - } -} - -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 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; -impl domain::ports::DiaryExporter for PanicExporter { - fn stream_entries( - &self, - _stream: futures::stream::BoxStream< - 'static, - Result, - >, - _format: domain::models::ExportFormat, - ) -> futures::stream::BoxStream<'static, Result> { - panic!("PanicExporter::stream_entries") - } -} - -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!() - } - async fn backfill_from_credits_batch( - &self, - _batch_size: u32, - ) -> Result<(u64, bool), DomainError> { - panic!() - } - async fn update_enrichment( - &self, - _: &PersonId, - _: &PersonEnrichmentData, - ) -> 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!() - } - async fn list_page( - &self, - _limit: u32, - _offset: u32, - ) -> 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!() - } -} - -struct PanicRemoteWatchlist; -#[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![]) - } -} -async fn test_app() -> Router { - test_app_with(domain::testing::FakeAuthService).await.0 -} - -async fn test_app_with(auth: impl AuthService + 'static) -> (Router, SqlitePool) { - let pool = SqlitePool::connect("sqlite::memory:") - .await - .expect("in-memory SQLite failed"); - sqlite_migrate(&pool).await.expect("migration failed"); - - let repos = Repositories { - movie_command: Arc::new(SqliteMovieRepository::new(pool.clone())) as _, - movie_query: Arc::new(SqliteMovieRepository::new(pool.clone())) as _, - review: Arc::new(SqliteReviewRepository::new(pool.clone())) as _, - diary: Arc::new(SqliteDiaryRepository::new(pool.clone())) as _, - stats: Arc::new(SqliteStatsRepository::new(pool.clone())) 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_command: Arc::new(domain::testing::PanicWatchEventCommand), - watch_event_query: Arc::new(domain::testing::PanicWatchEventQuery), - 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_command: Arc::new(domain::ports::noop::NoopSocialCommand), - follow_graph: Arc::new(domain::ports::noop::NoopSocialQuery), - block_query: Arc::new(domain::ports::noop::NoopSocialQuery), - federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery) as _, - wrapup_stats: Arc::new(domain::testing::PanicWrapUpStatsQuery) as _, - wrapup_repo: Arc::new(domain::testing::PanicWrapUpRepository) as _, - insights: Arc::new(SqliteInsightsQuery::new(pool.clone())) as _, - goal_command: Arc::new(domain::testing::NoopGoalCommand), - goal_query: Arc::new(domain::testing::NoopGoalQuery), - user_settings: Arc::new(domain::testing::NoopUserSettingsRepository), - remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository), - refresh_session: Arc::new(domain::testing::PanicRefreshSessionRepository), - federated_profile: None, - }; - - let services = application::Services { - auth: Arc::new(auth), - 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), - review_logger: Arc::new(PanicReviewLogger), - person_enrichment: None, - }; - - let config = AppConfig { - allow_registration: false, - base_url: "http://localhost:3000".to_string(), - rate_limit: 20, - refresh_ttl_seconds: 2_592_000, - secure_cookies: false, - cors_origins: String::new(), - wrapup: application::config::WrapUpConfig { - font_path: None, - logo_path: None, - bg_dir: None, - }, - }; - - let instance = domain::value_objects::InstanceIdentity::new("http://localhost:3000"); - - let deps = Arc::new(bootstrap::build_deps(&repos, &services, &config, &instance)); - - let state = AppState { - app_ctx: AppContext { - deps, - services, - config, - instance, - #[cfg(feature = "federation")] - ap_document: Arc::new(domain::ports::noop::NoopApDocument), - #[cfg(feature = "federation")] - ap_blocklist: Arc::new(domain::ports::noop::NoopInstanceBlocklist), - }, - rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())), - }; - - let router = router::build_router(state, axum::Router::new()); - (router, pool) -} - -/// 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); -} - -// ── Insights ───────────────────────────────────────────────────────────────── - -async fn seed_insights_diary(pool: &SqlitePool, user_id: Uuid) { - // 12 thrillers rated 5 late at night, 12 comedies rated 2 — enough for a - // rating lift once genres come from movie_genres. - for i in 0..12u32 { - let movie_id = uuid::Uuid::new_v4().to_string(); - sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)") - .bind(&movie_id) - .bind(format!("Thriller {i}")) - .bind(2020) - .execute(pool) - .await - .unwrap(); - sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES (?, ?, ?)") - .bind(&movie_id) - .bind(44400 + i as i64) - .bind("Thriller") - .execute(pool) - .await - .unwrap(); - sqlx::query( - "INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at) \ - VALUES (?, ?, ?, ?, ?, ?)", - ) - .bind(uuid::Uuid::new_v4().to_string()) - .bind(&movie_id) - .bind(user_id.to_string()) - .bind(5) - .bind("2024-01-05 22:30:00") - .bind("2024-01-05 22:30:00") - .execute(pool) - .await - .unwrap(); - - let movie_id = uuid::Uuid::new_v4().to_string(); - sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)") - .bind(&movie_id) - .bind(format!("Comedy {i}")) - .bind(2015) - .execute(pool) - .await - .unwrap(); - sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES (?, ?, ?)") - .bind(&movie_id) - .bind(55300 + i as i64) - .bind("Comedy") - .execute(pool) - .await - .unwrap(); - sqlx::query( - "INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at) \ - VALUES (?, ?, ?, ?, ?, ?)", - ) - .bind(uuid::Uuid::new_v4().to_string()) - .bind(&movie_id) - .bind(user_id.to_string()) - .bind(2) - .bind("2024-03-10 13:00:00") - .bind("2024-03-10 13:00:00") - .execute(pool) - .await - .unwrap(); - } -} - -#[tokio::test] -async fn get_api_insights_returns_rating_lift_for_own_user() { - let (app, pool) = test_app_with(domain::testing::FakeAuthService).await; - let user_id = Uuid::new_v4(); - seed_insights_diary(&pool, user_id).await; - - let response = app - .oneshot(with_ip( - Request::builder() - .uri("/api/v1/insights") - .header("authorization", format!("Bearer {user_id}")) - .body(Body::empty()) - .unwrap(), - )) - .await - .unwrap(); - - let (status, bytes) = ( - response.status(), - response.into_body().collect().await.unwrap().to_bytes(), - ); - assert_eq!( - status, - StatusCode::OK, - "body: {}", - String::from_utf8(bytes.to_vec()).unwrap() - ); - let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); - - let rating_lifts: Vec<&serde_json::Value> = json["insights"] - .as_array() - .unwrap() - .iter() - .filter(|i| i["kind"] == "rating_lift") - .collect(); - assert_eq!(rating_lifts.len(), 2, "thriller up, comedy down: {json}"); - - let thriller = rating_lifts - .iter() - .find(|i| i["genre"] == "Thriller") - .unwrap(); - assert_eq!(thriller["delta"], 1.5); - assert_eq!(thriller["sample_count"], 12); - // Timestamps are real → rhythm fires. Hours split evenly between 22 - // (thrillers) and 13 (comedies); every window covering either bucket ties, - // so the earliest one, 11:00–14:00, wins. Days tie too (Fri/Sun), sorted - // ascending. - let rhythm = json["insights"] - .as_array() - .unwrap() - .iter() - .find(|i| i["kind"] == "rhythm") - .expect("rhythm expected for real timestamps"); - assert_eq!(rhythm["peak_hour_start"], 11); - assert_eq!(rhythm["peak_hour_end"], 14); - assert_eq!(rhythm["top_days"], serde_json::json!([5, 7])); -} - -async fn get_insights_json( - app: Router, - user_id: Uuid, - query: &str, -) -> (StatusCode, serde_json::Value) { - let response = app - .oneshot(with_ip( - Request::builder() - .uri(format!("/api/v1/insights{query}")) - .header("authorization", format!("Bearer {user_id}")) - .body(Body::empty()) - .unwrap(), - )) - .await - .unwrap(); - let status = response.status(); - let bytes = response.into_body().collect().await.unwrap().to_bytes(); - let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); - (status, json) -} - -#[tokio::test] -async fn get_api_insights_ignores_remote_reviews() { - let (app, pool) = test_app_with(domain::testing::FakeAuthService).await; - let user_id = Uuid::new_v4(); - seed_insights_diary(&pool, user_id).await; - // Federated rows stored under the same user_id are someone else's watches. - for i in 0..12u32 { - let movie_id = uuid::Uuid::new_v4().to_string(); - sqlx::query("INSERT INTO movies (id, title, release_year) VALUES (?, ?, ?)") - .bind(&movie_id) - .bind(format!("Horror {i}")) - .bind(2019) - .execute(&pool) - .await - .unwrap(); - sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES (?, ?, ?)") - .bind(&movie_id) - .bind(66600 + i as i64) - .bind("Horror") - .execute(&pool) - .await - .unwrap(); - sqlx::query( - "INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at, remote_actor_url) \ - VALUES (?, ?, ?, ?, ?, ?, ?)", - ) - .bind(uuid::Uuid::new_v4().to_string()) - .bind(&movie_id) - .bind(user_id.to_string()) - .bind(5) - .bind("2024-05-05 09:00:00") - .bind("2024-05-05 09:00:00") - .bind("https://remote.example/users/someone") - .execute(&pool) - .await - .unwrap(); - } - - let (status, json) = get_insights_json(app, user_id, "").await; - assert_eq!(status, StatusCode::OK); - let insights = json["insights"].as_array().unwrap(); - assert!( - !insights.iter().any(|i| i["genre"] == "Horror"), - "remote reviews must not feed insights: {json}" - ); - let thriller = insights - .iter() - .find(|i| i["kind"] == "rating_lift" && i["genre"] == "Thriller") - .unwrap(); - assert_eq!(thriller["delta"], 1.5); -} - -#[tokio::test] -async fn get_api_insights_buckets_rhythm_in_requested_time_zone() { - let (app, pool) = test_app_with(domain::testing::FakeAuthService).await; - let user_id = Uuid::new_v4(); - seed_insights_diary(&pool, user_id).await; - - // January in Warsaw is UTC+1: 22:30 → 23:30 and 13:00 → 14:00, so the - // earliest tied window moves from 11–14 to 12–15. - let (status, json) = get_insights_json(app, user_id, "?tz=Europe/Warsaw").await; - assert_eq!(status, StatusCode::OK); - let rhythm = json["insights"] - .as_array() - .unwrap() - .iter() - .find(|i| i["kind"] == "rhythm") - .expect("rhythm expected"); - assert_eq!(rhythm["peak_hour_start"], 12); - assert_eq!(rhythm["peak_hour_end"], 15); -} - -#[tokio::test] -async fn get_api_insights_rejects_unknown_time_zone() { - let (app, _pool) = test_app_with(domain::testing::FakeAuthService).await; - let (status, _) = get_insights_json(app, Uuid::new_v4(), "?tz=Mars/Olympus").await; - assert_eq!(status, StatusCode::BAD_REQUEST); -}