presentation wiring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 09:30:20 +02:00
parent 97a496553a
commit 79a06e6844
18 changed files with 883 additions and 18 deletions

View File

@@ -22,6 +22,8 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
async-trait = "0.1" async-trait = "0.1"
uuid = { version = "1.23.0", features = ["v4", "serde"] } uuid = { version = "1.23.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["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" } domain = { path = "crates/domain" }
common = { path = "crates/common" } common = { path = "crates/common" }

View File

@@ -4,3 +4,5 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
async-trait = { workspace = true }
domain = { workspace = true }

View File

@@ -1,14 +1,13 @@
pub fn add(left: u64, right: u64) -> u64 { use async_trait::async_trait;
left + right use domain::{errors::DomainError, ports::AuthService, value_objects::UserId};
}
#[cfg(test)] pub struct StubAuthService;
mod tests {
use super::*;
#[test] #[async_trait]
fn it_works() { impl AuthService for StubAuthService {
let result = add(2, 2); async fn validate_token(&self, _token: &str) -> Result<UserId, DomainError> {
assert_eq!(result, 4); Err(DomainError::InfrastructureError(
"auth service not implemented".into(),
))
} }
} }

View File

@@ -9,4 +9,4 @@ askama = { version = "0.16.0" }
serde = { workspace = true } serde = { workspace = true }
domain = { workspace = true } domain = { workspace = true }
presentation = { workspace = true } application = { workspace = true }

View File

@@ -1,6 +1,6 @@
use askama::Template; use askama::Template;
use application::ports::HtmlRenderer;
use domain::models::{DiaryEntry, collections::Paginated}; use domain::models::{DiaryEntry, collections::Paginated};
use presentation::ports::HtmlRenderer; // Assuming you exposed the port
#[derive(Template)] #[derive(Template)]
#[template(path = "diary.html")] #[template(path = "diary.html")]

View File

@@ -1,4 +1,5 @@
pub mod commands; pub mod commands;
pub mod context; pub mod context;
pub mod ports;
pub mod queries; pub mod queries;
pub mod use_cases; pub mod use_cases;

View File

@@ -0,0 +1,5 @@
use domain::models::{DiaryEntry, collections::Paginated};
pub trait HtmlRenderer: Send + Sync {
fn render_diary_page(&self, data: &Paginated<DiaryEntry>) -> Result<String, String>;
}

View File

@@ -15,6 +15,16 @@ tracing = { workspace = true }
tracing-subscriber = { workspace = true } tracing-subscriber = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true }
async-trait = { workspace = true }
domain = { workspace = true } domain = { workspace = true }
application = { 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"

View File

@@ -0,0 +1,118 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Deserialize)]
pub struct DiaryQueryParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
pub sort_by: Option<String>,
pub movie_id: Option<Uuid>,
}
#[derive(Deserialize)]
pub struct LogReviewForm {
pub external_metadata_id: Option<String>,
pub manual_title: Option<String>,
pub manual_release_year: Option<u16>,
pub manual_director: Option<String>,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: String,
}
#[derive(Deserialize)]
pub struct LogReviewRequest {
pub external_metadata_id: Option<String>,
pub manual_title: Option<String>,
pub manual_release_year: Option<u16>,
pub manual_director: Option<String>,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: String,
}
#[derive(Serialize)]
pub struct MovieDto {
pub id: Uuid,
pub title: String,
pub release_year: u16,
pub director: Option<String>,
pub poster_path: Option<String>,
}
#[derive(Serialize)]
pub struct ReviewDto {
pub id: Uuid,
pub rating: u8,
pub comment: Option<String>,
pub watched_at: String,
}
#[derive(Serialize)]
pub struct DiaryEntryDto {
pub movie: MovieDto,
pub review: ReviewDto,
}
#[derive(Serialize)]
pub struct DiaryResponse {
pub items: Vec<DiaryEntryDto>,
pub total_count: u64,
pub limit: u32,
pub offset: u32,
}
#[derive(Serialize)]
pub struct ReviewHistoryResponse {
pub movie: MovieDto,
pub viewings: Vec<ReviewDto>,
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");
}
}

View File

