Refactor test stubs and consolidate panic implementations

- Removed redundant panic repository implementations in `event_handlers.rs` and `extractors.rs`.
- Introduced a single `Panic` struct to serve as a stub for various repository and service traits.
- Simplified test state creation by using a factory function `make_test_state` to reduce code duplication.
- Updated tests to utilize the new panic implementation and streamlined router setup for better readability.
This commit is contained in:
2026-05-09 20:07:44 +02:00
parent 89e78a0d1f
commit 1eaa3ca8a6
3 changed files with 143 additions and 479 deletions

View File

@@ -336,14 +336,6 @@ mod tests {
assert!(data.external_metadata_id.is_none()); assert!(data.external_metadata_id.is_none());
} }
#[test]
fn into_command_sets_user_id() {
let data = LogReviewData::try_from(make_form("2024-03-15T20:30:00")).unwrap();
let user_id = Uuid::new_v4();
let cmd = data.into_command(user_id);
assert_eq!(cmd.user_id, user_id);
}
#[test] #[test]
fn sort_by_asc_string_becomes_ascending() { fn sort_by_asc_string_becomes_ascending() {
let params = DiaryQueryParams { let params = DiaryQueryParams {
@@ -368,38 +360,6 @@ mod tests {
assert!(matches!(query.sort_by, Some(domain::models::SortDirection::Descending))); assert!(matches!(query.sort_by, Some(domain::models::SortDirection::Descending)));
} }
#[test]
fn diary_response_serializes_correctly() {
let resp = DiaryResponse {
items: vec![],
total_count: 0,
limit: 20,
offset: 0,
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"total_count\":0"));
assert!(json.contains("\"items\":[]"));
}
#[test]
fn diary_query_params_fields_are_optional() {
let params = DiaryQueryParams {
limit: None,
offset: None,
sort_by: None,
movie_id: None,
};
assert!(params.limit.is_none());
assert!(params.sort_by.is_none());
}
#[test]
fn login_request_deserializes() {
let json = r#"{"email":"a@b.com","password":"secret"}"#;
let req: LoginRequest = serde_json::from_str(json).unwrap();
assert_eq!(req.email, "a@b.com");
}
#[test] #[test]
fn form_accepts_date_only() { fn form_accepts_date_only() {
let data = LogReviewData::try_from(make_form("2024-03-15")).unwrap(); let data = LogReviewData::try_from(make_form("2024-03-15")).unwrap();

View File

@@ -59,137 +59,3 @@ impl EventHandler for PosterSyncHandler {
Err(err) Err(err)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use application::config::AppConfig;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, User, UserStats, UserTrends, collections::{PageParams, Paginated}},
ports::{
AuthService, DiaryRepository, EventPublisher, GeneratedToken, MetadataClient,
MetadataSearchCriteria, MovieRepository, PasswordHasher, PosterFetcherClient,
PosterStorage, ReviewRepository, StatsRepository, UserRepository,
},
value_objects::{
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl,
Rating, ReleaseYear, ReviewId, UserId,
},
};
struct PanicRepo;
struct PanicMetadata;
struct PanicFetcher;
struct PanicStorage;
struct PanicAuth;
struct PanicHasher;
struct PanicUserRepo;
struct NoopPublisher;
#[async_trait]
impl MovieRepository for PanicRepo {
async fn get_movie_by_external_id(&self, _: &ExternalMetadataId) -> Result<Option<Movie>, DomainError> { panic!("unexpected") }
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> { panic!("unexpected") }
async fn get_movies_by_title_and_year(&self, _: &MovieTitle, _: &ReleaseYear) -> Result<Vec<Movie>, DomainError> { panic!("unexpected") }
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!("unexpected") }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!("unexpected") }
}
#[async_trait]
impl ReviewRepository for PanicRepo {
async fn save_review(&self, _: &Review) -> Result<DomainEvent, DomainError> { panic!("unexpected") }
async fn get_review_by_id(&self, _: &ReviewId) -> Result<Option<Review>, DomainError> { panic!("unexpected") }
async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { panic!("unexpected") }
}
#[async_trait]
impl DiaryRepository for PanicRepo {
async fn query_diary(&self, _: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> { panic!("unexpected") }
async fn query_activity_feed(&self, _: &PageParams) -> Result<Paginated<FeedEntry>, DomainError> { panic!("unexpected") }
async fn get_review_history(&self, _: &MovieId) -> Result<ReviewHistory, DomainError> { panic!("unexpected") }
async fn get_user_history(&self, _: &UserId) -> Result<Vec<DiaryEntry>, DomainError> { panic!("unexpected") }
}
#[async_trait]
impl StatsRepository for PanicRepo {
async fn get_user_stats(&self, _: &UserId) -> Result<UserStats, DomainError> { panic!("unexpected") }
async fn get_user_trends(&self, _: &UserId) -> Result<UserTrends, DomainError> { panic!("unexpected") }
}
#[async_trait]
impl MetadataClient for PanicMetadata {
async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result<Movie, DomainError> { panic!("unexpected") }
async fn get_poster_url(&self, _: &ExternalMetadataId) -> Result<Option<PosterUrl>, DomainError> { panic!("unexpected") }
}
#[async_trait]
impl PosterFetcherClient for PanicFetcher {
async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result<Vec<u8>, DomainError> { panic!("unexpected") }
}
#[async_trait]
impl PosterStorage for PanicStorage {
async fn store_poster(&self, _: &MovieId, _: &[u8]) -> Result<PosterPath, DomainError> { panic!("unexpected") }
async fn get_poster(&self, _: &PosterPath) -> Result<Vec<u8>, DomainError> { panic!("unexpected") }
}
#[async_trait]
impl AuthService for PanicAuth {
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> { panic!("unexpected") }
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> { panic!("unexpected") }
}
#[async_trait]
impl PasswordHasher for PanicHasher {
async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> { panic!("unexpected") }
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> { panic!("unexpected") }
}
#[async_trait]
impl UserRepository for PanicUserRepo {
async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> { panic!("unexpected") }
async fn save(&self, _: &User) -> Result<(), DomainError> { panic!("unexpected") }
async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<User>, DomainError> { panic!("unexpected") }
async fn find_by_username(&self, _: &domain::value_objects::Username) -> Result<Option<User>, DomainError> { panic!("unexpected") }
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> { panic!("unexpected") }
}
#[async_trait]
impl EventPublisher for NoopPublisher {
async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> { Ok(()) }
}
fn panic_ctx() -> AppContext {
let repo = Arc::new(PanicRepo);
AppContext {
movie_repository: Arc::clone(&repo) as _,
review_repository: Arc::clone(&repo) as _,
diary_repository: Arc::clone(&repo) as _,
stats_repository: repo as _,
metadata_client: Arc::new(PanicMetadata),
poster_fetcher: Arc::new(PanicFetcher),
poster_storage: Arc::new(PanicStorage),
event_publisher: Arc::new(NoopPublisher),
auth_service: Arc::new(PanicAuth),
password_hasher: Arc::new(PanicHasher),
user_repository: Arc::new(PanicUserRepo),
config: AppConfig { allow_registration: false, base_url: "http://localhost:3000".to_string(), rate_limit: 20 },
}
}
#[tokio::test]
async fn review_logged_is_ignored() {
let handler = PosterSyncHandler::new(panic_ctx(), 3);
let event = DomainEvent::ReviewLogged {
review_id: ReviewId::generate(),
movie_id: MovieId::generate(),
user_id: UserId::generate(),
rating: Rating::new(4).unwrap(),
watched_at: chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap().and_hms_opt(0, 0, 0).unwrap(),
};
assert!(handler.handle(&event).await.is_ok());
}
}

View File

@@ -98,356 +98,194 @@ where
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::sync::Arc;
use axum::{ use axum::{
body::Body, body::Body,
http::{Request, StatusCode}, http::{Request, StatusCode},
routing::get, routing::get,
Router, Router,
}; };
use application::{config::AppConfig, context::AppContext};
use domain::{
errors::DomainError,
events::DomainEvent,
models::{DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, UserStats, UserTrends, collections::{PageParams, Paginated}},
ports::{
AuthService, DiaryRepository, EventPublisher, GeneratedToken, MetadataClient,
MovieRepository, PasswordHasher, PosterFetcherClient, PosterStorage,
ReviewRepository, StatsRepository, UserRepository,
},
value_objects::{
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl,
ReleaseYear, ReviewId, UserId,
},
};
use tower::ServiceExt; use tower::ServiceExt;
async fn protected_handler(user: AuthenticatedUser) -> String { // --- Panic stubs (defined once) ---
user.0.value().to_string()
struct Panic;
#[async_trait::async_trait]
impl MovieRepository for Panic {
async fn get_movie_by_external_id(&self, _: &ExternalMetadataId) -> Result<Option<Movie>, DomainError> { panic!() }
async fn get_movie_by_id(&self, _: &MovieId) -> Result<Option<Movie>, DomainError> { panic!() }
async fn get_movies_by_title_and_year(&self, _: &MovieTitle, _: &ReleaseYear) -> Result<Vec<Movie>, DomainError> { panic!() }
async fn upsert_movie(&self, _: &Movie) -> Result<(), DomainError> { panic!() }
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> { panic!() }
} }
#[async_trait::async_trait]
fn test_router(state: crate::state::AppState) -> Router { impl ReviewRepository for Panic {
Router::new() async fn save_review(&self, _: &Review) -> Result<DomainEvent, DomainError> { panic!() }
.route("/protected", get(protected_handler)) async fn get_review_by_id(&self, _: &ReviewId) -> Result<Option<Review>, DomainError> { panic!() }
.with_state(state) async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { panic!() }
} }
#[async_trait::async_trait]
#[tokio::test] impl DiaryRepository for Panic {
async fn missing_auth_header_returns_401() { async fn query_diary(&self, _: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> { panic!() }
use std::sync::Arc; async fn query_activity_feed(&self, _: &PageParams) -> Result<Paginated<FeedEntry>, DomainError> { panic!() }
use application::context::AppContext; async fn get_review_history(&self, _: &MovieId) -> Result<ReviewHistory, DomainError> { panic!() }
async fn get_user_history(&self, _: &UserId) -> Result<Vec<DiaryEntry>, DomainError> { panic!() }
struct PanicRepo;
#[async_trait::async_trait]
impl domain::ports::MovieRepository for PanicRepo {
async fn get_movie_by_external_id(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::models::Movie>, domain::errors::DomainError> { panic!() }
async fn get_movie_by_id(&self, _: &domain::value_objects::MovieId) -> Result<Option<domain::models::Movie>, domain::errors::DomainError> { panic!() }
async fn get_movies_by_title_and_year(&self, _: &domain::value_objects::MovieTitle, _: &domain::value_objects::ReleaseYear) -> Result<Vec<domain::models::Movie>, domain::errors::DomainError> { panic!() }
async fn upsert_movie(&self, _: &domain::models::Movie) -> Result<(), domain::errors::DomainError> { panic!() }
async fn delete_movie(&self, _: &domain::value_objects::MovieId) -> Result<(), domain::errors::DomainError> { panic!() }
}
#[async_trait::async_trait]
impl domain::ports::ReviewRepository for PanicRepo {
async fn save_review(&self, _: &domain::models::Review) -> Result<domain::events::DomainEvent, domain::errors::DomainError> { panic!() }
async fn get_review_by_id(&self, _: &domain::value_objects::ReviewId) -> Result<Option<domain::models::Review>, domain::errors::DomainError> { panic!() }
async fn delete_review(&self, _: &domain::value_objects::ReviewId) -> Result<(), domain::errors::DomainError> { panic!() }
}
#[async_trait::async_trait]
impl domain::ports::DiaryRepository for PanicRepo {
async fn query_diary(&self, _: &domain::models::DiaryFilter) -> Result<domain::models::collections::Paginated<domain::models::DiaryEntry>, domain::errors::DomainError> { panic!() }
async fn query_activity_feed(&self, _: &domain::models::collections::PageParams) -> Result<domain::models::collections::Paginated<domain::models::FeedEntry>, domain::errors::DomainError> { panic!() }
async fn get_review_history(&self, _: &domain::value_objects::MovieId) -> Result<domain::models::ReviewHistory, domain::errors::DomainError> { panic!() }
async fn get_user_history(&self, _: &domain::value_objects::UserId) -> Result<Vec<domain::models::DiaryEntry>, domain::errors::DomainError> { panic!() }
}
#[async_trait::async_trait]
impl domain::ports::StatsRepository for PanicRepo {
async fn get_user_stats(&self, _: &domain::value_objects::UserId) -> Result<domain::models::UserStats, domain::errors::DomainError> { panic!() }
async fn get_user_trends(&self, _: &domain::value_objects::UserId) -> Result<domain::models::UserTrends, domain::errors::DomainError> { panic!() }
}
struct PanicRenderer;
impl crate::ports::HtmlRenderer for PanicRenderer {
fn render_diary_page(&self, _: &domain::models::collections::Paginated<domain::models::DiaryEntry>, _: application::ports::HtmlPageContext) -> Result<String, String> { panic!() }
fn render_login_page(&self, _: application::ports::LoginPageData<'_>) -> Result<String, String> { panic!() }
fn render_register_page(&self, _: application::ports::RegisterPageData<'_>) -> Result<String, String> { panic!() }
fn render_new_review_page(&self, _: application::ports::NewReviewPageData<'_>) -> Result<String, String> { panic!() }
fn render_activity_feed_page(&self, _: application::ports::ActivityFeedPageData) -> Result<String, String> { panic!() }
fn render_users_page(&self, _: application::ports::UsersPageData) -> Result<String, String> { panic!() }
fn render_profile_page(&self, _: application::ports::ProfilePageData) -> Result<String, String> { panic!() }
fn render_following_page(&self, _: application::ports::FollowingPageData) -> Result<String, String> { panic!() }
fn render_followers_page(&self, _: application::ports::FollowersPageData) -> Result<String, String> { panic!() }
}
struct PanicRssRenderer;
impl crate::ports::RssFeedRenderer for PanicRssRenderer {
fn render_feed(&self, _: &[domain::models::DiaryEntry], _: &str) -> Result<String, String> { panic!() }
}
struct PanicMeta; struct PanicFetcher; struct PanicStorage; struct PanicEvent; struct PanicHasher; struct PanicAuth; struct PanicUserRepo;
#[async_trait::async_trait] impl domain::ports::MetadataClient for PanicMeta { async fn fetch_movie_metadata(&self, _: &domain::ports::MetadataSearchCriteria) -> Result<domain::models::Movie, domain::errors::DomainError> { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::value_objects::PosterUrl>, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::PosterFetcherClient for PanicFetcher { async fn fetch_poster_bytes(&self, _: &domain::value_objects::PosterUrl) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::PosterStorage for PanicStorage { async fn store_poster(&self, _: &domain::value_objects::MovieId, _: &[u8]) -> Result<domain::value_objects::PosterPath, domain::errors::DomainError> { panic!() } async fn get_poster(&self, _: &domain::value_objects::PosterPath) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::EventPublisher for PanicEvent { async fn publish(&self, _: &domain::events::DomainEvent) -> Result<(), domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::PasswordHasher for PanicHasher { async fn hash(&self, _: &str) -> Result<domain::value_objects::PasswordHash, domain::errors::DomainError> { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result<bool, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::AuthService for PanicAuth { async fn generate_token(&self, _: &domain::value_objects::UserId) -> Result<domain::ports::GeneratedToken, domain::errors::DomainError> { panic!() } async fn validate_token(&self, _: &str) -> Result<domain::value_objects::UserId, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn find_by_username(&self, _: &domain::value_objects::Username) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, domain::errors::DomainError> { panic!() } }
let state = crate::state::AppState {
app_ctx: AppContext {
movie_repository: Arc::new(PanicRepo) as _,
review_repository: Arc::new(PanicRepo) as _,
diary_repository: Arc::new(PanicRepo) as _,
stats_repository: Arc::new(PanicRepo) as _,
metadata_client: Arc::new(PanicMeta),
poster_fetcher: Arc::new(PanicFetcher),
poster_storage: Arc::new(PanicStorage),
event_publisher: Arc::new(PanicEvent),
auth_service: Arc::new(PanicAuth),
password_hasher: Arc::new(PanicHasher),
user_repository: Arc::new(PanicUserRepo),
config: application::config::AppConfig { allow_registration: false, base_url: "http://localhost:3000".to_string(), rate_limit: 20 },
},
html_renderer: Arc::new(PanicRenderer),
rss_renderer: Arc::new(PanicRssRenderer),
ap_service: std::sync::Arc::new(activitypub::NoopActivityPubService),
};
let app = test_router(state);
let response = app
.oneshot(
Request::builder()
.uri("/protected")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[async_trait::async_trait]
// Reusable helpers for cookie extractor tests impl StatsRepository for Panic {
async fn optional_cookie_handler(user: OptionalCookieUser) -> String { async fn get_user_stats(&self, _: &UserId) -> Result<UserStats, DomainError> { panic!() }
match user.0 { async fn get_user_trends(&self, _: &UserId) -> Result<UserTrends, DomainError> { panic!() }
Some(id) => id.value().to_string(),
None => "none".to_string(),
}
} }
#[async_trait::async_trait]
async fn required_cookie_handler(user: RequiredCookieUser) -> String { impl MetadataClient for Panic {
user.0.value().to_string() async fn fetch_movie_metadata(&self, _: &domain::ports::MetadataSearchCriteria) -> Result<Movie, DomainError> { panic!() }
async fn get_poster_url(&self, _: &ExternalMetadataId) -> Result<Option<PosterUrl>, DomainError> { panic!() }
} }
#[async_trait::async_trait]
fn test_router_optional(state: crate::state::AppState) -> Router { impl PosterFetcherClient for Panic { async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result<Vec<u8>, DomainError> { panic!() } }
Router::new() #[async_trait::async_trait]
.route("/optional", get(optional_cookie_handler)) impl PosterStorage for Panic {
.with_state(state) async fn store_poster(&self, _: &MovieId, _: &[u8]) -> Result<PosterPath, DomainError> { panic!() }
async fn get_poster(&self, _: &PosterPath) -> Result<Vec<u8>, DomainError> { panic!() }
} }
#[async_trait::async_trait]
impl AuthService for Panic {
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> { panic!() }
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> { panic!() }
}
#[async_trait::async_trait]
impl PasswordHasher for Panic {
async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> { panic!() }
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> { panic!() }
}
#[async_trait::async_trait]
impl UserRepository for Panic {
async fn find_by_email(&self, _: &Email) -> Result<Option<domain::models::User>, DomainError> { panic!() }
async fn save(&self, _: &domain::models::User) -> Result<(), DomainError> { panic!() }
async fn find_by_id(&self, _: &UserId) -> Result<Option<domain::models::User>, DomainError> { panic!() }
async fn find_by_username(&self, _: &domain::value_objects::Username) -> Result<Option<domain::models::User>, DomainError> { panic!() }
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> { panic!() }
}
#[async_trait::async_trait]
impl EventPublisher for Panic { async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> { panic!() } }
fn test_router_required(state: crate::state::AppState) -> Router { impl crate::ports::HtmlRenderer for Panic {
Router::new() fn render_diary_page(&self, _: &Paginated<DiaryEntry>, _: application::ports::HtmlPageContext) -> Result<String, String> { panic!() }
.route("/required", get(required_cookie_handler)) fn render_login_page(&self, _: application::ports::LoginPageData<'_>) -> Result<String, String> { panic!() }
.with_state(state) fn render_register_page(&self, _: application::ports::RegisterPageData<'_>) -> Result<String, String> { panic!() }
fn render_new_review_page(&self, _: application::ports::NewReviewPageData<'_>) -> Result<String, String> { panic!() }
fn render_activity_feed_page(&self, _: application::ports::ActivityFeedPageData) -> Result<String, String> { panic!() }
fn render_users_page(&self, _: application::ports::UsersPageData) -> Result<String, String> { panic!() }
fn render_profile_page(&self, _: application::ports::ProfilePageData) -> Result<String, String> { panic!() }
fn render_following_page(&self, _: application::ports::FollowingPageData) -> Result<String, String> { panic!() }
fn render_followers_page(&self, _: application::ports::FollowersPageData) -> Result<String, String> { panic!() }
}
impl crate::ports::RssFeedRenderer for Panic {
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> { panic!() }
} }
struct RejectingAuth; struct RejectingAuth;
#[async_trait::async_trait] #[async_trait::async_trait]
impl domain::ports::AuthService for RejectingAuth { impl AuthService for RejectingAuth {
async fn generate_token(&self, _: &domain::value_objects::UserId) -> Result<domain::ports::GeneratedToken, domain::errors::DomainError> { panic!() } async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> { panic!() }
async fn validate_token(&self, _: &str) -> Result<domain::value_objects::UserId, domain::errors::DomainError> { async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
Err(domain::errors::DomainError::Unauthorized("bad token".into())) Err(DomainError::Unauthorized("bad token".into()))
} }
} }
async fn panic_state() -> crate::state::AppState { // --- Single state factory — only auth_service varies ---
use std::sync::Arc;
use application::context::AppContext; fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppState {
struct PanicRepo2; let repo = Arc::new(Panic);
#[async_trait::async_trait]
impl domain::ports::MovieRepository for PanicRepo2 {
async fn get_movie_by_external_id(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::models::Movie>, domain::errors::DomainError> { panic!() }
async fn get_movie_by_id(&self, _: &domain::value_objects::MovieId) -> Result<Option<domain::models::Movie>, domain::errors::DomainError> { panic!() }
async fn get_movies_by_title_and_year(&self, _: &domain::value_objects::MovieTitle, _: &domain::value_objects::ReleaseYear) -> Result<Vec<domain::models::Movie>, domain::errors::DomainError> { panic!() }
async fn upsert_movie(&self, _: &domain::models::Movie) -> Result<(), domain::errors::DomainError> { panic!() }
async fn delete_movie(&self, _: &domain::value_objects::MovieId) -> Result<(), domain::errors::DomainError> { panic!() }
}
#[async_trait::async_trait]
impl domain::ports::ReviewRepository for PanicRepo2 {
async fn save_review(&self, _: &domain::models::Review) -> Result<domain::events::DomainEvent, domain::errors::DomainError> { panic!() }
async fn get_review_by_id(&self, _: &domain::value_objects::ReviewId) -> Result<Option<domain::models::Review>, domain::errors::DomainError> { panic!() }
async fn delete_review(&self, _: &domain::value_objects::ReviewId) -> Result<(), domain::errors::DomainError> { panic!() }
}
#[async_trait::async_trait]
impl domain::ports::DiaryRepository for PanicRepo2 {
async fn query_diary(&self, _: &domain::models::DiaryFilter) -> Result<domain::models::collections::Paginated<domain::models::DiaryEntry>, domain::errors::DomainError> { panic!() }
async fn query_activity_feed(&self, _: &domain::models::collections::PageParams) -> Result<domain::models::collections::Paginated<domain::models::FeedEntry>, domain::errors::DomainError> { panic!() }
async fn get_review_history(&self, _: &domain::value_objects::MovieId) -> Result<domain::models::ReviewHistory, domain::errors::DomainError> { panic!() }
async fn get_user_history(&self, _: &domain::value_objects::UserId) -> Result<Vec<domain::models::DiaryEntry>, domain::errors::DomainError> { panic!() }
}
#[async_trait::async_trait]
impl domain::ports::StatsRepository for PanicRepo2 {
async fn get_user_stats(&self, _: &domain::value_objects::UserId) -> Result<domain::models::UserStats, domain::errors::DomainError> { panic!() }
async fn get_user_trends(&self, _: &domain::value_objects::UserId) -> Result<domain::models::UserTrends, domain::errors::DomainError> { panic!() }
}
struct PanicMeta2; struct PanicFetcher2; struct PanicStorage2; struct PanicEvent2; struct PanicHasher2; struct PanicUserRepo2;
#[async_trait::async_trait] impl domain::ports::MetadataClient for PanicMeta2 { async fn fetch_movie_metadata(&self, _: &domain::ports::MetadataSearchCriteria) -> Result<domain::models::Movie, domain::errors::DomainError> { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::value_objects::PosterUrl>, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::PosterFetcherClient for PanicFetcher2 { async fn fetch_poster_bytes(&self, _: &domain::value_objects::PosterUrl) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::PosterStorage for PanicStorage2 { async fn store_poster(&self, _: &domain::value_objects::MovieId, _: &[u8]) -> Result<domain::value_objects::PosterPath, domain::errors::DomainError> { panic!() } async fn get_poster(&self, _: &domain::value_objects::PosterPath) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::EventPublisher for PanicEvent2 { async fn publish(&self, _: &domain::events::DomainEvent) -> Result<(), domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::PasswordHasher for PanicHasher2 { async fn hash(&self, _: &str) -> Result<domain::value_objects::PasswordHash, domain::errors::DomainError> { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result<bool, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::AuthService for PanicAuth2 { async fn generate_token(&self, _: &domain::value_objects::UserId) -> Result<domain::ports::GeneratedToken, domain::errors::DomainError> { panic!() } async fn validate_token(&self, _: &str) -> Result<domain::value_objects::UserId, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo2 { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn find_by_username(&self, _: &domain::value_objects::Username) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, domain::errors::DomainError> { panic!() } }
struct PanicRenderer2;
impl crate::ports::HtmlRenderer for PanicRenderer2 {
fn render_diary_page(&self, _: &domain::models::collections::Paginated<domain::models::DiaryEntry>, _: application::ports::HtmlPageContext) -> Result<String, String> { panic!() }
fn render_login_page(&self, _: application::ports::LoginPageData<'_>) -> Result<String, String> { panic!() }
fn render_register_page(&self, _: application::ports::RegisterPageData<'_>) -> Result<String, String> { panic!() }
fn render_new_review_page(&self, _: application::ports::NewReviewPageData<'_>) -> Result<String, String> { panic!() }
fn render_activity_feed_page(&self, _: application::ports::ActivityFeedPageData) -> Result<String, String> { panic!() }
fn render_users_page(&self, _: application::ports::UsersPageData) -> Result<String, String> { panic!() }
fn render_profile_page(&self, _: application::ports::ProfilePageData) -> Result<String, String> { panic!() }
fn render_following_page(&self, _: application::ports::FollowingPageData) -> Result<String, String> { panic!() }
fn render_followers_page(&self, _: application::ports::FollowersPageData) -> Result<String, String> { panic!() }
}
struct PanicRssRenderer2;
impl crate::ports::RssFeedRenderer for PanicRssRenderer2 {
fn render_feed(&self, _: &[domain::models::DiaryEntry], _: &str) -> Result<String, String> { panic!() }
}
struct PanicAuth2;
crate::state::AppState { crate::state::AppState {
app_ctx: AppContext { app_ctx: AppContext {
movie_repository: Arc::new(PanicRepo2) as _, movie_repository: Arc::clone(&repo) as _,
review_repository: Arc::new(PanicRepo2) as _, review_repository: Arc::clone(&repo) as _,
diary_repository: Arc::new(PanicRepo2) as _, diary_repository: Arc::clone(&repo) as _,
stats_repository: Arc::new(PanicRepo2) as _, stats_repository: Arc::clone(&repo) as _,
metadata_client: Arc::new(PanicMeta2), metadata_client: Arc::clone(&repo) as _,
poster_fetcher: Arc::new(PanicFetcher2), poster_fetcher: Arc::clone(&repo) as _,
poster_storage: Arc::new(PanicStorage2), poster_storage: Arc::clone(&repo) as _,
event_publisher: Arc::new(PanicEvent2), event_publisher: Arc::clone(&repo) as _,
auth_service: Arc::new(PanicAuth2), password_hasher: Arc::clone(&repo) as _,
password_hasher: Arc::new(PanicHasher2), user_repository: Arc::clone(&repo) as _,
user_repository: Arc::new(PanicUserRepo2), auth_service,
config: application::config::AppConfig { allow_registration: false, base_url: "http://localhost:3000".to_string(), rate_limit: 20 }, config: AppConfig { allow_registration: false, base_url: "http://localhost:3000".to_string(), rate_limit: 20 },
}, },
html_renderer: Arc::new(PanicRenderer2), html_renderer: Arc::new(Panic),
rss_renderer: Arc::new(PanicRssRenderer2), rss_renderer: Arc::new(Panic),
ap_service: std::sync::Arc::new(activitypub::NoopActivityPubService), ap_service: Arc::new(activitypub::NoopActivityPubService),
} }
} }
async fn rejecting_state() -> crate::state::AppState { // --- Routers ---
use std::sync::Arc;
use application::context::AppContext; async fn protected_handler(user: AuthenticatedUser) -> String { user.0.value().to_string() }
struct PanicRepo3; async fn optional_cookie_handler(user: OptionalCookieUser) -> String {
#[async_trait::async_trait] match user.0 { Some(id) => id.value().to_string(), None => "none".to_string() }
impl domain::ports::MovieRepository for PanicRepo3 { }
async fn get_movie_by_external_id(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::models::Movie>, domain::errors::DomainError> { panic!() } async fn required_cookie_handler(user: RequiredCookieUser) -> String { user.0.value().to_string() }
async fn get_movie_by_id(&self, _: &domain::value_objects::MovieId) -> Result<Option<domain::models::Movie>, domain::errors::DomainError> { panic!() }
async fn get_movies_by_title_and_year(&self, _: &domain::value_objects::MovieTitle, _: &domain::value_objects::ReleaseYear) -> Result<Vec<domain::models::Movie>, domain::errors::DomainError> { panic!() } fn router_protected(state: crate::state::AppState) -> Router { Router::new().route("/protected", get(protected_handler)).with_state(state) }
async fn upsert_movie(&self, _: &domain::models::Movie) -> Result<(), domain::errors::DomainError> { panic!() } fn router_optional(state: crate::state::AppState) -> Router { Router::new().route("/optional", get(optional_cookie_handler)).with_state(state) }
async fn delete_movie(&self, _: &domain::value_objects::MovieId) -> Result<(), domain::errors::DomainError> { panic!() } fn router_required(state: crate::state::AppState) -> Router { Router::new().route("/required", get(required_cookie_handler)).with_state(state) }
}
#[async_trait::async_trait] // --- Tests ---
impl domain::ports::ReviewRepository for PanicRepo3 {
async fn save_review(&self, _: &domain::models::Review) -> Result<domain::events::DomainEvent, domain::errors::DomainError> { panic!() } #[tokio::test]
async fn get_review_by_id(&self, _: &domain::value_objects::ReviewId) -> Result<Option<domain::models::Review>, domain::errors::DomainError> { panic!() } async fn missing_auth_header_returns_401() {
async fn delete_review(&self, _: &domain::value_objects::ReviewId) -> Result<(), domain::errors::DomainError> { panic!() } let app = router_protected(make_test_state(Arc::new(Panic)));
} let resp = app.oneshot(Request::builder().uri("/protected").body(Body::empty()).unwrap()).await.unwrap();
#[async_trait::async_trait] assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
impl domain::ports::DiaryRepository for PanicRepo3 {
async fn query_diary(&self, _: &domain::models::DiaryFilter) -> Result<domain::models::collections::Paginated<domain::models::DiaryEntry>, domain::errors::DomainError> { panic!() }
async fn query_activity_feed(&self, _: &domain::models::collections::PageParams) -> Result<domain::models::collections::Paginated<domain::models::FeedEntry>, domain::errors::DomainError> { panic!() }
async fn get_review_history(&self, _: &domain::value_objects::MovieId) -> Result<domain::models::ReviewHistory, domain::errors::DomainError> { panic!() }
async fn get_user_history(&self, _: &domain::value_objects::UserId) -> Result<Vec<domain::models::DiaryEntry>, domain::errors::DomainError> { panic!() }
}
#[async_trait::async_trait]
impl domain::ports::StatsRepository for PanicRepo3 {
async fn get_user_stats(&self, _: &domain::value_objects::UserId) -> Result<domain::models::UserStats, domain::errors::DomainError> { panic!() }
async fn get_user_trends(&self, _: &domain::value_objects::UserId) -> Result<domain::models::UserTrends, domain::errors::DomainError> { panic!() }
}
struct PanicMeta3; struct PanicFetcher3; struct PanicStorage3; struct PanicEvent3; struct PanicHasher3; struct PanicUserRepo3;
#[async_trait::async_trait] impl domain::ports::MetadataClient for PanicMeta3 { async fn fetch_movie_metadata(&self, _: &domain::ports::MetadataSearchCriteria) -> Result<domain::models::Movie, domain::errors::DomainError> { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result<Option<domain::value_objects::PosterUrl>, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::PosterFetcherClient for PanicFetcher3 { async fn fetch_poster_bytes(&self, _: &domain::value_objects::PosterUrl) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::PosterStorage for PanicStorage3 { async fn store_poster(&self, _: &domain::value_objects::MovieId, _: &[u8]) -> Result<domain::value_objects::PosterPath, domain::errors::DomainError> { panic!() } async fn get_poster(&self, _: &domain::value_objects::PosterPath) -> Result<Vec<u8>, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::EventPublisher for PanicEvent3 { async fn publish(&self, _: &domain::events::DomainEvent) -> Result<(), domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::PasswordHasher for PanicHasher3 { async fn hash(&self, _: &str) -> Result<domain::value_objects::PasswordHash, domain::errors::DomainError> { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result<bool, domain::errors::DomainError> { panic!() } }
#[async_trait::async_trait] impl domain::ports::UserRepository for PanicUserRepo3 { async fn find_by_email(&self, _: &domain::value_objects::Email) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn save(&self, _: &domain::models::User) -> Result<(), domain::errors::DomainError> { panic!() } async fn find_by_id(&self, _: &domain::value_objects::UserId) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn find_by_username(&self, _: &domain::value_objects::Username) -> Result<Option<domain::models::User>, domain::errors::DomainError> { panic!() } async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, domain::errors::DomainError> { panic!() } }
struct PanicRenderer3;
impl crate::ports::HtmlRenderer for PanicRenderer3 {
fn render_diary_page(&self, _: &domain::models::collections::Paginated<domain::models::DiaryEntry>, _: application::ports::HtmlPageContext) -> Result<String, String> { panic!() }
fn render_login_page(&self, _: application::ports::LoginPageData<'_>) -> Result<String, String> { panic!() }
fn render_register_page(&self, _: application::ports::RegisterPageData<'_>) -> Result<String, String> { panic!() }
fn render_new_review_page(&self, _: application::ports::NewReviewPageData<'_>) -> Result<String, String> { panic!() }
fn render_activity_feed_page(&self, _: application::ports::ActivityFeedPageData) -> Result<String, String> { panic!() }
fn render_users_page(&self, _: application::ports::UsersPageData) -> Result<String, String> { panic!() }
fn render_profile_page(&self, _: application::ports::ProfilePageData) -> Result<String, String> { panic!() }
fn render_following_page(&self, _: application::ports::FollowingPageData) -> Result<String, String> { panic!() }
fn render_followers_page(&self, _: application::ports::FollowersPageData) -> Result<String, String> { panic!() }
}
struct PanicRssRenderer3;
impl crate::ports::RssFeedRenderer for PanicRssRenderer3 {
fn render_feed(&self, _: &[domain::models::DiaryEntry], _: &str) -> Result<String, String> { panic!() }
}
crate::state::AppState {
app_ctx: AppContext {
movie_repository: Arc::new(PanicRepo3) as _,
review_repository: Arc::new(PanicRepo3) as _,
diary_repository: Arc::new(PanicRepo3) as _,
stats_repository: Arc::new(PanicRepo3) as _,
metadata_client: Arc::new(PanicMeta3),
poster_fetcher: Arc::new(PanicFetcher3),
poster_storage: Arc::new(PanicStorage3),
event_publisher: Arc::new(PanicEvent3),
auth_service: Arc::new(RejectingAuth),
password_hasher: Arc::new(PanicHasher3),
user_repository: Arc::new(PanicUserRepo3),
config: application::config::AppConfig { allow_registration: false, base_url: "http://localhost:3000".to_string(), rate_limit: 20 },
},
html_renderer: Arc::new(PanicRenderer3),
rss_renderer: Arc::new(PanicRssRenderer3),
ap_service: std::sync::Arc::new(activitypub::NoopActivityPubService),
}
} }
#[tokio::test] #[tokio::test]
async fn optional_cookie_user_returns_none_without_cookie() { async fn optional_cookie_user_returns_none_without_cookie() {
let app = test_router_optional(panic_state().await); let app = router_optional(make_test_state(Arc::new(Panic)));
let response = app let resp = app.oneshot(Request::builder().uri("/optional").body(Body::empty()).unwrap()).await.unwrap();
.oneshot(Request::builder().uri("/optional").body(Body::empty()).unwrap()) assert_eq!(resp.status(), StatusCode::OK);
.await let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(&body[..], b"none"); assert_eq!(&body[..], b"none");
} }
#[tokio::test] #[tokio::test]
async fn optional_cookie_user_returns_none_with_invalid_token() { async fn optional_cookie_user_returns_none_with_invalid_token() {
let app = test_router_optional(rejecting_state().await); let app = router_optional(make_test_state(Arc::new(RejectingAuth)));
let response = app let resp = app.oneshot(Request::builder().uri("/optional").header("cookie", "token=bad.token.here").body(Body::empty()).unwrap()).await.unwrap();
.oneshot( assert_eq!(resp.status(), StatusCode::OK);
Request::builder() let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
.uri("/optional")
.header("cookie", "token=bad.token.here")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(&body[..], b"none"); assert_eq!(&body[..], b"none");
} }
#[tokio::test] #[tokio::test]
async fn required_cookie_user_redirects_without_cookie() { async fn required_cookie_user_redirects_without_cookie() {
let app = test_router_required(panic_state().await); let app = router_required(make_test_state(Arc::new(Panic)));
let response = app let resp = app.oneshot(Request::builder().uri("/required").body(Body::empty()).unwrap()).await.unwrap();
.oneshot(Request::builder().uri("/required").body(Body::empty()).unwrap()) assert_eq!(resp.status(), StatusCode::SEE_OTHER);
.await assert_eq!(resp.headers().get("location").unwrap(), "/login");
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/login");
} }
#[tokio::test] #[tokio::test]
async fn required_cookie_user_redirects_with_invalid_token() { async fn required_cookie_user_redirects_with_invalid_token() {
let app = test_router_required(rejecting_state().await); let app = router_required(make_test_state(Arc::new(RejectingAuth)));
let response = app let resp = app.oneshot(Request::builder().uri("/required").header("cookie", "token=bad.token.here").body(Body::empty()).unwrap()).await.unwrap();
.oneshot( assert_eq!(resp.status(), StatusCode::SEE_OTHER);
Request::builder() assert_eq!(resp.headers().get("location").unwrap(), "/login");
.uri("/required")
.header("cookie", "token=bad.token.here")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SEE_OTHER);
assert_eq!(response.headers().get("location").unwrap(), "/login");
} }
} }