refactor: presentation becomes http-axum adapter, absorbs templates

This commit is contained in:
2026-10-25 21:34:09 +01:00
parent 2875594ed9
commit a931fb548f
94 changed files with 99 additions and 126 deletions

View File

@@ -0,0 +1,395 @@
use crate::extractors::tests::{AcceptingAuth, test_app_state, test_app_state_with_follow_graph};
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
routing::get,
};
use domain::errors::DomainError;
use std::sync::Arc;
use tower::ServiceExt;
use uuid::Uuid;
// Custom stub for SearchPort that returns empty results instead of panicking
struct SearchPortStub;
#[async_trait::async_trait]
impl domain::ports::SearchPort for SearchPortStub {
async fn search(
&self,
_: &domain::models::SearchQuery,
) -> Result<domain::models::SearchResults, DomainError> {
Ok(domain::models::SearchResults {
movies: domain::models::collections::Paginated {
items: vec![],
total_count: 0,
limit: 10,
offset: 0,
},
people: domain::models::collections::Paginated {
items: vec![],
total_count: 0,
limit: 10,
offset: 0,
},
})
}
}
// Custom stub for PersonQuery that returns 404 instead of panicking
struct PersonQueryStub;
#[async_trait::async_trait]
impl domain::ports::PersonQuery for PersonQueryStub {
async fn get_by_id(
&self,
_: &domain::models::PersonId,
) -> Result<Option<domain::models::Person>, DomainError> {
Ok(None) // Return None to trigger 404
}
async fn get_by_external_id(
&self,
_: &domain::models::ExternalPersonId,
) -> Result<Option<domain::models::Person>, DomainError> {
Ok(None)
}
async fn get_credits(
&self,
_: &domain::models::PersonId,
) -> Result<domain::models::PersonCredits, DomainError> {
Err(DomainError::NotFound("Person not found".into()))
}
async fn list_orphaned_persons(&self) -> Result<Vec<domain::models::PersonId>, DomainError> {
Ok(vec![])
}
async fn list_page(
&self,
_limit: u32,
_offset: u32,
) -> Result<Vec<domain::models::Person>, DomainError> {
Ok(vec![])
}
}
// --- Search endpoint tests ---
#[tokio::test]
async fn search_endpoint_returns_200_with_empty_results() {
let state = crate::extractors::tests::test_app_state_with_search_port(Arc::new(SearchPortStub));
let app = Router::new()
.route("/api/v1/search", get(crate::handlers::search::get_search))
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/search?q=test&limit=10&offset=0")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn search_endpoint_with_no_query_returns_200() {
let state = crate::extractors::tests::test_app_state_with_search_port(Arc::new(SearchPortStub));
let app = Router::new()
.route("/api/v1/search", get(crate::handlers::search::get_search))
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/search?q=&limit=5&offset=0")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
// --- Person endpoint tests ---
#[tokio::test]
async fn person_endpoint_returns_404_for_unknown_id() {
let state =
crate::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub));
let app = Router::new()
.route(
"/api/v1/people/{id}",
get(crate::handlers::search::get_person_handler),
)
.with_state(state);
let unknown_id = Uuid::new_v4();
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/v1/people/{}", unknown_id))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn person_credits_endpoint_returns_404_for_unknown_id() {
let state =
crate::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub));
let app = Router::new()
.route(
"/api/v1/people/{id}/credits",
get(crate::handlers::search::get_person_credits_handler),
)
.with_state(state);
let unknown_id = Uuid::new_v4();
let resp = app
.oneshot(
Request::builder()
.uri(format!("/api/v1/people/{}/credits", unknown_id))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[test]
fn injected_stub_reaches_prebuilt_deps() {
let stub: Arc<dyn domain::ports::PersonQuery> = Arc::new(PersonQueryStub);
let state = crate::extractors::tests::test_app_state_with_person_query(Arc::clone(&stub));
assert!(
Arc::ptr_eq(&state.app_ctx.deps.person.get_person.person_query, &stub),
"prebuilt deps must hold the injected stub too, or handlers reading deps \
will silently use the real dependency"
);
}
// --- Watchlist endpoint tests ---
#[tokio::test]
async fn get_watchlist_requires_auth() {
let state = test_app_state();
let app = Router::new()
.route(
"/api/v1/watchlist",
get(crate::handlers::watchlist::get_watchlist_handler),
)
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/watchlist")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// --- Pending-follower-count endpoint tests ---
struct FixedPendingCount(usize);
#[async_trait::async_trait]
impl domain::ports::FollowGraphQuery for FixedPendingCount {
async fn get_following(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
unreachable!()
}
async fn get_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
unreachable!()
}
async fn get_pending_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
unreachable!()
}
async fn get_pending_following(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
unreachable!()
}
async fn count_following(
&self,
_: &domain::value_objects::UserId,
) -> Result<usize, DomainError> {
unreachable!()
}
async fn count_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<usize, DomainError> {
unreachable!()
}
async fn count_pending_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<usize, DomainError> {
Ok(self.0)
}
async fn get_relation(
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::SocialIdentity,
) -> Result<domain::value_objects::FollowRelation, DomainError> {
unreachable!()
}
}
#[tokio::test]
async fn pending_count_endpoint_reports_the_use_case_result() {
let uid = domain::value_objects::UserId::from_uuid(Uuid::new_v4());
let state = test_app_state_with_follow_graph(
Arc::new(FixedPendingCount(3)),
Arc::new(AcceptingAuth(uid)),
);
let app = Router::new()
.route(
"/api/v1/social/followers/pending/count",
get(crate::handlers::social::get_pending_follower_count),
)
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/social/followers/pending/count")
.header("authorization", "Bearer test-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["count"], 3);
}
#[tokio::test]
async fn get_watchlist_status_requires_auth() {
let state = test_app_state();
let app = Router::new()
.route(
"/api/v1/watchlist/{movie_id}",
get(crate::handlers::watchlist::get_watchlist_status),
)
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/watchlist/00000000-0000-0000-0000-000000000001")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// --- Federation collection error-handling tests ---
#[cfg(feature = "federation")]
struct FailingApDocument;
#[cfg(feature = "federation")]
#[async_trait::async_trait]
impl domain::ports::ApDocumentPort for FailingApDocument {
async fn actor_json(&self, _: &str) -> Result<String, DomainError> {
Err(DomainError::InfrastructureError("boom".into()))
}
async fn followers_collection_json(
&self,
_: Uuid,
_: Option<u32>,
) -> Result<String, DomainError> {
Err(DomainError::InfrastructureError("boom".into()))
}
async fn following_collection_json(
&self,
_: Uuid,
_: Option<u32>,
) -> Result<String, DomainError> {
Err(DomainError::InfrastructureError("boom".into()))
}
}
#[cfg(feature = "federation")]
fn state_with_failing_ap_document() -> crate::state::AppState {
let mut state = test_app_state();
state.app_ctx.ap_document = Arc::new(FailingApDocument);
state
}
#[cfg(feature = "federation")]
#[tokio::test]
async fn followers_collection_returns_500_on_federation_error() {
let app = Router::new()
.route(
"/users/{id}/followers",
get(crate::handlers::social::get_followers_collection),
)
.with_state(state_with_failing_ap_document());
let resp = app
.oneshot(
Request::builder()
.uri(format!("/users/{}/followers", Uuid::nil()))
.header(axum::http::header::ACCEPT, "application/activity+json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[cfg(feature = "federation")]
#[tokio::test]
async fn following_collection_returns_500_on_federation_error() {
let app = Router::new()
.route(
"/users/{id}/following",
get(crate::handlers::social::get_following_collection),
)
.with_state(state_with_failing_ap_document());
let resp = app
.oneshot(
Request::builder()
.uri(format!("/users/{}/following", Uuid::nil()))
.header(axum::http::header::ACCEPT, "application/activity+json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}

View File

@@ -0,0 +1,26 @@
#[test]
fn prebuilt_deps_share_port_instances_with_repos() {
use std::sync::Arc;
let repos = crate::extractors::tests::test_repositories();
let user = Arc::clone(&repos.user);
let follow_graph = Arc::clone(&repos.follow_graph);
let state = crate::extractors::tests::build_test_state_from(
repos,
Arc::new(crate::extractors::tests::Panic),
);
assert!(
Arc::ptr_eq(&state.app_ctx.deps.users.get_local_profile.user, &user),
"deps.users.get_local_profile.user must be the same Arc as the Repositories it was built from"
);
assert!(
Arc::ptr_eq(&state.app_ctx.deps.social.query.follow_graph, &follow_graph),
"deps.social.query.follow_graph must be the same Arc as the Repositories it was built from"
);
assert_eq!(
state.app_ctx.deps.users.get_local_profile.instance, state.app_ctx.instance,
"prebuilt deps must carry the context's InstanceIdentity"
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,119 @@
use super::*;
fn make_form(watched_at: &str) -> LogReviewForm {
LogReviewForm {
external_metadata_id: None,
manual_title: None,
manual_release_year: None,
manual_director: None,
rating: 4,
comment: None,
watched_at: watched_at.to_string(),
watch_medium: None,
spoilers: false,
csrf_token: String::new(),
}
}
fn make_request(watched_at: &str) -> LogReviewRequest {
LogReviewRequest {
external_metadata_id: None,
manual_title: None,
manual_release_year: None,
manual_director: None,
rating: 4,
comment: None,
watched_at: watched_at.to_string(),
watch_medium: None,
spoilers: false,
}
}
#[test]
fn form_accepts_datetime_with_seconds() {
let data = LogReviewData::try_from(make_form("2024-03-15T20:30:00")).unwrap();
assert_eq!(data.watched_at.format("%H:%M:%S").to_string(), "20:30:00");
}
#[test]
fn form_accepts_datetime_without_seconds() {
let data = LogReviewData::try_from(make_form("2024-03-15T20:30")).unwrap();
assert_eq!(data.watched_at.format("%H:%M").to_string(), "20:30");
}
#[test]
fn form_rejects_invalid_datetime() {
assert!(LogReviewData::try_from(make_form("not-a-date")).is_err());
}
#[test]
fn api_accepts_datetime_with_seconds() {
let data = LogReviewData::try_from(make_request("2024-03-15T20:30:00")).unwrap();
assert_eq!(data.watched_at.format("%H:%M:%S").to_string(), "20:30:00");
}
#[test]
fn api_accepts_datetime_without_seconds() {
let data = LogReviewData::try_from(make_request("2024-03-15T20:30")).unwrap();
assert_eq!(data.watched_at.format("%H:%M").to_string(), "20:30");
}
#[test]
fn api_rejects_invalid_datetime() {
assert!(LogReviewData::try_from(make_request("garbage")).is_err());
}
#[test]
fn whitespace_external_id_becomes_none_in_form() {
let mut form = make_form("2024-03-15T20:30:00");
form.external_metadata_id = Some(" ".to_string());
let data = LogReviewData::try_from(form).unwrap();
assert!(data.external_metadata_id.is_none());
}
#[test]
fn whitespace_external_id_becomes_none_in_request() {
let mut req = make_request("2024-03-15T20:30:00");
req.external_metadata_id = Some(" ".to_string());
let data = LogReviewData::try_from(req).unwrap();
assert!(data.external_metadata_id.is_none());
}
#[test]
fn sort_by_asc_string_becomes_ascending() {
let params = DiaryQueryParams {
sort_by: Some("asc".to_string()),
limit: None,
offset: None,
movie_id: None,
user_id: None,
};
let query = to_diary_query(params);
assert!(matches!(
query.sort_by,
Some(domain::models::ReviewSortBy::Ascending)
));
}
#[test]
fn sort_by_other_string_becomes_descending() {
let params = DiaryQueryParams {
sort_by: Some("desc".to_string()),
limit: None,
offset: None,
movie_id: None,
user_id: None,
};
let query = to_diary_query(params);
assert!(matches!(
query.sort_by,
Some(domain::models::ReviewSortBy::Descending)
));
}
#[test]
fn form_accepts_date_only() {
let data = LogReviewData::try_from(make_form("2024-03-15")).unwrap();
assert_eq!(data.watched_at.format("%H:%M:%S").to_string(), "00:00:00");
assert_eq!(data.watched_at.format("%Y-%m-%d").to_string(), "2024-03-15");
}

View File

@@ -0,0 +1,2 @@
mod api_handlers;
mod context;