@@ -4,7 +4,7 @@ use axum::{
}; };
use domain::errors::DomainError; use domain::errors::DomainError;
pub struct ApiError(DomainError); pub struct ApiError(pub DomainError);
impl From<DomainError> for ApiError { impl From<DomainError> for ApiError {
fn from(err: DomainError) -> Self { fn from(err: DomainError) -> Self {

View File

@@ -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<S> FromRequestParts<S> for AuthenticatedUser
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
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<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 save_review(&self, _: &domain::models::Review) -> Result<domain::events::DomainEvent, domain::errors::DomainError> { panic!() }
async fn query_diary(&self, _: &domain::models::DiaryFilter) -> Result<domain::models::collections::Paginated<domain::models::DiaryEntry>, domain::errors::DomainError> { panic!() }
async fn get_review_history(&self, _: &domain::value_objects::MovieId) -> Result<domain::models::ReviewHistory, domain::errors::DomainError> { panic!() }
}
struct PanicRenderer;
impl crate::ports::HtmlRenderer for PanicRenderer {
fn render_diary_page(&self, _: &domain::models::collections::Paginated<domain::models::DiaryEntry>) -> Result<String, String> { 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<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!() } }
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);
}
}

View File

@@ -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<AppState>,
Query(params): Query<DiaryQueryParams>,
) -> Result<impl IntoResponse, 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?;
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<AppState>,
user: AuthenticatedUser,
Form(form): Form<LogReviewForm>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
Query(params): Query<DiaryQueryParams>,
) -> Result<Json<DiaryResponse>, 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<AppState>,
Path(movie_id): Path<Uuid>,
) -> Result<Json<ReviewHistoryResponse>, 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<AppState>,
user: AuthenticatedUser,
Json(req): Json<LogReviewRequest>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
_user: AuthenticatedUser,
Path(movie_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
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<AppState>,
Json(_req): Json<LoginRequest>,
) -> Json<LoginResponse> {
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()),
}
}
}

View File

@@ -4,3 +4,4 @@ pub mod extractors;
pub mod handlers; pub mod handlers;
pub mod ports; pub mod ports;
pub mod routes; pub mod routes;
pub mod state;

View File

@@ -1,4 +1,153 @@
#[tokio::main] use std::sync::Arc;
async fn main() -> anyhow::Result<()> {
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<Movie, DomainError> {
Err(DomainError::InfrastructureError(
"metadata client not implemented".into(),
))
}
async fn get_poster_url(
&self,
_id: &ExternalMetadataId,
) -> Result<Option<PosterUrl>, 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<Vec<u8>, 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<PosterPath, DomainError> {
Err(DomainError::InfrastructureError(
"poster storage not implemented".into(),
))
}
async fn get_poster(&self, _path: &PosterPath) -> Result<Vec<u8>, 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(()) Ok(())
} }
}
struct StubPasswordHasher;
#[async_trait]
impl PasswordHasher for StubPasswordHasher {
async fn hash(&self, _plain: &str) -> Result<PasswordHash, DomainError> {
Err(DomainError::InfrastructureError(
"password hasher not implemented".into(),
))
}
async fn verify(&self, _plain: &str, _hash: &PasswordHash) -> Result<bool, DomainError> {
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<AppState> {
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();
}

View File

@@ -1,5 +1 @@
use domain::models::{DiaryEntry, collections::Paginated}; pub use application::ports::HtmlRenderer;
pub trait HtmlRenderer: Send + Sync {
fn render_diary_page(&self, data: &Paginated<DiaryEntry>) -> Result<String, String>;
}

View File

@@ -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<AppState> {
Router::new()
.route("/diary", routing::get(handlers::html::get_diary_page))
.route("/reviews", routing::post(handlers::html::post_review))
}
fn api_routes() -> Router<AppState> {
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)),
)
}

View File

@@ -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<dyn HtmlRenderer>,
}

View File

@@ -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<Movie, DomainError> {
panic!("metadata not wired in tests")
}
async fn get_poster_url(
&self,
_: &ExternalMetadataId,
) -> Result<Option<PosterUrl>, DomainError> {
panic!()
}
}
struct PanicFetcher;
#[async_trait]
impl PosterFetcherClient for PanicFetcher {
async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result<Vec<u8>, DomainError> {
panic!()
}
}
struct PanicStorage;
#[async_trait]
impl PosterStorage for PanicStorage {
async fn store_poster(&self, _: &MovieId, _: &[u8]) -> Result<PosterPath, DomainError> {
panic!()
}
async fn get_poster(&self, _: &PosterPath) -> Result<Vec<u8>, DomainError> {
panic!()
}
}
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 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");
}