export feature
This commit is contained in:
@@ -32,6 +32,7 @@ sqlx = { workspace = true }
|
||||
template-askama = { workspace = true }
|
||||
event-publisher = { workspace = true }
|
||||
rss = { workspace = true }
|
||||
export = { workspace = true }
|
||||
infer = "0.19.0"
|
||||
percent-encoding = "2"
|
||||
|
||||
|
||||
@@ -259,6 +259,16 @@ pub struct ProfileQueryParams {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct ExportQueryParams {
|
||||
#[serde(default = "default_export_format")]
|
||||
pub format: String,
|
||||
}
|
||||
|
||||
fn default_export_format() -> String {
|
||||
"csv".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -345,7 +355,10 @@ mod tests {
|
||||
movie_id: None,
|
||||
};
|
||||
let query = GetDiaryQuery::from(params);
|
||||
assert!(matches!(query.sort_by, Some(domain::models::SortDirection::Ascending)));
|
||||
assert!(matches!(
|
||||
query.sort_by,
|
||||
Some(domain::models::SortDirection::Ascending)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -357,7 +370,10 @@ mod tests {
|
||||
movie_id: None,
|
||||
};
|
||||
let query = GetDiaryQuery::from(params);
|
||||
assert!(matches!(query.sort_by, Some(domain::models::SortDirection::Descending)));
|
||||
assert!(matches!(
|
||||
query.sort_by,
|
||||
Some(domain::models::SortDirection::Descending)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,8 +2,8 @@ use std::time::Duration;
|
||||
|
||||
use application::{commands::SyncPosterCommand, context::AppContext, use_cases::sync_poster};
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, events::DomainEvent};
|
||||
use domain::ports::EventHandler;
|
||||
use domain::{errors::DomainError, events::DomainEvent};
|
||||
|
||||
pub struct PosterSyncHandler {
|
||||
ctx: AppContext,
|
||||
|
||||
@@ -28,11 +28,7 @@ where
|
||||
"Missing or invalid auth token".into(),
|
||||
))
|
||||
})?;
|
||||
let user_id = app_state
|
||||
.app_ctx
|
||||
.auth_service
|
||||
.validate_token(token)
|
||||
.await?;
|
||||
let user_id = app_state.app_ctx.auth_service.validate_token(token).await?;
|
||||
Ok(AuthenticatedUser(user_id))
|
||||
}
|
||||
}
|
||||
@@ -98,28 +94,32 @@ where
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
use application::{config::AppConfig, context::AppContext};
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
http::{Request, StatusCode},
|
||||
routing::get,
|
||||
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}},
|
||||
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,
|
||||
MovieRepository, PasswordHasher, PosterFetcherClient, PosterStorage, ReviewRepository,
|
||||
StatsRepository, UserRepository,
|
||||
},
|
||||
value_objects::{
|
||||
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterPath, PosterUrl,
|
||||
ReleaseYear, ReviewId, UserId,
|
||||
},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tower::ServiceExt;
|
||||
|
||||
// --- Panic stubs (defined once) ---
|
||||
@@ -128,82 +128,232 @@ mod tests {
|
||||
|
||||
#[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 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]
|
||||
impl ReviewRepository for Panic {
|
||||
async fn save_review(&self, _: &Review) -> Result<DomainEvent, DomainError> { panic!() }
|
||||
async fn get_review_by_id(&self, _: &ReviewId) -> Result<Option<Review>, DomainError> { panic!() }
|
||||
async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> { panic!() }
|
||||
async fn save_review(&self, _: &Review) -> Result<DomainEvent, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_review_by_id(&self, _: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn delete_review(&self, _: &ReviewId) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_all_reviews_for_user(&self, _: &UserId) -> Result<Vec<Review>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl DiaryRepository for Panic {
|
||||
async fn query_diary(&self, _: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> { panic!() }
|
||||
async fn query_activity_feed(&self, _: &PageParams) -> Result<Paginated<FeedEntry>, DomainError> { panic!() }
|
||||
async fn get_review_history(&self, _: &MovieId) -> Result<ReviewHistory, DomainError> { panic!() }
|
||||
async fn get_user_history(&self, _: &UserId) -> Result<Vec<DiaryEntry>, DomainError> { panic!() }
|
||||
async fn query_diary(&self, _: &DiaryFilter) -> Result<Paginated<DiaryEntry>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn query_activity_feed(
|
||||
&self,
|
||||
_: &PageParams,
|
||||
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_review_history(&self, _: &MovieId) -> Result<ReviewHistory, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_user_history(&self, _: &UserId) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl StatsRepository for Panic {
|
||||
async fn get_user_stats(&self, _: &UserId) -> Result<UserStats, DomainError> { panic!() }
|
||||
async fn get_user_trends(&self, _: &UserId) -> Result<UserTrends, DomainError> { panic!() }
|
||||
async fn get_user_stats(&self, _: &UserId) -> Result<UserStats, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn get_user_trends(&self, _: &UserId) -> Result<UserTrends, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl MetadataClient for Panic {
|
||||
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 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]
|
||||
impl PosterFetcherClient for Panic { async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result<Vec<u8>, DomainError> { panic!() } }
|
||||
impl PosterFetcherClient for Panic {
|
||||
async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result<Vec<u8>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl PosterStorage for Panic {
|
||||
async fn store_poster(&self, _: &MovieId, _: &[u8]) -> Result<PosterPath, DomainError> { panic!() }
|
||||
async fn get_poster(&self, _: &PosterPath) -> Result<Vec<u8>, DomainError> { panic!() }
|
||||
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 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 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 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!() } }
|
||||
impl EventPublisher for Panic {
|
||||
async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
#[async_trait::async_trait]
|
||||
impl domain::ports::DiaryExporter for Panic {
|
||||
async fn serialize_entries(
|
||||
&self,
|
||||
_: &[domain::models::DiaryEntry],
|
||||
_: domain::models::ExportFormat,
|
||||
) -> Result<Vec<u8>, domain::errors::DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::ports::HtmlRenderer for Panic {
|
||||
fn render_diary_page(&self, _: &Paginated<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!() }
|
||||
fn render_diary_page(
|
||||
&self,
|
||||
_: &Paginated<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!()
|
||||
}
|
||||
}
|
||||
impl crate::ports::RssFeedRenderer for Panic {
|
||||
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> { panic!() }
|
||||
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
struct RejectingAuth;
|
||||
#[async_trait::async_trait]
|
||||
impl AuthService for RejectingAuth {
|
||||
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> { panic!() }
|
||||
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
|
||||
Err(DomainError::Unauthorized("bad token".into()))
|
||||
}
|
||||
@@ -218,6 +368,7 @@ mod tests {
|
||||
movie_repository: Arc::clone(&repo) as _,
|
||||
review_repository: Arc::clone(&repo) as _,
|
||||
diary_repository: Arc::clone(&repo) as _,
|
||||
diary_exporter: Arc::clone(&repo) as _,
|
||||
stats_repository: Arc::clone(&repo) as _,
|
||||
metadata_client: Arc::clone(&repo) as _,
|
||||
poster_fetcher: Arc::clone(&repo) as _,
|
||||
@@ -226,7 +377,11 @@ mod tests {
|
||||
password_hasher: Arc::clone(&repo) as _,
|
||||
user_repository: Arc::clone(&repo) as _,
|
||||
auth_service,
|
||||
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(Panic),
|
||||
rss_renderer: Arc::new(Panic),
|
||||
@@ -236,47 +391,103 @@ mod tests {
|
||||
|
||||
// --- Routers ---
|
||||
|
||||
async fn protected_handler(user: AuthenticatedUser) -> String { user.0.value().to_string() }
|
||||
async fn optional_cookie_handler(user: OptionalCookieUser) -> String {
|
||||
match user.0 { Some(id) => id.value().to_string(), None => "none".to_string() }
|
||||
async fn protected_handler(user: AuthenticatedUser) -> String {
|
||||
user.0.value().to_string()
|
||||
}
|
||||
async fn optional_cookie_handler(user: OptionalCookieUser) -> String {
|
||||
match user.0 {
|
||||
Some(id) => id.value().to_string(),
|
||||
None => "none".to_string(),
|
||||
}
|
||||
}
|
||||
async fn required_cookie_handler(user: RequiredCookieUser) -> String {
|
||||
user.0.value().to_string()
|
||||
}
|
||||
async fn required_cookie_handler(user: RequiredCookieUser) -> String { user.0.value().to_string() }
|
||||
|
||||
fn router_protected(state: crate::state::AppState) -> Router { Router::new().route("/protected", get(protected_handler)).with_state(state) }
|
||||
fn router_optional(state: crate::state::AppState) -> Router { Router::new().route("/optional", get(optional_cookie_handler)).with_state(state) }
|
||||
fn router_required(state: crate::state::AppState) -> Router { Router::new().route("/required", get(required_cookie_handler)).with_state(state) }
|
||||
fn router_protected(state: crate::state::AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/protected", get(protected_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
fn router_optional(state: crate::state::AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/optional", get(optional_cookie_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
fn router_required(state: crate::state::AppState) -> Router {
|
||||
Router::new()
|
||||
.route("/required", get(required_cookie_handler))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_auth_header_returns_401() {
|
||||
let app = router_protected(make_test_state(Arc::new(Panic)));
|
||||
let resp = app.oneshot(Request::builder().uri("/protected").body(Body::empty()).unwrap()).await.unwrap();
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/protected")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_cookie_user_returns_none_without_cookie() {
|
||||
let app = router_optional(make_test_state(Arc::new(Panic)));
|
||||
let resp = app.oneshot(Request::builder().uri("/optional").body(Body::empty()).unwrap()).await.unwrap();
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/optional")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(&body[..], b"none");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_cookie_user_returns_none_with_invalid_token() {
|
||||
let app = router_optional(make_test_state(Arc::new(RejectingAuth)));
|
||||
let resp = app.oneshot(Request::builder().uri("/optional").header("cookie", "token=bad.token.here").body(Body::empty()).unwrap()).await.unwrap();
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/optional")
|
||||
.header("cookie", "token=bad.token.here")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
||||
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(&body[..], b"none");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn required_cookie_user_redirects_without_cookie() {
|
||||
let app = router_required(make_test_state(Arc::new(Panic)));
|
||||
let resp = app.oneshot(Request::builder().uri("/required").body(Body::empty()).unwrap()).await.unwrap();
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/required")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(resp.headers().get("location").unwrap(), "/login");
|
||||
}
|
||||
@@ -284,7 +495,16 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn required_cookie_user_redirects_with_invalid_token() {
|
||||
let app = router_required(make_test_state(Arc::new(RejectingAuth)));
|
||||
let resp = app.oneshot(Request::builder().uri("/required").header("cookie", "token=bad.token.here").body(Body::empty()).unwrap()).await.unwrap();
|
||||
let resp = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/required")
|
||||
.header("cookie", "token=bad.token.here")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(resp.headers().get("location").unwrap(), "/login");
|
||||
}
|
||||
|
||||
@@ -14,13 +14,17 @@ pub mod html {
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
commands::{DeleteReviewCommand, LoginCommand, RegisterCommand},
|
||||
commands::{DeleteReviewCommand, ExportCommand, LoginCommand, RegisterCommand},
|
||||
ports::{
|
||||
FollowersPageData, FollowingPageData, HtmlPageContext, LoginPageData,
|
||||
NewReviewPageData, RegisterPageData, RemoteActorView,
|
||||
},
|
||||
use_cases::{delete_review, log_review, login as login_uc, register as register_uc},
|
||||
use_cases::{
|
||||
delete_review, export_diary as export_diary_uc, log_review, login as login_uc,
|
||||
register as register_uc,
|
||||
},
|
||||
};
|
||||
use domain::models::ExportFormat;
|
||||
use domain::{errors::DomainError, value_objects::UserId};
|
||||
|
||||
use crate::{
|
||||
@@ -265,6 +269,45 @@ pub mod html {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_export(
|
||||
State(state): State<AppState>,
|
||||
RequiredCookieUser(user_id): RequiredCookieUser,
|
||||
Query(params): Query<crate::dtos::ExportQueryParams>,
|
||||
) -> impl IntoResponse {
|
||||
let format = match params.format.as_str() {
|
||||
"csv" => ExportFormat::Csv,
|
||||
"json" => ExportFormat::Json,
|
||||
_ => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
let (content_type, filename) = match &format {
|
||||
ExportFormat::Csv => ("text/csv; charset=utf-8", "diary.csv"),
|
||||
ExportFormat::Json => ("application/json", "diary.json"),
|
||||
};
|
||||
let cmd = ExportCommand {
|
||||
user_id: user_id.value(),
|
||||
format,
|
||||
};
|
||||
match export_diary_uc::execute(&state.app_ctx, cmd).await {
|
||||
Ok(bytes) => (
|
||||
StatusCode::OK,
|
||||
[
|
||||
(axum::http::header::CONTENT_TYPE, content_type.to_string()),
|
||||
(
|
||||
axum::http::header::CONTENT_DISPOSITION,
|
||||
format!("attachment; filename=\"{}\"", filename),
|
||||
),
|
||||
],
|
||||
bytes,
|
||||
)
|
||||
.into_response(),
|
||||
Err(DomainError::Unauthorized(_)) => StatusCode::FORBIDDEN.into_response(),
|
||||
Err(e) => {
|
||||
tracing::error!("export error: {:?}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_activity_feed(
|
||||
OptionalCookieUser(user_id): OptionalCookieUser,
|
||||
State(state): State<AppState>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pub mod event_handlers;
|
||||
pub mod dtos;
|
||||
pub mod errors;
|
||||
pub mod event_handlers;
|
||||
pub mod extractors;
|
||||
pub mod handlers;
|
||||
pub mod ports;
|
||||
|
||||
@@ -10,15 +10,19 @@ use sqlx::sqlite::SqliteConnectOptions;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use activitypub::{
|
||||
ActivityPubEventHandler, ActivityPubPort, ActivityPubService, DomainUserRepoAdapter,
|
||||
ReviewObjectHandler,
|
||||
};
|
||||
use application::{config::AppConfig, context::AppContext};
|
||||
use auth::{AuthConfig, Argon2PasswordHasher, JwtAuthService};
|
||||
use auth::{Argon2PasswordHasher, AuthConfig, JwtAuthService};
|
||||
use export::ExportAdapter;
|
||||
use metadata::MetadataClientImpl;
|
||||
use poster_fetcher::{PosterFetcherConfig, ReqwestPosterFetcher};
|
||||
use poster_storage::{PosterStorageAdapter, StorageConfig};
|
||||
use activitypub::{ActivityPubEventHandler, ActivityPubPort, ActivityPubService, DomainUserRepoAdapter, ReviewObjectHandler};
|
||||
use rss::RssAdapter;
|
||||
use sqlite::{SqliteMovieRepository, SqliteUserRepository};
|
||||
use sqlite_federation::SqliteFederationRepository;
|
||||
use rss::RssAdapter;
|
||||
use template_askama::AskamaHtmlRenderer;
|
||||
|
||||
use presentation::{routes, state::AppState};
|
||||
@@ -68,18 +72,23 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
.context("Database migration failed")?;
|
||||
|
||||
use domain::ports::{
|
||||
AuthService, DiaryRepository, MetadataClient, MovieRepository, PasswordHasher,
|
||||
PosterFetcherClient, PosterStorage, ReviewRepository, StatsRepository, UserRepository,
|
||||
AuthService, DiaryExporter, DiaryRepository, MetadataClient, MovieRepository,
|
||||
PasswordHasher, PosterFetcherClient, PosterStorage, ReviewRepository, StatsRepository,
|
||||
UserRepository,
|
||||
};
|
||||
let movie_repository: Arc<dyn MovieRepository> = Arc::clone(&sqlite_repo) as _;
|
||||
let movie_repository: Arc<dyn MovieRepository> = Arc::clone(&sqlite_repo) as _;
|
||||
let review_repository: Arc<dyn ReviewRepository> = Arc::clone(&sqlite_repo) as _;
|
||||
let diary_repository: Arc<dyn DiaryRepository> = Arc::clone(&sqlite_repo) as _;
|
||||
let stats_repository: Arc<dyn StatsRepository> = Arc::clone(&sqlite_repo) as _;
|
||||
let diary_repository: Arc<dyn DiaryRepository> = Arc::clone(&sqlite_repo) as _;
|
||||
let stats_repository: Arc<dyn StatsRepository> = Arc::clone(&sqlite_repo) as _;
|
||||
|
||||
let user_repository: Arc<dyn UserRepository> = Arc::new(SqliteUserRepository::new(pool.clone()));
|
||||
let metadata_client: Arc<dyn MetadataClient> = Arc::new(MetadataClientImpl::new_omdb(omdb_api_key));
|
||||
let poster_fetcher: Arc<dyn PosterFetcherClient> = Arc::new(ReqwestPosterFetcher::new(PosterFetcherConfig::from_env())?);
|
||||
let poster_storage: Arc<dyn PosterStorage> = Arc::new(PosterStorageAdapter::from_config(storage_config));
|
||||
let user_repository: Arc<dyn UserRepository> =
|
||||
Arc::new(SqliteUserRepository::new(pool.clone()));
|
||||
let metadata_client: Arc<dyn MetadataClient> =
|
||||
Arc::new(MetadataClientImpl::new_omdb(omdb_api_key));
|
||||
let poster_fetcher: Arc<dyn PosterFetcherClient> =
|
||||
Arc::new(ReqwestPosterFetcher::new(PosterFetcherConfig::from_env())?);
|
||||
let poster_storage: Arc<dyn PosterStorage> =
|
||||
Arc::new(PosterStorageAdapter::from_config(storage_config));
|
||||
let auth_service: Arc<dyn AuthService> = Arc::new(JwtAuthService::new(auth_config));
|
||||
let password_hasher: Arc<dyn PasswordHasher> = Arc::new(Argon2PasswordHasher);
|
||||
|
||||
@@ -89,6 +98,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
movie_repository: Arc::clone(&movie_repository),
|
||||
review_repository: Arc::clone(&review_repository),
|
||||
diary_repository: Arc::clone(&diary_repository),
|
||||
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
|
||||
stats_repository: Arc::clone(&stats_repository),
|
||||
metadata_client: Arc::clone(&metadata_client),
|
||||
poster_fetcher: Arc::clone(&poster_fetcher),
|
||||
@@ -139,6 +149,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
||||
movie_repository,
|
||||
review_repository,
|
||||
diary_repository,
|
||||
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
|
||||
stats_repository,
|
||||
metadata_client,
|
||||
poster_fetcher,
|
||||
|
||||
@@ -36,7 +36,11 @@ impl RateLimiter {
|
||||
let prev = self.window.load(Ordering::Acquire);
|
||||
if now != prev {
|
||||
// compare_exchange ensures only one thread wins the window reset
|
||||
if self.window.compare_exchange(prev, now, Ordering::AcqRel, Ordering::Relaxed).is_ok() {
|
||||
if self
|
||||
.window
|
||||
.compare_exchange(prev, now, Ordering::AcqRel, Ordering::Relaxed)
|
||||
.is_ok()
|
||||
{
|
||||
self.count.store(1, Ordering::Release);
|
||||
return true;
|
||||
}
|
||||
@@ -130,6 +134,7 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
|
||||
"/posters/{*path}",
|
||||
routing::get(handlers::posters::get_poster),
|
||||
)
|
||||
.route("/diary/export", routing::get(handlers::html::get_export))
|
||||
.route("/feed.rss", routing::get(handlers::rss::get_feed))
|
||||
.route(
|
||||
"/users/{id}/feed.rss",
|
||||
|
||||
@@ -21,9 +21,9 @@ use domain::{
|
||||
};
|
||||
use http_body_util::BodyExt;
|
||||
use presentation::{routes, state::AppState};
|
||||
use rss::RssAdapter;
|
||||
use sqlite::SqliteMovieRepository;
|
||||
use sqlx::SqlitePool;
|
||||
use rss::RssAdapter;
|
||||
use template_askama::AskamaHtmlRenderer;
|
||||
use tower::ServiceExt;
|
||||
|
||||
@@ -41,7 +41,10 @@ impl MetadataClient for PanicMeta {
|
||||
async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result<Movie, DomainError> {
|
||||
panic!("metadata not wired in tests")
|
||||
}
|
||||
async fn get_poster_url(&self, _: &ExternalMetadataId) -> Result<Option<PosterUrl>, DomainError> {
|
||||
async fn get_poster_url(
|
||||
&self,
|
||||
_: &ExternalMetadataId,
|
||||
) -> Result<Option<PosterUrl>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
@@ -68,25 +71,58 @@ impl PosterStorage for PanicStorage {
|
||||
struct PanicHasher;
|
||||
#[async_trait]
|
||||
impl PasswordHasher for PanicHasher {
|
||||
async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> { panic!() }
|
||||
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> { panic!() }
|
||||
async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicAuth;
|
||||
#[async_trait]
|
||||
impl AuthService for PanicAuth {
|
||||
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> { panic!() }
|
||||
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> { panic!() }
|
||||
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
struct NobodyUserRepo;
|
||||
#[async_trait]
|
||||
impl UserRepository for NobodyUserRepo {
|
||||
async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> { Ok(None) }
|
||||
async fn find_by_username(&self, _: &domain::value_objects::Username) -> Result<Option<User>, DomainError> { Ok(None) }
|
||||
async fn save(&self, _: &User) -> Result<(), DomainError> { panic!() }
|
||||
async fn find_by_id(&self, _: &UserId) -> Result<Option<User>, DomainError> { panic!() }
|
||||
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> { panic!() }
|
||||
async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn find_by_username(
|
||||
&self,
|
||||
_: &domain::value_objects::Username,
|
||||
) -> Result<Option<User>, DomainError> {
|
||||
Ok(None)
|
||||
}
|
||||
async fn save(&self, _: &User) -> Result<(), DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn find_by_id(&self, _: &UserId) -> Result<Option<User>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
struct PanicExporter;
|
||||
#[async_trait]
|
||||
impl domain::ports::DiaryExporter for PanicExporter {
|
||||
async fn serialize_entries(
|
||||
&self,
|
||||
_: &[domain::models::DiaryEntry],
|
||||
_: domain::models::ExportFormat,
|
||||
) -> Result<Vec<u8>, DomainError> {
|
||||
panic!()
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_app() -> Router {
|
||||
@@ -102,6 +138,7 @@ async fn test_app() -> Router {
|
||||
movie_repository: Arc::clone(&repo) as _,
|
||||
review_repository: Arc::clone(&repo) as _,
|
||||
diary_repository: Arc::clone(&repo) as _,
|
||||
diary_exporter: Arc::new(PanicExporter),
|
||||
stats_repository: Arc::clone(&repo) as _,
|
||||
metadata_client: Arc::new(PanicMeta),
|
||||
poster_fetcher: Arc::new(PanicFetcher),
|
||||
@@ -110,7 +147,11 @@ async fn test_app() -> Router {
|
||||
auth_service: Arc::new(PanicAuth),
|
||||
password_hasher: Arc::new(PanicHasher),
|
||||
user_repository: Arc::new(NobodyUserRepo),
|
||||
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(AskamaHtmlRenderer::new()),
|
||||
rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())),
|
||||
@@ -124,7 +165,12 @@ async fn test_app() -> Router {
|
||||
async fn get_api_diary_returns_empty_list() {
|
||||
let app = test_app().await;
|
||||
let response = app
|
||||
.oneshot(Request::builder().uri("/api/diary").body(Body::empty()).unwrap())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/diary")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user