From a1c3f7760debafe8f37c4c9ac9715b02b2e5d545 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 22:52:41 +0100 Subject: [PATCH] test: move http-axum tests to tests/ Drop bootstrap dev-dep. Extractor + api handler + app-context tests (need full AppState via build_deps) -> bootstrap/tests/http_handlers/. forms tests -> http-axum/tests/log_review_form_test.rs. Federation collection tests now go through public federation::html_routes(). Kept inline (private fns): api/social social_actor_to_dto (4), federation/blocklist_api social_actor_to_blocked_dto (1), html/social social_actor_to_template (1), router slow_down/api_governor (2). --- Cargo.lock | 1 - crates/adapters/http-axum/Cargo.toml | 1 - .../adapters/http-axum/src/extractors/mod.rs | 3 - crates/adapters/http-axum/src/html/forms.rs | 4 - crates/adapters/http-axum/src/lib.rs | 3 - .../http-axum/tests/log_review_form_test.rs | 3 +- crates/bootstrap/Cargo.toml | 1 + crates/bootstrap/tests/http_handlers.rs | 9 + .../tests/http_handlers/api_handlers_test.rs | 62 ++--- .../tests/http_handlers/app_context_test.rs | 8 +- .../tests/http_handlers/extractors_test.rs | 255 +----------------- .../bootstrap/tests/http_handlers/support.rs | 243 +++++++++++++++++ 12 files changed, 296 insertions(+), 297 deletions(-) create mode 100644 crates/bootstrap/tests/http_handlers.rs create mode 100644 crates/bootstrap/tests/http_handlers/support.rs diff --git a/Cargo.lock b/Cargo.lock index 300197eb..eda8a741 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2421,7 +2421,6 @@ dependencies = [ "async-trait", "axum", "axum-governor", - "bootstrap", "bytes", "chrono", "chrono-tz", diff --git a/crates/adapters/http-axum/Cargo.toml b/crates/adapters/http-axum/Cargo.toml index c7954a7a..45901788 100644 --- a/crates/adapters/http-axum/Cargo.toml +++ b/crates/adapters/http-axum/Cargo.toml @@ -43,4 +43,3 @@ bytes = { workspace = true } futures = { workspace = true } tower = { version = "0.5", features = ["util"] } domain = { workspace = true, features = ["test-helpers"] } -bootstrap = { workspace = true } diff --git a/crates/adapters/http-axum/src/extractors/mod.rs b/crates/adapters/http-axum/src/extractors/mod.rs index e4620219..539c7222 100644 --- a/crates/adapters/http-axum/src/extractors/mod.rs +++ b/crates/adapters/http-axum/src/extractors/mod.rs @@ -156,6 +156,3 @@ where } } } - -#[cfg(test)] -pub(crate) mod tests; diff --git a/crates/adapters/http-axum/src/html/forms.rs b/crates/adapters/http-axum/src/html/forms.rs index d5573800..835b18e9 100644 --- a/crates/adapters/http-axum/src/html/forms.rs +++ b/crates/adapters/http-axum/src/html/forms.rs @@ -310,7 +310,3 @@ pub struct DismissWatchForm { #[serde(rename = "_csrf", default)] pub csrf_token: String, } - -#[cfg(test)] -#[path = "forms_tests.rs"] -mod tests; diff --git a/crates/adapters/http-axum/src/lib.rs b/crates/adapters/http-axum/src/lib.rs index 76d8477f..2dd8736a 100644 --- a/crates/adapters/http-axum/src/lib.rs +++ b/crates/adapters/http-axum/src/lib.rs @@ -8,6 +8,3 @@ pub mod openapi; pub mod router; pub mod state; mod support; - -#[cfg(test)] -mod tests; diff --git a/crates/adapters/http-axum/tests/log_review_form_test.rs b/crates/adapters/http-axum/tests/log_review_form_test.rs index a7ef08a4..2e1c3da7 100644 --- a/crates/adapters/http-axum/tests/log_review_form_test.rs +++ b/crates/adapters/http-axum/tests/log_review_form_test.rs @@ -1,4 +1,5 @@ -use super::*; +use api_types::{DiaryQueryParams, LogReviewRequest}; +use http_axum::html::forms::{LogReviewData, LogReviewForm, to_diary_query}; fn make_form(watched_at: &str) -> LogReviewForm { LogReviewForm { diff --git a/crates/bootstrap/Cargo.toml b/crates/bootstrap/Cargo.toml index bb15f275..35303588 100644 --- a/crates/bootstrap/Cargo.toml +++ b/crates/bootstrap/Cargo.toml @@ -46,3 +46,4 @@ chrono = { workspace = true } uuid = { workspace = true } serde_json = { workspace = true } tower = { version = "0.5", features = ["util"] } +domain = { workspace = true, features = ["test-helpers"] } diff --git a/crates/bootstrap/tests/http_handlers.rs b/crates/bootstrap/tests/http_handlers.rs new file mode 100644 index 00000000..7456da07 --- /dev/null +++ b/crates/bootstrap/tests/http_handlers.rs @@ -0,0 +1,9 @@ +#[path = "http_handlers/support.rs"] +mod support; + +#[path = "http_handlers/api_handlers_test.rs"] +mod api_handlers_test; +#[path = "http_handlers/app_context_test.rs"] +mod app_context_test; +#[path = "http_handlers/extractors_test.rs"] +mod extractors_test; diff --git a/crates/bootstrap/tests/http_handlers/api_handlers_test.rs b/crates/bootstrap/tests/http_handlers/api_handlers_test.rs index 9062745f..29ae5b92 100644 --- a/crates/bootstrap/tests/http_handlers/api_handlers_test.rs +++ b/crates/bootstrap/tests/http_handlers/api_handlers_test.rs @@ -1,4 +1,4 @@ -use crate::extractors::tests::{AcceptingAuth, test_app_state, test_app_state_with_follow_graph}; +use crate::support::{AcceptingAuth, test_app_state, test_app_state_with_follow_graph}; use axum::{ Router, body::Body, @@ -73,9 +73,9 @@ impl domain::ports::PersonQuery for PersonQueryStub { #[tokio::test] async fn search_endpoint_returns_200_with_empty_results() { - let state = crate::extractors::tests::test_app_state_with_search_port(Arc::new(SearchPortStub)); + let state = crate::support::test_app_state_with_search_port(Arc::new(SearchPortStub)); let app = Router::new() - .route("/api/v1/search", get(crate::api::search::get_search)) + .route("/api/v1/search", get(http_axum::api::search::get_search)) .with_state(state); let resp = app @@ -93,9 +93,9 @@ async fn search_endpoint_returns_200_with_empty_results() { #[tokio::test] async fn search_endpoint_with_no_query_returns_200() { - let state = crate::extractors::tests::test_app_state_with_search_port(Arc::new(SearchPortStub)); + let state = crate::support::test_app_state_with_search_port(Arc::new(SearchPortStub)); let app = Router::new() - .route("/api/v1/search", get(crate::api::search::get_search)) + .route("/api/v1/search", get(http_axum::api::search::get_search)) .with_state(state); let resp = app @@ -115,12 +115,11 @@ async fn search_endpoint_with_no_query_returns_200() { #[tokio::test] async fn person_endpoint_returns_404_for_unknown_id() { - let state = - crate::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub)); + let state = crate::support::test_app_state_with_person_query(Arc::new(PersonQueryStub)); let app = Router::new() .route( "/api/v1/people/{id}", - get(crate::api::search::get_person_handler), + get(http_axum::api::search::get_person_handler), ) .with_state(state); @@ -140,12 +139,11 @@ async fn person_endpoint_returns_404_for_unknown_id() { #[tokio::test] async fn person_credits_endpoint_returns_404_for_unknown_id() { - let state = - crate::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub)); + let state = crate::support::test_app_state_with_person_query(Arc::new(PersonQueryStub)); let app = Router::new() .route( "/api/v1/people/{id}/credits", - get(crate::api::search::get_person_credits_handler), + get(http_axum::api::search::get_person_credits_handler), ) .with_state(state); @@ -166,7 +164,7 @@ async fn person_credits_endpoint_returns_404_for_unknown_id() { #[test] fn injected_stub_reaches_prebuilt_deps() { let stub: Arc = Arc::new(PersonQueryStub); - let state = crate::extractors::tests::test_app_state_with_person_query(Arc::clone(&stub)); + let state = crate::support::test_app_state_with_person_query(Arc::clone(&stub)); assert!( Arc::ptr_eq(&state.app_ctx.deps.person.get_person.person_query, &stub), @@ -183,7 +181,7 @@ async fn get_watchlist_requires_auth() { let app = Router::new() .route( "/api/v1/watchlist", - get(crate::api::watchlist::get_watchlist_handler), + get(http_axum::api::watchlist::get_watchlist_handler), ) .with_state(state); @@ -266,7 +264,7 @@ async fn pending_count_endpoint_reports_the_use_case_result() { let app = Router::new() .route( "/api/v1/social/followers/pending/count", - get(crate::api::social::get_pending_follower_count), + get(http_axum::api::social::get_pending_follower_count), ) .with_state(state); @@ -295,7 +293,7 @@ async fn get_watchlist_status_requires_auth() { let app = Router::new() .route( "/api/v1/watchlist/{movie_id}", - get(crate::api::watchlist::get_watchlist_status), + get(http_axum::api::watchlist::get_watchlist_status), ) .with_state(state); @@ -340,7 +338,7 @@ impl domain::ports::ApDocumentPort for FailingApDocument { } #[cfg(feature = "federation")] -fn state_with_failing_ap_document() -> crate::state::AppState { +fn state_with_failing_ap_document() -> http_axum::state::AppState { let mut state = test_app_state(); state.app_ctx.ap_document = Arc::new(FailingApDocument); state @@ -349,12 +347,7 @@ fn state_with_failing_ap_document() -> crate::state::AppState { #[cfg(feature = "federation")] #[tokio::test] async fn followers_collection_returns_500_on_federation_error() { - let app = Router::new() - .route( - "/users/{id}/followers", - get(crate::federation::collections::get_followers_collection), - ) - .with_state(state_with_failing_ap_document()); + let app = http_axum::federation::html_routes().with_state(state_with_failing_ap_document()); let resp = app .oneshot( @@ -373,12 +366,7 @@ async fn followers_collection_returns_500_on_federation_error() { #[cfg(feature = "federation")] #[tokio::test] async fn following_collection_returns_500_on_federation_error() { - let app = Router::new() - .route( - "/users/{id}/following", - get(crate::federation::collections::get_following_collection), - ) - .with_state(state_with_failing_ap_document()); + let app = http_axum::federation::html_routes().with_state(state_with_failing_ap_document()); let resp = app .oneshot( @@ -405,12 +393,11 @@ async fn refusal_body(resp: axum::response::Response) -> serde_json::Value { #[tokio::test] async fn a_missing_resource_is_refused_with_not_found_code() { - let state = - crate::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub)); + let state = crate::support::test_app_state_with_person_query(Arc::new(PersonQueryStub)); let app = Router::new() .route( "/api/v1/people/{id}", - get(crate::api::search::get_person_handler), + get(http_axum::api::search::get_person_handler), ) .with_state(state); @@ -435,7 +422,7 @@ async fn a_missing_credential_is_refused_with_unauthorized_code() { let app = Router::new() .route( "/api/v1/watchlist", - get(crate::api::watchlist::get_watchlist_handler), + get(http_axum::api::watchlist::get_watchlist_handler), ) .with_state(test_app_state()); @@ -455,12 +442,11 @@ async fn a_missing_credential_is_refused_with_unauthorized_code() { #[tokio::test] async fn a_malformed_path_parameter_is_refused_with_invalid_input_code() { - let state = - crate::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub)); + let state = crate::support::test_app_state_with_person_query(Arc::new(PersonQueryStub)); let app = Router::new() .route( "/api/v1/people/{id}", - get(crate::api::search::get_person_handler), + get(http_axum::api::search::get_person_handler), ) .with_state(state); @@ -483,7 +469,7 @@ async fn a_malformed_json_body_is_refused_with_invalid_input_code() { let app = Router::new() .route( "/api/v1/auth/login", - axum::routing::post(crate::api::auth::login), + axum::routing::post(http_axum::api::auth::login), ) .with_state(test_app_state()); @@ -510,7 +496,7 @@ async fn a_json_body_of_the_wrong_shape_is_refused_as_unprocessable() { let app = Router::new() .route( "/api/v1/auth/login", - axum::routing::post(crate::api::auth::login), + axum::routing::post(http_axum::api::auth::login), ) .with_state(test_app_state()); @@ -535,7 +521,7 @@ async fn a_non_multipart_upload_is_refused_with_invalid_input_code() { let app = Router::new() .route( "/api/v1/webhooks/plex", - axum::routing::post(crate::api::webhook::post_plex_webhook), + axum::routing::post(http_axum::api::webhook::post_plex_webhook), ) .with_state(test_app_state()); diff --git a/crates/bootstrap/tests/http_handlers/app_context_test.rs b/crates/bootstrap/tests/http_handlers/app_context_test.rs index b7896e4d..f8998d4c 100644 --- a/crates/bootstrap/tests/http_handlers/app_context_test.rs +++ b/crates/bootstrap/tests/http_handlers/app_context_test.rs @@ -2,14 +2,12 @@ fn prebuilt_deps_share_port_instances_with_repos() { use std::sync::Arc; - let repos = crate::extractors::tests::test_repositories(); + let repos = crate::support::test_repositories(); let user = Arc::clone(&repos.user); let follow_graph = Arc::clone(&repos.follow_graph); - let state = crate::extractors::tests::build_test_state_from( - repos, - Arc::new(domain::testing::PanicAuthService), - ); + let state = + crate::support::build_test_state_from(repos, Arc::new(domain::testing::PanicAuthService)); assert!( Arc::ptr_eq(&state.app_ctx.deps.users.get_local_profile.user, &user), diff --git a/crates/bootstrap/tests/http_handlers/extractors_test.rs b/crates/bootstrap/tests/http_handlers/extractors_test.rs index 1795bb7e..e49fd9a0 100644 --- a/crates/bootstrap/tests/http_handlers/extractors_test.rs +++ b/crates/bootstrap/tests/http_handlers/extractors_test.rs @@ -1,37 +1,23 @@ -use super::*; -use crate::context::AppContext; -use application::config::AppConfig; +use std::sync::Arc; + use axum::{ Router, body::Body, http::{Request, StatusCode}, routing::get, }; -use bootstrap::Repositories; use domain::{ - errors::DomainError, - models::GeneratedToken, - ports::{AuthService, FollowGraphQuery, PersonQuery, SearchPort, UserRepository}, - testing::{ - InMemoryStore, PanicAuthService, PanicDiaryExporter, PanicDiaryQuery, PanicDocumentParser, - PanicEventPublisher, PanicGoalCommand, PanicGoalQuery, PanicImportProfileRepository, - PanicImportSessionRepository, PanicInsightsQuery, PanicMetadataClient, PanicMovieCommand, - PanicMovieProfileRepository, PanicMovieQuery, PanicObjectStorage, PanicPasswordHasher, - PanicPersonCommand, PanicPersonQuery, PanicPosterFetcher, PanicProfileFieldsRepo, - PanicRefreshSessionRepository, PanicRemoteGoalRepository, PanicRemoteWatchlistRepository, - PanicReviewRepository, PanicRssFeedRenderer, PanicSearchCommand, PanicSearchPort, - PanicStatsRepository, PanicUserRepository, PanicUserSettingsRepository, - PanicWatchEventCommand, PanicWatchEventQuery, PanicWatchlistRepository, - PanicWebhookTokenRepository, PanicWrapUpRepository, PanicWrapUpStatsQuery, - }, + errors::DomainError, models::GeneratedToken, ports::AuthService, testing::InMemoryStore, value_objects::UserId, }; -use std::sync::Arc; +use http_axum::extractors::{ + AdminApiUser, AdminUser, AuthenticatedUser, OptionalCookieUser, RequiredCookieUser, +}; use tower::ServiceExt; -// --- Local stubs --- - -struct PanicReviewLogger; +use crate::support::{ + AcceptingAuth, ErroringUserRepo, make_test_state, test_app_state, test_app_state_with_user, +}; struct RejectingAuth; #[async_trait::async_trait] @@ -44,219 +30,6 @@ impl AuthService for RejectingAuth { } } -#[async_trait::async_trait] -impl application::ports::ReviewLogger for PanicReviewLogger { - async fn log_review( - &self, - _: application::diary::commands::LogReviewCommand, - ) -> Result<(), DomainError> { - panic!() - } -} - -// --- Repositories factory — `Panic*` stubs everywhere --- - -pub(crate) fn test_repositories() -> Repositories { - Repositories { - movie_command: Arc::new(PanicMovieCommand) as _, - movie_query: Arc::new(PanicMovieQuery) as _, - review: Arc::new(PanicReviewRepository) as _, - diary: Arc::new(PanicDiaryQuery) as _, - stats: Arc::new(PanicStatsRepository) as _, - user: Arc::new(PanicUserRepository) as _, - import_session: Arc::new(PanicImportSessionRepository) as _, - import_profile: Arc::new(PanicImportProfileRepository) as _, - movie_profile: Arc::new(PanicMovieProfileRepository) as _, - watchlist: Arc::new(PanicWatchlistRepository) as _, - watch_event_command: Arc::new(PanicWatchEventCommand) as _, - watch_event_query: Arc::new(PanicWatchEventQuery) as _, - webhook_token: Arc::new(PanicWebhookTokenRepository) as _, - profile_fields: Arc::new(PanicProfileFieldsRepo) as _, - person_command: Arc::new(PanicPersonCommand) as _, - person_query: Arc::new(PanicPersonQuery) as _, - search_port: Arc::new(PanicSearchPort) as _, - search_command: Arc::new(PanicSearchCommand) as _, - remote_watchlist: Arc::new(PanicRemoteWatchlistRepository) as _, - social_command: Arc::new(domain::ports::noop::NoopSocialCommand) as _, - follow_graph: Arc::new(domain::ports::noop::NoopSocialQuery) as _, - block_query: Arc::new(domain::ports::noop::NoopSocialQuery) as _, - federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery) as _, - wrapup_stats: Arc::new(PanicWrapUpStatsQuery) as _, - wrapup_repo: Arc::new(PanicWrapUpRepository) as _, - insights: Arc::new(PanicInsightsQuery) as _, - goal_command: Arc::new(PanicGoalCommand) as _, - goal_query: Arc::new(PanicGoalQuery) as _, - user_settings: Arc::new(PanicUserSettingsRepository) as _, - remote_goal: Arc::new(PanicRemoteGoalRepository) as _, - refresh_session: Arc::new(PanicRefreshSessionRepository) as _, - federated_profile: None, - } -} - -// --- Single state factory — builds `deps` from whatever `repos` it is given --- - -/// Builds an `AppState` from caller-supplied `repos`, running `bootstrap::build_deps` -/// AFTER any overrides the caller already applied to `repos`. This is the only place -/// `deps` gets built for tests, so `repos` and `deps` can never disagree. -pub(crate) fn build_test_state_from( - repos: Repositories, - auth_service: Arc, -) -> crate::state::AppState { - let services = application::Services { - auth: auth_service, - password_hasher: Arc::new(PanicPasswordHasher) as _, - metadata: Arc::new(PanicMetadataClient) as _, - poster_fetcher: Arc::new(PanicPosterFetcher) as _, - object_storage: Arc::new(PanicObjectStorage) as _, - event_publisher: Arc::new(PanicEventPublisher) as _, - diary_exporter: Arc::new(PanicDiaryExporter) as _, - document_parser: Arc::new(PanicDocumentParser) as _, - review_logger: Arc::new(PanicReviewLogger) as _, - 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)); - - crate::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(PanicRssFeedRenderer), - } -} - -pub fn make_test_state(auth_service: Arc) -> crate::state::AppState { - build_test_state_from(test_repositories(), auth_service) -} - -/// Reusable default test `AppState`: `Panic*` stubs everywhere, real prebuilt `deps`. -/// Callers that need a specific stub swapped in should use one of the -/// `test_app_state_with_*` constructors below, which inject BEFORE `build_deps` -/// runs — mutating the returned `AppState` afterwards would only reach `repos`, -/// not the prebuilt `deps`. -pub(crate) fn test_app_state() -> crate::state::AppState { - make_test_state(Arc::new(PanicAuthService)) -} - -/// Test state with `person_query` overridden BEFORE `build_deps` runs, so the -/// stub is visible from both `app_ctx.repos.person_query` and every prebuilt -/// deps struct that carries a `person_query` (e.g. `deps.person.get_person`). -pub(crate) fn test_app_state_with_person_query( - person_query: Arc, -) -> crate::state::AppState { - let mut repos = test_repositories(); - repos.person_query = person_query; - build_test_state_from(repos, Arc::new(PanicAuthService)) -} - -/// Test state with `search_port` overridden BEFORE `build_deps` runs, so the -/// stub is visible from both `app_ctx.repos.search_port` and any prebuilt deps -/// struct that carries a `search_port`. -pub(crate) fn test_app_state_with_search_port( - search_port: Arc, -) -> crate::state::AppState { - let mut repos = test_repositories(); - repos.search_port = search_port; - build_test_state_from(repos, Arc::new(PanicAuthService)) -} - -/// Test state with `follow_graph` overridden BEFORE `build_deps` runs, so the -/// stub is visible from both `app_ctx.repos.follow_graph` and the prebuilt -/// `deps.social.query` that the social handlers actually read from. -pub(crate) fn test_app_state_with_follow_graph( - follow_graph: Arc, - auth_service: Arc, -) -> crate::state::AppState { - let mut repos = test_repositories(); - repos.follow_graph = follow_graph; - build_test_state_from(repos, auth_service) -} - -/// Test state with `user` overridden BEFORE `build_deps` runs, so the stub is -/// visible from both `app_ctx.repos.user` and `deps.users.authorize_admin.user` -/// — the field the `AdminApiUser`/`AdminUser` extractors now read through. -pub(crate) fn test_app_state_with_user( - user: Arc, - auth_service: Arc, -) -> crate::state::AppState { - let mut repos = test_repositories(); - repos.user = user; - build_test_state_from(repos, auth_service) -} - -/// A `UserRepository` whose `find_by_id` always fails, for exercising the -/// repository-error rejection path of the admin extractors. Every other -/// method panics — the admin extractors only ever call `find_by_id`. -pub(crate) struct ErroringUserRepo; -#[async_trait::async_trait] -impl UserRepository for ErroringUserRepo { - async fn find_by_email( - &self, - _: &domain::value_objects::Email, - ) -> Result, DomainError> { - panic!() - } - async fn save(&self, _: &domain::models::User) -> Result<(), DomainError> { - panic!() - } - async fn find_by_id(&self, _: &UserId) -> Result, DomainError> { - Err(DomainError::InfrastructureError("db down".into())) - } - async fn find_by_username( - &self, - _: &domain::value_objects::Username, - ) -> Result, DomainError> { - panic!() - } - async fn list_with_stats(&self) -> Result, DomainError> { - panic!() - } - async fn update_profile( - &self, - _: &UserId, - _: &domain::models::UserProfile, - ) -> Result<(), DomainError> { - panic!() - } -} - -/// Auth stub that accepts any bearer token and resolves it to a fixed user id, -/// for tests that need to authenticate as a specific user rather than merely -/// exercise the unauthenticated-request path. -pub(crate) struct AcceptingAuth(pub UserId); -#[async_trait::async_trait] -impl AuthService for AcceptingAuth { - async fn generate_token(&self, _: &UserId) -> Result { - panic!() - } - async fn validate_token(&self, _: &str) -> Result { - Ok(self.0.clone()) - } -} - // --- Routers --- async fn protected_handler(user: AuthenticatedUser) -> String { @@ -278,27 +51,27 @@ async fn admin_cookie_handler(user: AdminUser) -> String { user.0.value().to_string() } -fn router_protected(state: crate::state::AppState) -> Router { +fn router_protected(state: http_axum::state::AppState) -> Router { Router::new() .route("/protected", get(protected_handler)) .with_state(state) } -fn router_optional(state: crate::state::AppState) -> Router { +fn router_optional(state: http_axum::state::AppState) -> Router { Router::new() .route("/optional", get(optional_cookie_handler)) .with_state(state) } -fn router_admin_api(state: crate::state::AppState) -> Router { +fn router_admin_api(state: http_axum::state::AppState) -> Router { Router::new() .route("/admin-api", get(admin_api_handler)) .with_state(state) } -fn router_admin_cookie(state: crate::state::AppState) -> Router { +fn router_admin_cookie(state: http_axum::state::AppState) -> Router { Router::new() .route("/admin-cookie", get(admin_cookie_handler)) .with_state(state) } -fn router_required(state: crate::state::AppState) -> Router { +fn router_required(state: http_axum::state::AppState) -> Router { Router::new() .route("/required", get(required_cookie_handler)) .with_state(state) diff --git a/crates/bootstrap/tests/http_handlers/support.rs b/crates/bootstrap/tests/http_handlers/support.rs new file mode 100644 index 00000000..16b57e7a --- /dev/null +++ b/crates/bootstrap/tests/http_handlers/support.rs @@ -0,0 +1,243 @@ +//! `AppState` fixtures: `Panic*` stubs everywhere, real prebuilt `deps` built through +//! `bootstrap::build_deps`, with per-port overrides injected BEFORE the deps are built. +#![allow(dead_code)] + +use application::config::AppConfig; +use bootstrap::Repositories; +use domain::{ + errors::DomainError, + models::GeneratedToken, + ports::{AuthService, FollowGraphQuery, PersonQuery, SearchPort, UserRepository}, + testing::{ + PanicAuthService, PanicDiaryExporter, PanicDiaryQuery, PanicDocumentParser, + PanicEventPublisher, PanicGoalCommand, PanicGoalQuery, PanicImportProfileRepository, + PanicImportSessionRepository, PanicInsightsQuery, PanicMetadataClient, PanicMovieCommand, + PanicMovieProfileRepository, PanicMovieQuery, PanicObjectStorage, PanicPasswordHasher, + PanicPersonCommand, PanicPersonQuery, PanicPosterFetcher, PanicProfileFieldsRepo, + PanicRefreshSessionRepository, PanicRemoteGoalRepository, PanicRemoteWatchlistRepository, + PanicReviewRepository, PanicRssFeedRenderer, PanicSearchCommand, PanicSearchPort, + PanicStatsRepository, PanicUserRepository, PanicUserSettingsRepository, + PanicWatchEventCommand, PanicWatchEventQuery, PanicWatchlistRepository, + PanicWebhookTokenRepository, PanicWrapUpRepository, PanicWrapUpStatsQuery, + }, + value_objects::UserId, +}; +use http_axum::context::AppContext; +use std::sync::Arc; + +// --- Local stubs --- + +struct PanicReviewLogger; + +#[async_trait::async_trait] +impl application::ports::ReviewLogger for PanicReviewLogger { + async fn log_review( + &self, + _: application::diary::commands::LogReviewCommand, + ) -> Result<(), DomainError> { + panic!() + } +} + +// --- Repositories factory — `Panic*` stubs everywhere --- + +pub fn test_repositories() -> Repositories { + Repositories { + movie_command: Arc::new(PanicMovieCommand) as _, + movie_query: Arc::new(PanicMovieQuery) as _, + review: Arc::new(PanicReviewRepository) as _, + diary: Arc::new(PanicDiaryQuery) as _, + stats: Arc::new(PanicStatsRepository) as _, + user: Arc::new(PanicUserRepository) as _, + import_session: Arc::new(PanicImportSessionRepository) as _, + import_profile: Arc::new(PanicImportProfileRepository) as _, + movie_profile: Arc::new(PanicMovieProfileRepository) as _, + watchlist: Arc::new(PanicWatchlistRepository) as _, + watch_event_command: Arc::new(PanicWatchEventCommand) as _, + watch_event_query: Arc::new(PanicWatchEventQuery) as _, + webhook_token: Arc::new(PanicWebhookTokenRepository) as _, + profile_fields: Arc::new(PanicProfileFieldsRepo) as _, + person_command: Arc::new(PanicPersonCommand) as _, + person_query: Arc::new(PanicPersonQuery) as _, + search_port: Arc::new(PanicSearchPort) as _, + search_command: Arc::new(PanicSearchCommand) as _, + remote_watchlist: Arc::new(PanicRemoteWatchlistRepository) as _, + social_command: Arc::new(domain::ports::noop::NoopSocialCommand) as _, + follow_graph: Arc::new(domain::ports::noop::NoopSocialQuery) as _, + block_query: Arc::new(domain::ports::noop::NoopSocialQuery) as _, + federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery) as _, + wrapup_stats: Arc::new(PanicWrapUpStatsQuery) as _, + wrapup_repo: Arc::new(PanicWrapUpRepository) as _, + insights: Arc::new(PanicInsightsQuery) as _, + goal_command: Arc::new(PanicGoalCommand) as _, + goal_query: Arc::new(PanicGoalQuery) as _, + user_settings: Arc::new(PanicUserSettingsRepository) as _, + remote_goal: Arc::new(PanicRemoteGoalRepository) as _, + refresh_session: Arc::new(PanicRefreshSessionRepository) as _, + federated_profile: None, + } +} + +// --- Single state factory — builds `deps` from whatever `repos` it is given --- + +/// Builds an `AppState` from caller-supplied `repos`, running `bootstrap::build_deps` +/// AFTER any overrides the caller already applied to `repos`. This is the only place +/// `deps` gets built for tests, so `repos` and `deps` can never disagree. +pub fn build_test_state_from( + repos: Repositories, + auth_service: Arc, +) -> http_axum::state::AppState { + let services = application::Services { + auth: auth_service, + password_hasher: Arc::new(PanicPasswordHasher) as _, + metadata: Arc::new(PanicMetadataClient) as _, + poster_fetcher: Arc::new(PanicPosterFetcher) as _, + object_storage: Arc::new(PanicObjectStorage) as _, + event_publisher: Arc::new(PanicEventPublisher) as _, + diary_exporter: Arc::new(PanicDiaryExporter) as _, + document_parser: Arc::new(PanicDocumentParser) as _, + review_logger: Arc::new(PanicReviewLogger) as _, + 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)); + + http_axum::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(PanicRssFeedRenderer), + } +} + +pub fn make_test_state(auth_service: Arc) -> http_axum::state::AppState { + build_test_state_from(test_repositories(), auth_service) +} + +/// Reusable default test `AppState`: `Panic*` stubs everywhere, real prebuilt `deps`. +/// Callers that need a specific stub swapped in should use one of the +/// `test_app_state_with_*` constructors below, which inject BEFORE `build_deps` +/// runs — mutating the returned `AppState` afterwards would only reach `repos`, +/// not the prebuilt `deps`. +pub fn test_app_state() -> http_axum::state::AppState { + make_test_state(Arc::new(PanicAuthService)) +} + +/// Test state with `person_query` overridden BEFORE `build_deps` runs, so the +/// stub is visible from both `app_ctx.repos.person_query` and every prebuilt +/// deps struct that carries a `person_query` (e.g. `deps.person.get_person`). +pub fn test_app_state_with_person_query( + person_query: Arc, +) -> http_axum::state::AppState { + let mut repos = test_repositories(); + repos.person_query = person_query; + build_test_state_from(repos, Arc::new(PanicAuthService)) +} + +/// Test state with `search_port` overridden BEFORE `build_deps` runs, so the +/// stub is visible from both `app_ctx.repos.search_port` and any prebuilt deps +/// struct that carries a `search_port`. +pub fn test_app_state_with_search_port( + search_port: Arc, +) -> http_axum::state::AppState { + let mut repos = test_repositories(); + repos.search_port = search_port; + build_test_state_from(repos, Arc::new(PanicAuthService)) +} + +/// Test state with `follow_graph` overridden BEFORE `build_deps` runs, so the +/// stub is visible from both `app_ctx.repos.follow_graph` and the prebuilt +/// `deps.social.query` that the social handlers actually read from. +pub fn test_app_state_with_follow_graph( + follow_graph: Arc, + auth_service: Arc, +) -> http_axum::state::AppState { + let mut repos = test_repositories(); + repos.follow_graph = follow_graph; + build_test_state_from(repos, auth_service) +} + +/// Test state with `user` overridden BEFORE `build_deps` runs, so the stub is +/// visible from both `app_ctx.repos.user` and `deps.users.authorize_admin.user` +/// — the field the `AdminApiUser`/`AdminUser` extractors now read through. +pub fn test_app_state_with_user( + user: Arc, + auth_service: Arc, +) -> http_axum::state::AppState { + let mut repos = test_repositories(); + repos.user = user; + build_test_state_from(repos, auth_service) +} + +/// A `UserRepository` whose `find_by_id` always fails, for exercising the +/// repository-error rejection path of the admin extractors. Every other +/// method panics — the admin extractors only ever call `find_by_id`. +pub struct ErroringUserRepo; +#[async_trait::async_trait] +impl UserRepository for ErroringUserRepo { + async fn find_by_email( + &self, + _: &domain::value_objects::Email, + ) -> Result, DomainError> { + panic!() + } + async fn save(&self, _: &domain::models::User) -> Result<(), DomainError> { + panic!() + } + async fn find_by_id(&self, _: &UserId) -> Result, DomainError> { + Err(DomainError::InfrastructureError("db down".into())) + } + async fn find_by_username( + &self, + _: &domain::value_objects::Username, + ) -> Result, DomainError> { + panic!() + } + async fn list_with_stats(&self) -> Result, DomainError> { + panic!() + } + async fn update_profile( + &self, + _: &UserId, + _: &domain::models::UserProfile, + ) -> Result<(), DomainError> { + panic!() + } +} + +/// Auth stub that accepts any bearer token and resolves it to a fixed user id, +/// for tests that need to authenticate as a specific user rather than merely +/// exercise the unauthenticated-request path. +pub struct AcceptingAuth(pub UserId); +#[async_trait::async_trait] +impl AuthService for AcceptingAuth { + async fn generate_token(&self, _: &UserId) -> Result { + panic!() + } + async fn validate_token(&self, _: &str) -> Result { + Ok(self.0.clone()) + } +}