From 79a06e6844d38bad3f6c230be94329be08c73141 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 4 May 2026 09:30:20 +0200 Subject: [PATCH] presentation wiring Co-Authored-By: Claude Sonnet 4.6 --- Cargo.toml | 2 + crates/adapters/auth/Cargo.toml | 2 + crates/adapters/auth/src/lib.rs | 19 +- crates/adapters/template-askama/Cargo.toml | 2 +- crates/adapters/template-askama/src/lib.rs | 2 +- crates/application/src/lib.rs | 1 + crates/application/src/ports.rs | 5 + crates/presentation/Cargo.toml | 10 + crates/presentation/src/dtos.rs | 118 ++++++++++ crates/presentation/src/errors.rs | 2 +- crates/presentation/src/extractors.rs | 116 ++++++++++ crates/presentation/src/handlers.rs | 255 +++++++++++++++++++++ crates/presentation/src/lib.rs | 1 + crates/presentation/src/main.rs | 149 ++++++++++++ crates/presentation/src/ports.rs | 6 +- crates/presentation/src/routes.rs | 36 +++ crates/presentation/src/state.rs | 11 + crates/presentation/tests/api_test.rs | 164 +++++++++++++ 18 files changed, 883 insertions(+), 18 deletions(-) create mode 100644 crates/application/src/ports.rs create mode 100644 crates/presentation/src/state.rs create mode 100644 crates/presentation/tests/api_test.rs diff --git a/Cargo.toml b/Cargo.toml index 1ae8d2f..eab3eae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,8 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } async-trait = "0.1" uuid = { version = "1.23.0", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } +sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "sqlite", "uuid", "macros"] } +template-askama = { path = "crates/adapters/template-askama" } domain = { path = "crates/domain" } common = { path = "crates/common" } diff --git a/crates/adapters/auth/Cargo.toml b/crates/adapters/auth/Cargo.toml index 56077c3..ce75e1f 100644 --- a/crates/adapters/auth/Cargo.toml +++ b/crates/adapters/auth/Cargo.toml @@ -4,3 +4,5 @@ version = "0.1.0" edition = "2024" [dependencies] +async-trait = { workspace = true } +domain = { workspace = true } diff --git a/crates/adapters/auth/src/lib.rs b/crates/adapters/auth/src/lib.rs index b93cf3f..47cf196 100644 --- a/crates/adapters/auth/src/lib.rs +++ b/crates/adapters/auth/src/lib.rs @@ -1,14 +1,13 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right -} +use async_trait::async_trait; +use domain::{errors::DomainError, ports::AuthService, value_objects::UserId}; -#[cfg(test)] -mod tests { - use super::*; +pub struct StubAuthService; - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); +#[async_trait] +impl AuthService for StubAuthService { + async fn validate_token(&self, _token: &str) -> Result { + Err(DomainError::InfrastructureError( + "auth service not implemented".into(), + )) } } diff --git a/crates/adapters/template-askama/Cargo.toml b/crates/adapters/template-askama/Cargo.toml index 2057914..0888c35 100644 --- a/crates/adapters/template-askama/Cargo.toml +++ b/crates/adapters/template-askama/Cargo.toml @@ -9,4 +9,4 @@ askama = { version = "0.16.0" } serde = { workspace = true } domain = { workspace = true } -presentation = { workspace = true } +application = { workspace = true } diff --git a/crates/adapters/template-askama/src/lib.rs b/crates/adapters/template-askama/src/lib.rs index e9f18ae..0689d65 100644 --- a/crates/adapters/template-askama/src/lib.rs +++ b/crates/adapters/template-askama/src/lib.rs @@ -1,6 +1,6 @@ use askama::Template; +use application::ports::HtmlRenderer; use domain::models::{DiaryEntry, collections::Paginated}; -use presentation::ports::HtmlRenderer; // Assuming you exposed the port #[derive(Template)] #[template(path = "diary.html")] diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 107faf1..682dd6f 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -1,4 +1,5 @@ pub mod commands; pub mod context; +pub mod ports; pub mod queries; pub mod use_cases; diff --git a/crates/application/src/ports.rs b/crates/application/src/ports.rs new file mode 100644 index 0000000..79c8d6a --- /dev/null +++ b/crates/application/src/ports.rs @@ -0,0 +1,5 @@ +use domain::models::{DiaryEntry, collections::Paginated}; + +pub trait HtmlRenderer: Send + Sync { + fn render_diary_page(&self, data: &Paginated) -> Result; +} diff --git a/crates/presentation/Cargo.toml b/crates/presentation/Cargo.toml index ca4f69a..b85ca7f 100644 --- a/crates/presentation/Cargo.toml +++ b/crates/presentation/Cargo.toml @@ -15,6 +15,16 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } tokio = { workspace = true } uuid = { workspace = true } +chrono = { workspace = true } +async-trait = { workspace = true } domain = { workspace = true } application = { workspace = true } +auth = { workspace = true } +sqlite = { workspace = true } +sqlx = { workspace = true } +template-askama = { workspace = true } + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" diff --git a/crates/presentation/src/dtos.rs b/crates/presentation/src/dtos.rs index e69de29..fc5d81b 100644 --- a/crates/presentation/src/dtos.rs +++ b/crates/presentation/src/dtos.rs @@ -0,0 +1,118 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Deserialize)] +pub struct DiaryQueryParams { + pub limit: Option, + pub offset: Option, + pub sort_by: Option, + pub movie_id: Option, +} + +#[derive(Deserialize)] +pub struct LogReviewForm { + pub external_metadata_id: Option, + pub manual_title: Option, + pub manual_release_year: Option, + pub manual_director: Option, + pub rating: u8, + pub comment: Option, + pub watched_at: String, +} + +#[derive(Deserialize)] +pub struct LogReviewRequest { + pub external_metadata_id: Option, + pub manual_title: Option, + pub manual_release_year: Option, + pub manual_director: Option, + pub rating: u8, + pub comment: Option, + pub watched_at: String, +} + +#[derive(Serialize)] +pub struct MovieDto { + pub id: Uuid, + pub title: String, + pub release_year: u16, + pub director: Option, + pub poster_path: Option, +} + +#[derive(Serialize)] +pub struct ReviewDto { + pub id: Uuid, + pub rating: u8, + pub comment: Option, + pub watched_at: String, +} + +#[derive(Serialize)] +pub struct DiaryEntryDto { + pub movie: MovieDto, + pub review: ReviewDto, +} + +#[derive(Serialize)] +pub struct DiaryResponse { + pub items: Vec, + pub total_count: u64, + pub limit: u32, + pub offset: u32, +} + +#[derive(Serialize)] +pub struct ReviewHistoryResponse { + pub movie: MovieDto, + pub viewings: Vec, + pub trend: String, +} + +#[derive(Deserialize)] +pub struct LoginRequest { + pub email: String, + pub password: String, +} + +#[derive(Serialize)] +pub struct LoginResponse { + pub token: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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"); + } +} diff --git a/crates/presentation/src/errors.rs b/crates/presentation/src/errors.rs index bd67dc2..10fc5bf 100644 --- a/crates/presentation/src/errors.rs +++ b/crates/presentation/src/errors.rs @@ -4,7 +4,7 @@ use axum::{ }; use domain::errors::DomainError; -pub struct ApiError(DomainError); +pub struct ApiError(pub DomainError); impl From for ApiError { fn from(err: DomainError) -> Self { diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs index e69de29..b9d3701 100644 --- a/crates/presentation/src/extractors.rs +++ b/crates/presentation/src/extractors.rs @@ -0,0 +1,116 @@ +use axum::{ + extract::{FromRef, FromRequestParts}, + http::{header::AUTHORIZATION, request::Parts}, +}; +use domain::{errors::DomainError, value_objects::UserId}; + +use crate::{errors::ApiError, state::AppState}; + +pub struct AuthenticatedUser(pub UserId); + +impl FromRequestParts for AuthenticatedUser +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = ApiError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let token = parts + .headers + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .ok_or_else(|| { + ApiError(DomainError::ValidationError( + "Missing auth token".into(), + )) + })?; + let user_id = app_state + .app_ctx + .auth_service + .validate_token(token) + .await?; + Ok(AuthenticatedUser(user_id)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::Body, + http::{Request, StatusCode}, + routing::get, + Router, + }; + use tower::ServiceExt; + + async fn protected_handler(user: AuthenticatedUser) -> String { + user.0.value().to_string() + } + + fn test_router(state: crate::state::AppState) -> Router { + Router::new() + .route("/protected", get(protected_handler)) + .with_state(state) + } + + #[tokio::test] + async fn missing_auth_header_returns_400() { + use std::sync::Arc; + use application::context::AppContext; + use auth::StubAuthService; + + 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, domain::errors::DomainError> { panic!() } + async fn get_movie_by_id(&self, _: &domain::value_objects::MovieId) -> Result, domain::errors::DomainError> { panic!() } + async fn get_movies_by_title_and_year(&self, _: &domain::value_objects::MovieTitle, _: &domain::value_objects::ReleaseYear) -> Result, domain::errors::DomainError> { panic!() } + async fn upsert_movie(&self, _: &domain::models::Movie) -> Result<(), domain::errors::DomainError> { panic!() } + async fn save_review(&self, _: &domain::models::Review) -> Result { panic!() } + async fn query_diary(&self, _: &domain::models::DiaryFilter) -> Result, domain::errors::DomainError> { panic!() } + async fn get_review_history(&self, _: &domain::value_objects::MovieId) -> Result { panic!() } + } + + struct PanicRenderer; + impl crate::ports::HtmlRenderer for PanicRenderer { + fn render_diary_page(&self, _: &domain::models::collections::Paginated) -> Result { panic!() } + } + + struct PanicMeta; struct PanicFetcher; struct PanicStorage; struct PanicEvent; struct PanicHasher; + #[async_trait::async_trait] impl domain::ports::MetadataClient for PanicMeta { async fn fetch_movie_metadata(&self, _: &domain::value_objects::ExternalMetadataId) -> Result { panic!() } async fn get_poster_url(&self, _: &domain::value_objects::ExternalMetadataId) -> Result, 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, 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 { panic!() } async fn get_poster(&self, _: &domain::value_objects::PosterPath) -> Result, 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 { panic!() } async fn verify(&self, _: &str, _: &domain::value_objects::PasswordHash) -> Result { panic!() } } + + let state = crate::state::AppState { + app_ctx: AppContext { + repository: Arc::new(PanicRepo), + metadata_client: Arc::new(PanicMeta), + poster_fetcher: Arc::new(PanicFetcher), + poster_storage: Arc::new(PanicStorage), + event_publisher: Arc::new(PanicEvent), + auth_service: Arc::new(StubAuthService), + password_hasher: Arc::new(PanicHasher), + }, + html_renderer: Arc::new(PanicRenderer), + }; + + let app = test_router(state); + let response = app + .oneshot( + Request::builder() + .uri("/protected") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } +} diff --git a/crates/presentation/src/handlers.rs b/crates/presentation/src/handlers.rs index e69de29..51e3231 100644 --- a/crates/presentation/src/handlers.rs +++ b/crates/presentation/src/handlers.rs @@ -0,0 +1,255 @@ +pub mod html { + use axum::{ + extract::{Query, State}, + response::{Html, IntoResponse, Redirect}, + Form, + }; + use chrono::NaiveDateTime; + + use application::{ + commands::LogReviewCommand, + queries::GetDiaryQuery, + use_cases::{get_diary, log_review}, + }; + use domain::{errors::DomainError, models::SortDirection}; + + use crate::{ + dtos::{DiaryQueryParams, LogReviewForm}, + errors::ApiError, + extractors::AuthenticatedUser, + state::AppState, + }; + + pub async fn get_diary_page( + State(state): State, + Query(params): Query, + ) -> Result { + let query = GetDiaryQuery { + limit: params.limit, + offset: params.offset, + sort_by: params.sort_by.as_deref().map(|s| { + if s == "asc" { + SortDirection::Ascending + } else { + SortDirection::Descending + } + }), + movie_id: params.movie_id, + }; + + let page = get_diary::execute(&state.app_ctx, query).await?; + let html = state + .html_renderer + .render_diary_page(&page) + .map_err(|e| ApiError(DomainError::InfrastructureError(e)))?; + + Ok(Html(html)) + } + + pub async fn post_review( + State(state): State, + user: AuthenticatedUser, + Form(form): Form, + ) -> Result { + let watched_at = NaiveDateTime::parse_from_str(&form.watched_at, "%Y-%m-%dT%H:%M:%S") + .map_err(|_| { + ApiError(DomainError::ValidationError( + "Invalid watched_at format, expected YYYY-MM-DDTHH:MM:SS".into(), + )) + })?; + + let cmd = LogReviewCommand { + external_metadata_id: form.external_metadata_id, + manual_title: form.manual_title, + manual_release_year: form.manual_release_year, + manual_director: form.manual_director, + user_id: user.0.value(), + rating: form.rating, + comment: form.comment, + watched_at, + }; + + log_review::execute(&state.app_ctx, cmd).await?; + + Ok(Redirect::to("/diary")) + } +} + +pub mod api { + use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, + response::IntoResponse, + }; + use chrono::NaiveDateTime; + use uuid::Uuid; + + use application::{ + commands::{LogReviewCommand, SyncPosterCommand}, + queries::{GetDiaryQuery, GetReviewHistoryQuery}, + use_cases::{get_diary, get_review_history, log_review, sync_poster}, + }; + use domain::{ + errors::DomainError, + models::{DiaryEntry, Movie, Review, SortDirection}, + services::review_history::Trend, + value_objects::MovieId, + }; + + use crate::{ + dtos::{ + DiaryEntryDto, DiaryQueryParams, DiaryResponse, LoginRequest, LoginResponse, + LogReviewRequest, MovieDto, ReviewDto, ReviewHistoryResponse, + }, + errors::ApiError, + extractors::AuthenticatedUser, + state::AppState, + }; + + pub async fn get_diary( + State(state): State, + Query(params): Query, + ) -> Result, ApiError> { + let query = GetDiaryQuery { + limit: params.limit, + offset: params.offset, + sort_by: params.sort_by.as_deref().map(|s| { + if s == "asc" { + SortDirection::Ascending + } else { + SortDirection::Descending + } + }), + movie_id: params.movie_id, + }; + + let page = get_diary::execute(&state.app_ctx, query).await?; + + Ok(Json(DiaryResponse { + items: page.items.iter().map(entry_to_dto).collect(), + total_count: page.total_count, + limit: page.limit, + offset: page.offset, + })) + } + + pub async fn get_review_history( + State(state): State, + Path(movie_id): Path, + ) -> Result, ApiError> { + let (history, trend) = get_review_history::execute( + &state.app_ctx, + GetReviewHistoryQuery { movie_id }, + ) + .await?; + + Ok(Json(ReviewHistoryResponse { + movie: movie_to_dto(history.movie()), + viewings: history.viewings().iter().map(review_to_dto).collect(), + trend: match trend { + Trend::Improved => "improved", + Trend::Declined => "declined", + Trend::Neutral => "neutral", + } + .to_string(), + })) + } + + pub async fn post_review( + State(state): State, + user: AuthenticatedUser, + Json(req): Json, + ) -> Result { + let watched_at = NaiveDateTime::parse_from_str(&req.watched_at, "%Y-%m-%dT%H:%M:%S") + .map_err(|_| { + ApiError(DomainError::ValidationError( + "Invalid watched_at format, expected YYYY-MM-DDTHH:MM:SS".into(), + )) + })?; + + let cmd = LogReviewCommand { + external_metadata_id: req.external_metadata_id, + manual_title: req.manual_title, + manual_release_year: req.manual_release_year, + manual_director: req.manual_director, + user_id: user.0.value(), + rating: req.rating, + comment: req.comment, + watched_at, + }; + + log_review::execute(&state.app_ctx, cmd).await?; + + Ok(StatusCode::CREATED) + } + + pub async fn sync_poster( + State(state): State, + _user: AuthenticatedUser, + Path(movie_id): Path, + ) -> Result { + let movie = state + .app_ctx + .repository + .get_movie_by_id(&MovieId::from_uuid(movie_id)) + .await? + .ok_or_else(|| ApiError(DomainError::NotFound(format!("Movie {movie_id}"))))?; + + let external_id = movie + .external_metadata_id() + .ok_or_else(|| { + ApiError(DomainError::ValidationError( + "Movie has no external metadata ID, cannot sync poster".into(), + )) + })? + .value() + .to_string(); + + sync_poster::execute( + &state.app_ctx, + SyncPosterCommand { + movie_id, + external_metadata_id: external_id, + }, + ) + .await?; + + Ok(StatusCode::NO_CONTENT) + } + + pub async fn login( + State(_state): State, + Json(_req): Json, + ) -> Json { + Json(LoginResponse { + token: "stub-token".to_string(), + }) + } + + fn movie_to_dto(movie: &Movie) -> MovieDto { + MovieDto { + id: movie.id().value(), + title: movie.title().value().to_string(), + release_year: movie.release_year().value(), + director: movie.director().map(|d| d.to_string()), + poster_path: movie.poster_path().map(|p| p.value().to_string()), + } + } + + fn review_to_dto(review: &Review) -> ReviewDto { + ReviewDto { + id: review.id().value(), + rating: review.rating().value(), + comment: review.comment().map(|c| c.value().to_string()), + watched_at: review.watched_at().to_string(), + } + } + + fn entry_to_dto(entry: &DiaryEntry) -> DiaryEntryDto { + DiaryEntryDto { + movie: movie_to_dto(entry.movie()), + review: review_to_dto(entry.review()), + } + } +} diff --git a/crates/presentation/src/lib.rs b/crates/presentation/src/lib.rs index 857540b..bdf4be3 100644 --- a/crates/presentation/src/lib.rs +++ b/crates/presentation/src/lib.rs @@ -4,3 +4,4 @@ pub mod extractors; pub mod handlers; pub mod ports; pub mod routes; +pub mod state; diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index 3239eee..3c481bc 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -1,4 +1,153 @@ +use std::sync::Arc; + +use anyhow::Context; +use async_trait::async_trait; +use domain::{ + errors::DomainError, + events::DomainEvent, + models::Movie, + ports::{EventPublisher, MetadataClient, PasswordHasher, PosterFetcherClient, PosterStorage}, + value_objects::{ExternalMetadataId, MovieId, PasswordHash, PosterPath, PosterUrl}, +}; +use sqlx::SqlitePool; +use tokio::net::TcpListener; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +use application::context::AppContext; +use auth::StubAuthService; +use sqlite::SqliteMovieRepository; +use template_askama::AskamaHtmlRenderer; + +use presentation::{routes, state::AppState}; + +struct StubMetadataClient; + +#[async_trait] +impl MetadataClient for StubMetadataClient { + async fn fetch_movie_metadata(&self, _id: &ExternalMetadataId) -> Result { + Err(DomainError::InfrastructureError( + "metadata client not implemented".into(), + )) + } + + async fn get_poster_url( + &self, + _id: &ExternalMetadataId, + ) -> Result, DomainError> { + Err(DomainError::InfrastructureError( + "metadata client not implemented".into(), + )) + } +} + +struct StubPosterFetcher; + +#[async_trait] +impl PosterFetcherClient for StubPosterFetcher { + async fn fetch_poster_bytes(&self, _url: &PosterUrl) -> Result, DomainError> { + Err(DomainError::InfrastructureError( + "poster fetcher not implemented".into(), + )) + } +} + +struct StubPosterStorage; + +#[async_trait] +impl PosterStorage for StubPosterStorage { + async fn store_poster( + &self, + _movie_id: &MovieId, + _bytes: &[u8], + ) -> Result { + Err(DomainError::InfrastructureError( + "poster storage not implemented".into(), + )) + } + + async fn get_poster(&self, _path: &PosterPath) -> Result, DomainError> { + Err(DomainError::InfrastructureError( + "poster storage not implemented".into(), + )) + } +} + +struct StubEventPublisher; + +#[async_trait] +impl EventPublisher for StubEventPublisher { + async fn publish(&self, _event: &DomainEvent) -> Result<(), DomainError> { + Ok(()) + } +} + +struct StubPasswordHasher; + +#[async_trait] +impl PasswordHasher for StubPasswordHasher { + async fn hash(&self, _plain: &str) -> Result { + Err(DomainError::InfrastructureError( + "password hasher not implemented".into(), + )) + } + + async fn verify(&self, _plain: &str, _hash: &PasswordHash) -> Result { + Err(DomainError::InfrastructureError( + "password hasher not implemented".into(), + )) + } +} + #[tokio::main] async fn main() -> anyhow::Result<()> { + init_tracing(); + + let state = wire_dependencies() + .await + .context("Failed to wire dependencies")?; + + let app = routes::build_router(state); + + let listener = TcpListener::bind("0.0.0.0:3000").await?; + tracing::info!("Listening on 0.0.0.0:3000"); + axum::serve(listener, app).await?; + Ok(()) } + +async fn wire_dependencies() -> anyhow::Result { + let pool = SqlitePool::connect("sqlite://reviews.db") + .await + .context("Failed to connect to SQLite database")?; + + let repo = SqliteMovieRepository::new(pool); + repo.migrate() + .await + .map_err(|e| anyhow::anyhow!("{}", e)) + .context("Database migration failed")?; + + let app_ctx = AppContext { + repository: Arc::new(repo), + metadata_client: Arc::new(StubMetadataClient), + poster_fetcher: Arc::new(StubPosterFetcher), + poster_storage: Arc::new(StubPosterStorage), + event_publisher: Arc::new(StubEventPublisher), + auth_service: Arc::new(StubAuthService), + password_hasher: Arc::new(StubPasswordHasher), + }; + + Ok(AppState { + app_ctx, + html_renderer: Arc::new(AskamaHtmlRenderer::new()), + }) +} + +fn init_tracing() { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG") + .unwrap_or_else(|_| "presentation=debug,tower_http=debug".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); +} diff --git a/crates/presentation/src/ports.rs b/crates/presentation/src/ports.rs index 79c8d6a..a8ce63b 100644 --- a/crates/presentation/src/ports.rs +++ b/crates/presentation/src/ports.rs @@ -1,5 +1 @@ -use domain::models::{DiaryEntry, collections::Paginated}; - -pub trait HtmlRenderer: Send + Sync { - fn render_diary_page(&self, data: &Paginated) -> Result; -} +pub use application::ports::HtmlRenderer; diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 8b13789..e31c3cf 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -1 +1,37 @@ +use axum::{Router, routing}; +use tower_http::{services::ServeDir, trace::TraceLayer}; +use crate::{handlers, state::AppState}; + +pub fn build_router(state: AppState) -> Router { + Router::new() + .merge(html_routes()) + .merge(api_routes()) + .nest_service("/static", ServeDir::new("static")) + .layer(TraceLayer::new_for_http()) + .with_state(state) +} + +fn html_routes() -> Router { + Router::new() + .route("/diary", routing::get(handlers::html::get_diary_page)) + .route("/reviews", routing::post(handlers::html::post_review)) +} + +fn api_routes() -> Router { + Router::new().nest( + "/api", + Router::new() + .route("/diary", routing::get(handlers::api::get_diary)) + .route( + "/movies/{id}/history", + routing::get(handlers::api::get_review_history), + ) + .route("/reviews", routing::post(handlers::api::post_review)) + .route( + "/movies/{id}/sync-poster", + routing::post(handlers::api::sync_poster), + ) + .route("/auth/login", routing::post(handlers::api::login)), + ) +} diff --git a/crates/presentation/src/state.rs b/crates/presentation/src/state.rs new file mode 100644 index 0000000..374f280 --- /dev/null +++ b/crates/presentation/src/state.rs @@ -0,0 +1,11 @@ +use std::sync::Arc; + +use application::context::AppContext; + +use crate::ports::HtmlRenderer; + +#[derive(Clone)] +pub struct AppState { + pub app_ctx: AppContext, + pub html_renderer: Arc, +} diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs new file mode 100644 index 0000000..261025d --- /dev/null +++ b/crates/presentation/tests/api_test.rs @@ -0,0 +1,164 @@ +use std::sync::Arc; + +use application::context::AppContext; +use async_trait::async_trait; +use auth::StubAuthService; +use axum::{ + Router, + body::Body, + http::{Request, StatusCode}, +}; +use domain::{ + errors::DomainError, + events::DomainEvent, + models::{DiaryEntry, DiaryFilter, Movie, Review, ReviewHistory, collections::Paginated}, + ports::{EventPublisher, MetadataClient, PasswordHasher, PosterFetcherClient, PosterStorage}, + value_objects::{ExternalMetadataId, MovieId, PasswordHash, PosterPath, PosterUrl}, +}; +use http_body_util::BodyExt; +use presentation::{routes, state::AppState}; +use sqlite::SqliteMovieRepository; +use sqlx::SqlitePool; +use template_askama::AskamaHtmlRenderer; +use tower::ServiceExt; + +struct NoopEventPublisher; +#[async_trait] +impl EventPublisher for NoopEventPublisher { + async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> { + Ok(()) + } +} + +struct PanicMeta; +#[async_trait] +impl MetadataClient for PanicMeta { + async fn fetch_movie_metadata(&self, _: &ExternalMetadataId) -> Result { + panic!("metadata not wired in tests") + } + async fn get_poster_url( + &self, + _: &ExternalMetadataId, + ) -> Result, DomainError> { + panic!() + } +} + +struct PanicFetcher; +#[async_trait] +impl PosterFetcherClient for PanicFetcher { + async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result, DomainError> { + panic!() + } +} + +struct PanicStorage; +#[async_trait] +impl PosterStorage for PanicStorage { + async fn store_poster(&self, _: &MovieId, _: &[u8]) -> Result { + panic!() + } + async fn get_poster(&self, _: &PosterPath) -> Result, DomainError> { + panic!() + } +} + +struct PanicHasher; +#[async_trait] +impl PasswordHasher for PanicHasher { + async fn hash(&self, _: &str) -> Result { + panic!() + } + async fn verify(&self, _: &str, _: &PasswordHash) -> Result { + panic!() + } +} + +async fn test_app() -> Router { + let pool = SqlitePool::connect("sqlite::memory:") + .await + .expect("in-memory SQLite failed"); + let repo = SqliteMovieRepository::new(pool); + repo.migrate().await.expect("migration failed"); + + let state = AppState { + app_ctx: AppContext { + repository: Arc::new(repo), + metadata_client: Arc::new(PanicMeta), + poster_fetcher: Arc::new(PanicFetcher), + poster_storage: Arc::new(PanicStorage), + event_publisher: Arc::new(NoopEventPublisher), + auth_service: Arc::new(StubAuthService), + password_hasher: Arc::new(PanicHasher), + }, + html_renderer: Arc::new(AskamaHtmlRenderer::new()), + }; + + routes::build_router(state) +} + +#[tokio::test] +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(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + + assert_eq!(json["total_count"], 0); + assert_eq!(json["items"], serde_json::json!([])); + assert_eq!(json["limit"], 20); + assert_eq!(json["offset"], 0); +} + +#[tokio::test] +async fn post_api_reviews_without_auth_returns_400() { + let app = test_app().await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/reviews") + .header("content-type", "application/json") + .body(Body::from( + r#"{"rating":4,"watched_at":"2026-01-01T20:00:00","manual_title":"Dune","manual_release_year":2021}"#, + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn post_api_auth_login_returns_stub_token() { + let app = test_app().await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/login") + .header("content-type", "application/json") + .body(Body::from(r#"{"email":"a@b.com","password":"x"}"#)) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(json["token"], "stub-token"); +}