presentation wiring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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" }
|
||||||
|
|||||||
@@ -4,3 +4,5 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
domain = { workspace = true }
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
5
crates/application/src/ports.rs
Normal file
5
crates/application/src/ports.rs
Normal 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>;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
11
crates/presentation/src/state.rs
Normal file
11
crates/presentation/src/state.rs
Normal 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>,
|
||||||
|
}
|
||||||
164
crates/presentation/tests/api_test.rs
Normal file
164
crates/presentation/tests/api_test.rs
Normal 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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user