fmt
Some checks failed
CI / Check / Test / Build (push) Has been cancelled

This commit is contained in:
2026-05-13 23:38:57 +02:00
parent 7415b91e23
commit 19171806b9
142 changed files with 4140 additions and 2025 deletions

View File

@@ -2,7 +2,10 @@ use chrono::NaiveDateTime;
use serde::Deserialize;
use uuid::Uuid;
use application::{commands::{LogReviewCommand, MovieInput}, queries::GetDiaryQuery};
use application::{
commands::{LogReviewCommand, MovieInput},
queries::GetDiaryQuery,
};
use domain::{errors::DomainError, models::SortDirection};
use api_types::{DiaryQueryParams, LogReviewRequest};

View File

@@ -10,55 +10,57 @@ use std::str::FromStr;
use application::{
commands::{
DeleteReviewCommand, MovieInput, RegisterCommand, SyncPosterCommand,
AddToWatchlistCommand, RemoveFromWatchlistCommand,
AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RegisterCommand,
RemoveFromWatchlistCommand, SyncPosterCommand,
},
queries::{
ExportQuery, GetActivityFeedQuery, GetMovieSocialPageQuery, GetMoviesQuery,
GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery, LoginQuery,
GetWatchlistQuery, IsOnWatchlistQuery,
GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery, GetWatchlistQuery,
IsOnWatchlistQuery, LoginQuery,
},
use_cases::{
delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
get_diary, get_movie_social_page, get_movies, get_review_history,
get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc,
register as register_uc, sync_poster, update_profile, update_profile_fields,
search as search_uc, get_person, get_person_credits,
add_to_watchlist, remove_from_watchlist, get_watchlist, is_on_watchlist,
add_to_watchlist, delete_review, export_diary as export_diary_uc,
get_activity_feed as get_feed_uc, get_diary, get_movie_social_page, get_movies, get_person,
get_person_credits, get_review_history, get_user_profile as get_user_profile_uc, get_users,
get_watchlist, is_on_watchlist, log_review, login as login_uc, register as register_uc,
remove_from_watchlist, search as search_uc, sync_poster, update_profile,
update_profile_fields,
},
};
use domain::{
errors::DomainError,
models::{DiaryEntry, ExportFormat, Movie, MovieSummary, Review, PersonId, collections::PageParams},
models::{
DiaryEntry, ExportFormat, Movie, MovieSummary, PersonId, Review, collections::PageParams,
},
services::review_history::Trend,
value_objects::{MovieId, UserId},
};
use crate::{
errors::ApiError,
extractors::AuthenticatedUser,
forms::{LogReviewData, to_diary_query},
state::AppState,
};
use api_types::search::{
CastCreditDto, CrewCreditDto, MovieSearchHitDto, PaginatedMovieHits, PaginatedPersonHits,
PersonCreditsDto, PersonDto, PersonSearchHitDto, SearchQueryParams, SearchResponse,
};
use api_types::{
ActivityFeedQueryParams, ActivityFeedResponse, AddToWatchlistRequest, CastMemberDto,
CrewMemberDto, DiaryEntryDto, DiaryQueryParams, DiaryResponse, DirectorStatDto,
ExportQueryParams, FeedEntryDto, GenreDto, KeywordDto, LogReviewRequest, LoginRequest,
LoginResponse, MonthActivityDto, MonthlyRatingDto, MovieDetailResponse, MovieDto,
MovieProfileResponse, MovieStatsDto, MoviesQueryParams, MoviesResponse, PaginationQueryParams,
ProfileResponse, RegisterRequest, ReviewDto, ReviewHistoryResponse, SocialFeedResponse,
SocialReviewDto, UserProfileQueryParams, UserProfileResponse, UserStatsDto, UserSummaryDto,
UserTrendsDto, UsersResponse, WatchlistEntryDto, WatchlistResponse, WatchlistStatusResponse,
};
#[cfg(feature = "federation")]
use api_types::{
ActorListResponse, ActorUrlRequest, AddBlockedDomainRequest, BlockedActorResponse,
BlockedDomainResponse, FollowRequest, RemoteActorDto,
};
use api_types::{
ActivityFeedQueryParams, ActivityFeedResponse, CastMemberDto, CrewMemberDto, DiaryEntryDto,
DiaryQueryParams, DiaryResponse, DirectorStatDto, ExportQueryParams, FeedEntryDto,
GenreDto, KeywordDto, LogReviewRequest, LoginRequest, LoginResponse, MonthActivityDto,
MonthlyRatingDto, MovieDetailResponse, MovieDto, MovieProfileResponse, MovieStatsDto,
MoviesQueryParams, MoviesResponse, PaginationQueryParams, ProfileResponse, RegisterRequest,
ReviewDto, ReviewHistoryResponse, SocialFeedResponse, SocialReviewDto, UserProfileQueryParams,
UserProfileResponse, UserStatsDto, UserSummaryDto, UserTrendsDto, UsersResponse,
WatchlistResponse, WatchlistEntryDto, WatchlistStatusResponse, AddToWatchlistRequest,
};
use api_types::search::{
CastCreditDto, CrewCreditDto, MovieSearchHitDto, PersonCreditsDto, PersonDto,
PersonSearchHitDto, PaginatedMovieHits, PaginatedPersonHits, SearchQueryParams, SearchResponse,
};
use crate::{
errors::ApiError,
extractors::AuthenticatedUser,
forms::{to_diary_query, LogReviewData},
state::AppState,
};
#[utoipa::path(
get, path = "/api/v1/diary",
@@ -307,7 +309,11 @@ pub async fn get_movie_detail(
let result = get_movie_social_page::execute(
&state.app_ctx,
GetMovieSocialPageQuery { movie_id, limit, offset },
GetMovieSocialPageQuery {
movie_id,
limit,
offset,
},
)
.await?;
@@ -320,13 +326,18 @@ pub async fn get_movie_detail(
rating_histogram: result.stats.rating_histogram,
},
reviews: SocialFeedResponse {
items: result.reviews.items.iter().map(|e| SocialReviewDto {
user_display: e.user_display_name().to_string(),
rating: e.review().rating().value(),
comment: e.review().comment().map(|c| c.value().to_string()),
watched_at: e.review().watched_at().to_string(),
is_federated: e.review().is_remote(),
}).collect(),
items: result
.reviews
.items
.iter()
.map(|e| SocialReviewDto {
user_display: e.user_display_name().to_string(),
rating: e.review().rating().value(),
comment: e.review().comment().map(|c| c.value().to_string()),
watched_at: e.review().watched_at().to_string(),
is_federated: e.review().is_remote(),
})
.collect(),
total_count: result.reviews.total_count,
limit: result.reviews.limit,
offset: result.reviews.offset,
@@ -347,7 +358,12 @@ pub async fn get_movie_profile(
Path(movie_id): Path<Uuid>,
) -> impl IntoResponse {
let id = domain::value_objects::MovieId::from_uuid(movie_id);
match state.app_ctx.movie_profile_repository.get_by_movie_id(&id).await {
match state
.app_ctx
.movie_profile_repository
.get_by_movie_id(&id)
.await
{
Ok(Some(p)) => Json(MovieProfileResponse {
tmdb_id: p.tmdb_id,
imdb_id: p.imdb_id,
@@ -360,18 +376,47 @@ pub async fn get_movie_profile(
vote_count: p.vote_count,
original_language: p.original_language,
collection_name: p.collection_name,
genres: p.genres.into_iter().map(|g| GenreDto { tmdb_id: g.tmdb_id, name: g.name }).collect(),
keywords: p.keywords.into_iter().map(|k| KeywordDto { tmdb_id: k.tmdb_id, name: k.name }).collect(),
cast: p.cast.into_iter().map(|c| CastMemberDto {
tmdb_person_id: c.tmdb_person_id, name: c.name, character: c.character,
billing_order: c.billing_order, profile_path: c.profile_path,
}).collect(),
crew: p.crew.into_iter().map(|c| CrewMemberDto {
tmdb_person_id: c.tmdb_person_id, name: c.name, job: c.job,
department: c.department, profile_path: c.profile_path,
}).collect(),
genres: p
.genres
.into_iter()
.map(|g| GenreDto {
tmdb_id: g.tmdb_id,
name: g.name,
})
.collect(),
keywords: p
.keywords
.into_iter()
.map(|k| KeywordDto {
tmdb_id: k.tmdb_id,
name: k.name,
})
.collect(),
cast: p
.cast
.into_iter()
.map(|c| CastMemberDto {
tmdb_person_id: c.tmdb_person_id,
name: c.name,
character: c.character,
billing_order: c.billing_order,
profile_path: c.profile_path,
})
.collect(),
crew: p
.crew
.into_iter()
.map(|c| CrewMemberDto {
tmdb_person_id: c.tmdb_person_id,
name: c.name,
job: c.job,
department: c.department,
profile_path: c.profile_path,
})
.collect(),
enriched_at: p.enriched_at.to_rfc3339(),
}).into_response(),
})
.into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("get_movie_profile: {:?}", e);
@@ -440,7 +485,11 @@ pub async fn update_profile_handler(
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"bio" => { if let Ok(text) = field.text().await { bio = Some(text); } }
"bio" => {
if let Ok(text) = field.text().await {
bio = Some(text);
}
}
"also_known_as" => {
if let Ok(text) = field.text().await {
also_known_as = Some(text).filter(|s| !s.is_empty());
@@ -449,13 +498,19 @@ pub async fn update_profile_handler(
"avatar" => {
let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() { avatar_bytes = Some(bytes.to_vec()); avatar_content_type = ct; }
if !bytes.is_empty() {
avatar_bytes = Some(bytes.to_vec());
avatar_content_type = ct;
}
}
}
"banner" => {
let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() { banner_bytes = Some(bytes.to_vec()); banner_content_type = ct; }
if !bytes.is_empty() {
banner_bytes = Some(bytes.to_vec());
banner_content_type = ct;
}
}
}
_ => {}
@@ -613,7 +668,11 @@ pub async fn add_blocked_domain_admin(
_admin: crate::extractors::AdminUser,
axum::Json(body): axum::Json<AddBlockedDomainRequest>,
) -> impl IntoResponse {
match state.ap_service.add_blocked_domain(&body.domain, body.reason.as_deref()).await {
match state
.ap_service
.add_blocked_domain(&body.domain, body.reason.as_deref())
.await
{
Ok(()) => StatusCode::CREATED.into_response(),
Err(e) => ap_err(e).into_response(),
}
@@ -656,7 +715,11 @@ pub async fn block_actor_api(
user: AuthenticatedUser,
axum::Json(body): axum::Json<ActorUrlRequest>,
) -> impl IntoResponse {
match state.ap_service.block_actor(user.0.value(), &body.actor_url).await {
match state
.ap_service
.block_actor(user.0.value(), &body.actor_url)
.await
{
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => ap_err(e).into_response(),
}
@@ -677,7 +740,11 @@ pub async fn unblock_actor_api(
user: AuthenticatedUser,
axum::Json(body): axum::Json<ActorUrlRequest>,
) -> impl IntoResponse {
match state.ap_service.unblock_actor(user.0.value(), &body.actor_url).await {
match state
.ap_service
.unblock_actor(user.0.value(), &body.actor_url)
.await
{
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => ap_err(e).into_response(),
}
@@ -972,9 +1039,7 @@ pub async fn get_activity_feed(
get, path = "/api/v1/users",
responses((status = 200, body = UsersResponse)),
)]
pub async fn list_users(
State(state): State<AppState>,
) -> Result<Json<UsersResponse>, ApiError> {
pub async fn list_users(State(state): State<AppState>) -> Result<Json<UsersResponse>, ApiError> {
let users = get_users::execute(&state.app_ctx, GetUsersQuery).await?;
Ok(Json(UsersResponse {
users: users
@@ -1199,31 +1264,42 @@ pub async fn get_search(
match search_uc::execute(&state.app_ctx, query).await {
Ok(results) => axum::Json(SearchResponse {
movies: PaginatedMovieHits {
items: results.movies.items.iter().map(|h| MovieSearchHitDto {
movie_id: h.movie_id.value(),
title: h.title.clone(),
release_year: h.release_year,
director: h.director.clone(),
poster_path: h.poster_path.clone(),
genres: h.genres.clone(),
}).collect(),
items: results
.movies
.items
.iter()
.map(|h| MovieSearchHitDto {
movie_id: h.movie_id.value(),
title: h.title.clone(),
release_year: h.release_year,
director: h.director.clone(),
poster_path: h.poster_path.clone(),
genres: h.genres.clone(),
})
.collect(),
total_count: results.movies.total_count,
limit: results.movies.limit,
offset: results.movies.offset,
},
people: PaginatedPersonHits {
items: results.people.items.iter().map(|h| PersonSearchHitDto {
person_id: h.person_id.value(),
name: h.name.clone(),
known_for_department: h.known_for_department.clone(),
profile_path: h.profile_path.clone(),
known_for_titles: h.known_for_titles.clone(),
}).collect(),
items: results
.people
.items
.iter()
.map(|h| PersonSearchHitDto {
person_id: h.person_id.value(),
name: h.name.clone(),
known_for_department: h.known_for_department.clone(),
profile_path: h.profile_path.clone(),
known_for_titles: h.known_for_titles.clone(),
})
.collect(),
total_count: results.people.total_count,
limit: results.people.limit,
offset: results.people.offset,
},
}).into_response(),
})
.into_response(),
Err(e) => {
tracing::error!("search failed: {e}");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
@@ -1251,7 +1327,8 @@ pub async fn get_person_handler(
name: person.name().to_string(),
known_for_department: person.known_for_department().map(str::to_string),
profile_path: person.profile_path().map(str::to_string),
}).into_response(),
})
.into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("get_person failed: {e}");
@@ -1282,22 +1359,31 @@ pub async fn get_person_credits_handler(
known_for_department: credits.person.known_for_department().map(str::to_string),
profile_path: credits.person.profile_path().map(str::to_string),
},
cast: credits.cast.iter().map(|c| CastCreditDto {
movie_id: c.movie_id.value(),
title: c.title.clone(),
release_year: c.release_year,
character: c.character.clone(),
poster_path: c.poster_path.clone(),
}).collect(),
crew: credits.crew.iter().map(|c| CrewCreditDto {
movie_id: c.movie_id.value(),
title: c.title.clone(),
release_year: c.release_year,
job: c.job.clone(),
department: c.department.clone(),
poster_path: c.poster_path.clone(),
}).collect(),
}).into_response(),
cast: credits
.cast
.iter()
.map(|c| CastCreditDto {
movie_id: c.movie_id.value(),
title: c.title.clone(),
release_year: c.release_year,
character: c.character.clone(),
poster_path: c.poster_path.clone(),
})
.collect(),
crew: credits
.crew
.iter()
.map(|c| CrewCreditDto {
movie_id: c.movie_id.value(),
title: c.title.clone(),
release_year: c.release_year,
job: c.job.clone(),
department: c.department.clone(),
poster_path: c.poster_path.clone(),
})
.collect(),
})
.into_response(),
Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("get_person_credits failed: {e}");
@@ -1334,11 +1420,15 @@ pub async fn get_watchlist_handler(
.await?;
Ok(Json(WatchlistResponse {
items: page.items.into_iter().map(|w| WatchlistEntryDto {
id: w.entry.id.value(),
movie: movie_to_dto(&w.movie),
added_at: w.entry.added_at.to_string(),
}).collect(),
items: page
.items
.into_iter()
.map(|w| WatchlistEntryDto {
id: w.entry.id.value(),
movie: movie_to_dto(&w.movie),
added_at: w.entry.added_at.to_string(),
})
.collect(),
total_count: page.total_count,
limit: page.limit,
offset: page.offset,
@@ -1394,7 +1484,10 @@ pub async fn delete_watchlist_entry(
) -> Result<impl IntoResponse, ApiError> {
remove_from_watchlist::execute(
&state.app_ctx,
RemoveFromWatchlistCommand { user_id: user.0.value(), movie_id },
RemoveFromWatchlistCommand {
user_id: user.0.value(),
movie_id,
},
)
.await?;
Ok(StatusCode::NO_CONTENT)
@@ -1416,7 +1509,10 @@ pub async fn get_watchlist_status(
) -> Result<Json<WatchlistStatusResponse>, ApiError> {
let on_watchlist = is_on_watchlist::execute(
&state.app_ctx,
IsOnWatchlistQuery { user_id: user.0.value(), movie_id },
IsOnWatchlistQuery {
user_id: user.0.value(),
movie_id,
},
)
.await?;
Ok(Json(WatchlistStatusResponse { on_watchlist }))

View File

@@ -9,6 +9,25 @@ use axum::{
use chrono::Utc;
use uuid::Uuid;
use application::{
commands::{
AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RegisterCommand,
RemoveFromWatchlistCommand,
},
ports::{
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData,
ProfileSettingsPageData, RegisterPageData, RemoteActorView, WatchlistDisplayEntry,
WatchlistPageData,
},
queries::{
ExportQuery, GetMovieSocialPageQuery, GetWatchlistQuery, IsOnWatchlistQuery, LoginQuery,
},
use_cases::{
add_to_watchlist, delete_review, export_diary as export_diary_uc, get_movie_social_page,
get_watchlist, is_on_watchlist, log_review, login as login_uc, register as register_uc,
remove_from_watchlist, update_profile, update_profile_fields,
},
};
#[cfg(feature = "federation")]
use application::{
ports::{
@@ -17,29 +36,17 @@ use application::{
},
use_cases::get_remote_watchlist,
};
use application::{
commands::{AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RegisterCommand, RemoveFromWatchlistCommand},
queries::{ExportQuery, GetMovieSocialPageQuery, GetWatchlistQuery, IsOnWatchlistQuery, LoginQuery},
ports::{
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData,
ProfileSettingsPageData, RegisterPageData, RemoteActorView, WatchlistDisplayEntry,
WatchlistPageData,
},
use_cases::{
add_to_watchlist, delete_review, export_diary as export_diary_uc, get_movie_social_page,
get_watchlist, is_on_watchlist, log_review, login as login_uc, register as register_uc,
remove_from_watchlist, update_profile, update_profile_fields,
},
};
use domain::models::ExportFormat;
use domain::{errors::DomainError, value_objects::UserId};
#[cfg(feature = "federation")]
use crate::forms::{ActorUrlForm, BlockDomainForm, FollowForm, FollowerActionForm, RemoveDomainForm, UnfollowForm};
use crate::forms::{
ActorUrlForm, BlockDomainForm, FollowForm, FollowerActionForm, RemoveDomainForm, UnfollowForm,
};
use crate::{
csrf::CsrfToken,
forms::{ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, LoginForm, RegisterForm},
extractors::{AdminUser, OptionalCookieUser, RequiredCookieUser},
forms::{ErrorQuery, FeedQueryParams, LogReviewData, LogReviewForm, LoginForm, RegisterForm},
state::AppState,
};
@@ -58,7 +65,10 @@ pub(crate) async fn build_page_context(
.ok()
.flatten();
let email = user.as_ref().map(|u| u.email().value().to_string());
let admin = user.as_ref().map(|u| matches!(u.role(), domain::models::UserRole::Admin)).unwrap_or(false);
let admin = user
.as_ref()
.map(|u| matches!(u.role(), domain::models::UserRole::Admin))
.unwrap_or(false);
(email, admin)
} else {
(None, false)
@@ -219,18 +229,17 @@ pub async fn post_register(
)
.await
{
Ok(_) => {
match login_uc::execute(&state.app_ctx, LoginQuery { email, password }).await {
Ok(result) => {
let max_age = (result.expires_at - Utc::now()).num_seconds().max(0);
let cookie = set_cookie_header(&result.token, max_age);
([cookie], Redirect::to("/")).into_response()
}
Err(_) => Redirect::to("/login").into_response(),
Ok(_) => match login_uc::execute(&state.app_ctx, LoginQuery { email, password }).await {
Ok(result) => {
let max_age = (result.expires_at - Utc::now()).num_seconds().max(0);
let cookie = set_cookie_header(&result.token, max_age);
([cookie], Redirect::to("/")).into_response()
}
Err(_) => Redirect::to("/login").into_response(),
},
Err(_) => {
Redirect::to("/register?error=Registration+failed.+Please+try+again.").into_response()
}
Err(_) => Redirect::to("/register?error=Registration+failed.+Please+try+again.")
.into_response(),
}
}
@@ -389,10 +398,11 @@ pub async fn get_activity_feed(
let mut remote_urls = Vec::new();
for url in urls {
if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url))
&& let Ok(parsed_id) = uuid::Uuid::parse_str(suffix) {
local_ids.push(parsed_id);
continue;
}
&& let Ok(parsed_id) = uuid::Uuid::parse_str(suffix)
{
local_ids.push(parsed_id);
continue;
}
remote_urls.push(url);
}
Some(domain::ports::FollowingFilter {
@@ -533,9 +543,7 @@ pub async fn get_user_profile(
.get(axum::http::header::ACCEPT)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if accept.contains("application/activity+json")
|| accept.contains("application/ld+json")
{
if accept.contains("application/activity+json") || accept.contains("application/ld+json") {
return match state
.ap_service
.actor_json(&profile_user_uuid.to_string())
@@ -667,8 +675,7 @@ pub async fn get_user_profile(
.entries
.as_ref()
.map(|e| {
let has_more =
(e.offset as u64).saturating_add(e.limit as u64) < e.total_count;
let has_more = (e.offset as u64).saturating_add(e.limit as u64) < e.total_count;
(e.offset, has_more, e.limit)
})
.unwrap_or((0, false, super::DEFAULT_PAGE_LIMIT));
@@ -730,7 +737,11 @@ pub async fn follow_remote_user(
Err(e) => {
tracing::error!("follow error: {:?}", e);
let msg = encode_error(&e.to_string());
let sep = if redirect_base.contains('?') { '&' } else { '?' };
let sep = if redirect_base.contains('?') {
'&'
} else {
'?'
};
Redirect::to(&format!("{}{}error={}", redirect_base, sep, msg)).into_response()
}
}
@@ -755,8 +766,9 @@ pub async fn unfollow_remote_user(
.unfollow(user_id.value(), &form.actor_url)
.await
{
Ok(()) => Redirect::to(&format!("/users/{}/following-list", profile_user_uuid))
.into_response(),
Ok(()) => {
Redirect::to(&format!("/users/{}/following-list", profile_user_uuid)).into_response()
}
Err(e) => {
let msg = encode_error(&e.to_string());
Redirect::to(&format!(
@@ -945,8 +957,9 @@ pub async fn remove_follower(
.remove_follower(user_id.value(), &form.actor_url)
.await
{
Ok(_) => Redirect::to(&format!("/users/{}/followers-list", profile_user_uuid))
.into_response(),
Ok(_) => {
Redirect::to(&format!("/users/{}/followers-list", profile_user_uuid)).into_response()
}
Err(e) => {
let msg = encode_error(&e.to_string());
Redirect::to(&format!(
@@ -971,7 +984,11 @@ pub async fn get_movie_detail(
match get_movie_social_page::execute(
&state.app_ctx,
GetMovieSocialPageQuery { movie_id, limit, offset },
GetMovieSocialPageQuery {
movie_id,
limit,
offset,
},
)
.await
{
@@ -982,13 +999,22 @@ pub async fn get_movie_detail(
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
Ok(result) => {
let histogram_max = result.stats.rating_histogram.iter().copied().max().unwrap_or(1);
let has_more = result.reviews.offset + result.reviews.limit
< result.reviews.total_count as u32;
let histogram_max = result
.stats
.rating_histogram
.iter()
.copied()
.max()
.unwrap_or(1);
let has_more =
result.reviews.offset + result.reviews.limit < result.reviews.total_count as u32;
let on_watchlist = match &user_id {
Some(uid) => is_on_watchlist::execute(
&state.app_ctx,
IsOnWatchlistQuery { user_id: uid.value(), movie_id },
IsOnWatchlistQuery {
user_id: uid.value(),
movie_id,
},
)
.await
.unwrap_or(false),
@@ -1030,7 +1056,9 @@ pub async fn get_watchlist_page(
let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false);
// Try local user first
let local_user = state.app_ctx.user_repository
let local_user = state
.app_ctx
.user_repository
.find_by_id(&domain::value_objects::UserId::from_uuid(owner_id))
.await
.ok()
@@ -1039,30 +1067,42 @@ pub async fn get_watchlist_page(
let (display_entries, has_more, current_offset, page_limit) = if local_user.is_some() {
match get_watchlist::execute(
&state.app_ctx,
GetWatchlistQuery { user_id: owner_id, limit: Some(limit), offset: Some(offset) },
).await {
GetWatchlistQuery {
user_id: owner_id,
limit: Some(limit),
offset: Some(offset),
},
)
.await
{
Err(e) => {
tracing::error!("watchlist error: {:?}", e);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
Ok(entries) => {
let has_more = entries.offset + entries.limit < entries.total_count as u32;
let display: Vec<WatchlistDisplayEntry> = entries.items.iter().map(|w| {
let remove_url = if is_owner {
Some(format!("/watchlist/{}/remove", w.movie.id().value()))
} else {
None
};
WatchlistDisplayEntry {
poster_url: w.movie.poster_path()
.map(|p| format!("/images/{}", p.value())),
movie_title: w.movie.title().value().to_string(),
release_year: w.movie.release_year().value(),
movie_url: Some(format!("/movies/{}", w.movie.id().value())),
added_at: w.entry.added_at.format("%b %-d, %Y").to_string(),
remove_url,
}
}).collect();
let display: Vec<WatchlistDisplayEntry> = entries
.items
.iter()
.map(|w| {
let remove_url = if is_owner {
Some(format!("/watchlist/{}/remove", w.movie.id().value()))
} else {
None
};
WatchlistDisplayEntry {
poster_url: w
.movie
.poster_path()
.map(|p| format!("/images/{}", p.value())),
movie_title: w.movie.title().value().to_string(),
release_year: w.movie.release_year().value(),
movie_url: Some(format!("/movies/{}", w.movie.id().value())),
added_at: w.entry.added_at.format("%b %-d, %Y").to_string(),
remove_url,
}
})
.collect();
(display, has_more, entries.offset, entries.limit)
}
}
@@ -1072,16 +1112,17 @@ pub async fn get_watchlist_page(
let remote_entries = get_remote_watchlist::execute(&state.app_ctx, owner_id)
.await
.unwrap_or_default();
let display: Vec<WatchlistDisplayEntry> = remote_entries.into_iter().map(|e| {
WatchlistDisplayEntry {
let display: Vec<WatchlistDisplayEntry> = remote_entries
.into_iter()
.map(|e| WatchlistDisplayEntry {
poster_url: e.poster_url,
movie_title: e.movie_title,
release_year: e.release_year,
movie_url: None,
added_at: e.added_at.format("%b %-d, %Y").to_string(),
remove_url: None,
}
}).collect();
})
.collect();
let len = display.len() as u32;
(display, false, 0u32, len)
}
@@ -1172,7 +1213,11 @@ pub async fn post_watchlist_add(
Ok(()) => Redirect::to(&redirect_base).into_response(),
Err(DomainError::NotFound(_)) => Redirect::to(&redirect_base).into_response(),
Err(DomainError::ValidationError(msg)) => {
let sep = if redirect_base.contains('?') { '&' } else { '?' };
let sep = if redirect_base.contains('?') {
'&'
} else {
'?'
};
let url = format!("{}{}error={}", redirect_base, sep, encode_error(&msg));
Redirect::to(&url).into_response()
}
@@ -1231,12 +1276,7 @@ pub async fn get_profile_settings(
ctx.page_title = "Profile Settings — Movies Diary".to_string();
ctx.canonical_url = format!("{}/settings/profile", state.app_ctx.config.base_url);
let user = match state
.app_ctx
.user_repository
.find_by_id(&user_id)
.await
{
let user = match state.app_ctx.user_repository.find_by_id(&user_id).await {
Ok(Some(u)) => u,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => {
@@ -1253,7 +1293,9 @@ pub async fn get_profile_settings(
.banner_path()
.map(|path| format!("{}/images/{}", base_url, path));
let profile_fields = state.app_ctx.profile_fields_repository
let profile_fields = state
.app_ctx
.profile_fields_repository
.get_fields(&user_id)
.await
.unwrap_or_default()
@@ -1319,7 +1361,11 @@ pub async fn get_blocked_domains_page(
}
Err(e) => {
tracing::error!("get_blocked_domains error: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to load blocked domains").into_response()
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to load blocked domains",
)
.into_response()
}
}
}
@@ -1335,7 +1381,11 @@ pub async fn post_blocked_domain(
return StatusCode::FORBIDDEN.into_response();
}
let reason = form.reason.as_deref().filter(|s| !s.trim().is_empty());
match state.ap_service.add_blocked_domain(&form.domain, reason).await {
match state
.ap_service
.add_blocked_domain(&form.domain, reason)
.await
{
Ok(()) => Redirect::to("/admin/blocked-domains").into_response(),
Err(e) => {
tracing::error!("add_blocked_domain error: {:?}", e);
@@ -1393,7 +1443,11 @@ pub async fn get_blocked_actors_page(
}
Err(e) => {
tracing::error!("get_blocked_actors error: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Failed to load blocked users").into_response()
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to load blocked users",
)
.into_response()
}
}
}
@@ -1408,7 +1462,11 @@ pub async fn post_block_actor_html(
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
match state.ap_service.block_actor(user_id.value(), &form.actor_url).await {
match state
.ap_service
.block_actor(user_id.value(), &form.actor_url)
.await
{
Ok(()) => Redirect::to("/social/blocked").into_response(),
Err(e) => {
tracing::error!("block_actor html error: {:?}", e);
@@ -1427,7 +1485,11 @@ pub async fn post_unblock_actor(
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
match state.ap_service.unblock_actor(user_id.value(), &form.actor_url).await {
match state
.ap_service
.unblock_actor(user_id.value(), &form.actor_url)
.await
{
Ok(()) => Redirect::to("/social/blocked").into_response(),
Err(e) => {
tracing::error!("unblock_actor error: {:?}", e);
@@ -1447,13 +1509,19 @@ pub async fn post_profile_settings(
let mut banner_bytes: Option<Vec<u8>> = None;
let mut banner_content_type: Option<String> = None;
let mut also_known_as: Option<String> = None;
let mut field_names: std::collections::HashMap<usize, String> = std::collections::HashMap::new();
let mut field_values: std::collections::HashMap<usize, String> = std::collections::HashMap::new();
let mut field_names: std::collections::HashMap<usize, String> =
std::collections::HashMap::new();
let mut field_values: std::collections::HashMap<usize, String> =
std::collections::HashMap::new();
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
match name.as_str() {
"bio" => { if let Ok(text) = field.text().await { bio = Some(text); } }
"bio" => {
if let Ok(text) = field.text().await {
bio = Some(text);
}
}
"also_known_as" => {
if let Ok(text) = field.text().await {
also_known_as = Some(text).filter(|s| !s.is_empty());
@@ -1462,26 +1530,36 @@ pub async fn post_profile_settings(
"avatar" => {
let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() { avatar_bytes = Some(bytes.to_vec()); avatar_content_type = ct; }
if !bytes.is_empty() {
avatar_bytes = Some(bytes.to_vec());
avatar_content_type = ct;
}
}
}
"banner" => {
let ct = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() { banner_bytes = Some(bytes.to_vec()); banner_content_type = ct; }
if !bytes.is_empty() {
banner_bytes = Some(bytes.to_vec());
banner_content_type = ct;
}
}
}
n if n.starts_with("field_name_") => {
if let Ok(idx) = n["field_name_".len()..].parse::<usize>() {
if let Ok(text) = field.text().await {
if !text.is_empty() { field_names.insert(idx, text); }
if !text.is_empty() {
field_names.insert(idx, text);
}
}
}
}
n if n.starts_with("field_value_") => {
if let Ok(idx) = n["field_value_".len()..].parse::<usize>() {
if let Ok(text) = field.text().await {
if !text.is_empty() { field_values.insert(idx, text); }
if !text.is_empty() {
field_values.insert(idx, text);
}
}
}
}
@@ -1502,10 +1580,12 @@ pub async fn post_profile_settings(
let fields: Vec<domain::models::ProfileField> = (0..4)
.filter_map(|i| {
field_names.get(&i).map(|name| domain::models::ProfileField {
name: name.clone(),
value: field_values.get(&i).cloned().unwrap_or_default(),
})
field_names
.get(&i)
.map(|name| domain::models::ProfileField {
name: name.clone(),
value: field_values.get(&i).cloned().unwrap_or_default(),
})
})
.collect();

View File

@@ -1,13 +1,13 @@
use api_types::{
ApplyMappingRequest, ConfirmRequest, SaveProfileRequest, SessionCreatedResponse,
SessionStateResponse,
};
use axum::{
Extension, Form,
extract::{Multipart, Path, State},
http::StatusCode,
response::{Html, IntoResponse, Redirect},
};
use api_types::{
ApplyMappingRequest, ConfirmRequest, SaveProfileRequest, SessionCreatedResponse,
SessionStateResponse,
};
use serde::Deserialize;
use std::collections::HashMap;
@@ -25,7 +25,10 @@ use application::{
list_import_profiles, save_import_profile,
},
};
use domain::models::{AnnotatedRow, FieldMapping, FileFormat, import::{DomainField, RowResult, Transform}};
use domain::models::{
AnnotatedRow, FieldMapping, FileFormat,
import::{DomainField, RowResult, Transform},
};
use domain::value_objects::ImportSessionId;
use crate::{
@@ -196,11 +199,10 @@ pub async fn post_upload(
)
.await
{
Ok(r) => {
Redirect::to(&format!("/import/{}/mapping", r.session_id.value())).into_response()
Ok(r) => Redirect::to(&format!("/import/{}/mapping", r.session_id.value())).into_response(),
Err(e) => {
Redirect::to(&format!("/import?error={}", encode_error(&e.to_string()))).into_response()
}
Err(e) => Redirect::to(&format!("/import?error={}", encode_error(&e.to_string())))
.into_response(),
}
}
@@ -408,8 +410,9 @@ pub async fn post_confirm(
summary.failed.len()
))
.into_response(),
Err(e) => Redirect::to(&format!("/import?error={}", encode_error(&e.to_string())))
.into_response(),
Err(e) => {
Redirect::to(&format!("/import?error={}", encode_error(&e.to_string()))).into_response()
}
}
}

View File

@@ -1,8 +1,8 @@
pub mod api;
pub mod html;
pub mod images;
pub mod rss;
pub mod api;
pub mod import;
pub mod rss;
const DEFAULT_PAGE_LIMIT: u32 = 5;
const RSS_FEED_LIMIT: u32 = 50;

View File

@@ -1,7 +1,7 @@
pub mod csrf;
pub mod forms;
pub mod errors;
pub mod extractors;
pub mod forms;
pub mod handlers;
pub mod openapi;
pub mod ports;

View File

@@ -13,13 +13,17 @@ use template_askama::AskamaHtmlRenderer;
use presentation::{openapi, routes, state::AppState};
use domain::ports::{DiaryExporter, DocumentParser, EventPublisher, ImportProfileRepository, ImportSessionRepository};
use domain::ports::{
DiaryExporter, DocumentParser, EventPublisher, ImportProfileRepository, ImportSessionRepository,
};
#[cfg(feature = "postgres")]
use postgres_search;
#[cfg(not(any(feature = "sqlite", feature = "postgres")))]
compile_error!("At least one database backend must be enabled. Use --features sqlite or --features postgres");
compile_error!(
"At least one database backend must be enabled. Use --features sqlite or --features postgres"
);
#[tokio::main]
async fn main() -> anyhow::Result<()> {
@@ -37,7 +41,11 @@ async fn main() -> anyhow::Result<()> {
let addr = format!("{}:{}", host, port);
let listener = TcpListener::bind(&addr).await?;
tracing::info!("Listening on {}", addr);
axum::serve(listener, app.into_make_service_with_connect_info::<std::net::SocketAddr>()).await?;
axum::serve(
listener,
app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
)
.await?;
Ok(())
}
@@ -52,25 +60,71 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
let poster_fetcher = poster_fetcher::create()?;
let image_storage = image_storage::create()?;
let (movie_repository, review_repository, diary_repository, stats_repository, user_repository, import_session_repository, import_profile_repository, movie_profile_repository, watchlist_repository, person_command, person_query, search_command, search_port, db_pool) =
match backend.as_str() {
#[cfg(feature = "postgres")]
"postgres" => {
let (pool, m, r, d, s, u, is, ip, mp, wl) = postgres::wire(&database_url).await?;
let (pc, pq) = postgres::create_person_adapter(pool.clone());
let (sc, sp) = postgres_search::create_search_adapter(pool.clone());
(m, r, d, s, u, is, ip, mp, wl, pc, pq, sc, sp, DbPool::Postgres(pool))
}
#[cfg(feature = "sqlite")]
_ => {
let (pool, m, r, d, s, u, is, ip, mp, wl) = sqlite::wire(&database_url).await?;
let (pc, pq) = sqlite::create_person_adapter(pool.clone());
let (sc, sp) = sqlite_search::create_search_adapter(pool.clone());
(m, r, d, s, u, is, ip, mp, wl, pc, pq, sc, sp, DbPool::Sqlite(pool))
}
#[cfg(not(feature = "sqlite"))]
_ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build (sqlite feature is not enabled)"),
};
let (
movie_repository,
review_repository,
diary_repository,
stats_repository,
user_repository,
import_session_repository,
import_profile_repository,
movie_profile_repository,
watchlist_repository,
person_command,
person_query,
search_command,
search_port,
db_pool,
) = match backend.as_str() {
#[cfg(feature = "postgres")]
"postgres" => {
let (pool, m, r, d, s, u, is, ip, mp, wl) = postgres::wire(&database_url).await?;
let (pc, pq) = postgres::create_person_adapter(pool.clone());
let (sc, sp) = postgres_search::create_search_adapter(pool.clone());
(
m,
r,
d,
s,
u,
is,
ip,
mp,
wl,
pc,
pq,
sc,
sp,
DbPool::Postgres(pool),
)
}
#[cfg(feature = "sqlite")]
_ => {
let (pool, m, r, d, s, u, is, ip, mp, wl) = sqlite::wire(&database_url).await?;
let (pc, pq) = sqlite::create_person_adapter(pool.clone());
let (sc, sp) = sqlite_search::create_search_adapter(pool.clone());
(
m,
r,
d,
s,
u,
is,
ip,
mp,
wl,
pc,
pq,
sc,
sp,
DbPool::Sqlite(pool),
)
}
#[cfg(not(feature = "sqlite"))]
_ => anyhow::bail!(
"DATABASE_BACKEND={backend} is not supported by this build (sqlite feature is not enabled)"
),
};
let profile_fields_repo = match &db_pool {
#[cfg(feature = "postgres")]
@@ -86,27 +140,31 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
#[cfg(feature = "federation")]
let (event_publisher_arc, ap_router, ap_service, social_query, remote_watchlist_repo) = {
let (federation_repo, social_query_arc, review_store, remote_watchlist_repo) = match &db_pool {
#[cfg(feature = "postgres-federation")]
DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()),
#[cfg(feature = "sqlite-federation")]
DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()),
#[cfg(not(feature = "sqlite-federation"))]
_ => anyhow::bail!("DATABASE_BACKEND={backend} federation is not supported by this build"),
};
let (federation_repo, social_query_arc, review_store, remote_watchlist_repo) =
match &db_pool {
#[cfg(feature = "postgres-federation")]
DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()),
#[cfg(feature = "sqlite-federation")]
DbPool::Sqlite(pool) => sqlite_federation::wire(pool.clone()),
#[cfg(not(feature = "sqlite-federation"))]
_ => anyhow::bail!(
"DATABASE_BACKEND={backend} federation is not supported by this build"
),
};
let ep: Arc<dyn EventPublisher> = match event_bus {
EventBusBackend::Db => {
tracing::info!("event bus: DB queue");
match &db_pool {
#[cfg(feature = "postgres")]
DbPool::Postgres(pool) => postgres_event_queue::PostgresEventQueue::create_publisher(
pool.clone()
).await?,
DbPool::Postgres(pool) => {
postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone())
.await?
}
#[cfg(feature = "sqlite")]
DbPool::Sqlite(pool) => sqlite_event_queue::SqliteEventQueue::create_publisher(
pool.clone()
).await?,
DbPool::Sqlite(pool) => {
sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await?
}
}
}
#[cfg(feature = "nats")]
@@ -129,11 +187,18 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
app_config.base_url.clone(),
app_config.allow_registration,
Arc::clone(&ep),
).await?;
)
.await?;
let ap_router = ap.router;
let ap_service_arc = ap.service;
(ep, ap_router, ap_service_arc, social_query_arc, remote_watchlist_repo)
(
ep,
ap_router,
ap_service_arc,
social_query_arc,
remote_watchlist_repo,
)
};
#[cfg(not(feature = "federation"))]
@@ -142,15 +207,17 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
tracing::info!("event bus: DB queue");
match &db_pool {
#[cfg(feature = "postgres")]
DbPool::Postgres(pool) => postgres_event_queue::PostgresEventQueue::create_publisher(
pool.clone()
).await?,
DbPool::Postgres(pool) => {
postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone()).await?
}
#[cfg(feature = "sqlite")]
DbPool::Sqlite(pool) => sqlite_event_queue::SqliteEventQueue::create_publisher(
pool.clone()
).await?,
DbPool::Sqlite(pool) => {
sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await?
}
#[cfg(not(feature = "sqlite"))]
_ => anyhow::bail!("EVENT_BUS_BACKEND=db has no adapter for DATABASE_BACKEND={backend}; enable the sqlite or postgres feature"),
_ => anyhow::bail!(
"EVENT_BUS_BACKEND=db has no adapter for DATABASE_BACKEND={backend}; enable the sqlite or postgres feature"
),
}
}
#[cfg(feature = "nats")]
@@ -213,7 +280,6 @@ enum DbPool {
Postgres(sqlx::PgPool),
}
#[derive(Clone, Copy)]
enum EventBusBackend {
Db,
@@ -231,7 +297,9 @@ impl EventBusBackend {
#[cfg(feature = "nats")]
"nats" => Ok(Self::Nats),
#[cfg(not(feature = "nats"))]
"nats" => anyhow::bail!("EVENT_BUS_BACKEND=nats requires the nats feature to be compiled in"),
"nats" => {
anyhow::bail!("EVENT_BUS_BACKEND=nats requires the nats feature to be compiled in")
}
other => anyhow::bail!("unknown EVENT_BUS_BACKEND={other}, expected 'db' or 'nats'"),
}
}

View File

@@ -3,10 +3,7 @@ use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::api::login,
crate::handlers::api::register,
),
components(schemas(LoginRequest, LoginResponse, RegisterRequest)),
paths(crate::handlers::api::login, crate::handlers::api::register,),
components(schemas(LoginRequest, LoginResponse, RegisterRequest))
)]
pub struct AuthDoc;

View File

@@ -1,4 +1,6 @@
use api_types::{ActivityFeedResponse, DiaryEntryDto, DiaryResponse, FeedEntryDto, LogReviewRequest, ReviewDto};
use api_types::{
ActivityFeedResponse, DiaryEntryDto, DiaryResponse, FeedEntryDto, LogReviewRequest, ReviewDto,
};
use utoipa::OpenApi;
#[derive(OpenApi)]
@@ -17,6 +19,6 @@ use utoipa::OpenApi;
LogReviewRequest,
ActivityFeedResponse,
FeedEntryDto,
)),
))
)]
pub struct DiaryDoc;

View File

@@ -22,6 +22,6 @@ use utoipa::OpenApi;
ApplyMappingRequest,
ConfirmRequest,
SaveProfileRequest,
)),
))
)]
pub struct ImportDoc;

View File

@@ -33,6 +33,6 @@ use utoipa::OpenApi;
BlockedDomainResponse,
AddBlockedDomainRequest,
BlockedActorResponse,
)),
))
)]
pub struct SocialDoc;

View File

@@ -1,4 +1,6 @@
use api_types::{ProfileResponse, UserProfileResponse, UserStatsDto, UserSummaryDto, UsersResponse};
use api_types::{
ProfileResponse, UserProfileResponse, UserStatsDto, UserSummaryDto, UsersResponse,
};
use utoipa::OpenApi;
#[derive(OpenApi)]
@@ -15,6 +17,6 @@ use utoipa::OpenApi;
UserProfileResponse,
UserStatsDto,
ProfileResponse,
)),
))
)]
pub struct UsersDoc;

View File

@@ -1,4 +1,6 @@
use api_types::{AddToWatchlistRequest, WatchlistEntryDto, WatchlistResponse, WatchlistStatusResponse};
use api_types::{
AddToWatchlistRequest, WatchlistEntryDto, WatchlistResponse, WatchlistStatusResponse,
};
use utoipa::OpenApi;
#[derive(OpenApi)]

View File

@@ -60,7 +60,10 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
let base = Router::new()
.route("/", routing::get(handlers::html::get_activity_feed))
.route("/users", routing::get(handlers::html::get_users_list))
.route("/u/{username}", routing::get(handlers::html::get_user_by_username))
.route(
"/u/{username}",
routing::get(handlers::html::get_user_by_username),
)
.route(
"/users/{id}",
routing::get(handlers::html::get_user_profile),
@@ -79,24 +82,41 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
"/reviews/{id}/delete",
routing::post(handlers::html::post_delete_review),
)
.route(
"/images/{*key}",
routing::get(handlers::images::get_image),
)
.route("/images/{*key}", routing::get(handlers::images::get_image))
.route(
"/posters/{path}",
routing::get(|axum::extract::Path(p): axum::extract::Path<String>| async move {
axum::response::Redirect::permanent(&format!("/images/{}", p))
}),
routing::get(
|axum::extract::Path(p): axum::extract::Path<String>| async move {
axum::response::Redirect::permanent(&format!("/images/{}", p))
},
),
)
.route("/diary/export", routing::get(handlers::html::get_export))
.route("/import", routing::get(handlers::import::get_import_page))
.route("/import/upload", routing::post(handlers::import::post_upload))
.route("/import/{id}/mapping", routing::get(handlers::import::get_mapping_page).post(handlers::import::post_mapping))
.route("/import/{id}/preview", routing::get(handlers::import::get_preview_page))
.route("/import/{id}/confirm", routing::post(handlers::import::post_confirm))
.route("/import/done", routing::get(handlers::import::get_import_done))
.route("/import/profiles/{profile_id}/delete", routing::post(handlers::import::post_delete_profile))
.route(
"/import/upload",
routing::post(handlers::import::post_upload),
)
.route(
"/import/{id}/mapping",
routing::get(handlers::import::get_mapping_page).post(handlers::import::post_mapping),
)
.route(
"/import/{id}/preview",
routing::get(handlers::import::get_preview_page),
)
.route(
"/import/{id}/confirm",
routing::post(handlers::import::post_confirm),
)
.route(
"/import/done",
routing::get(handlers::import::get_import_done),
)
.route(
"/import/profiles/{profile_id}/delete",
routing::post(handlers::import::post_delete_profile),
)
.route("/feed.rss", routing::get(handlers::rss::get_feed))
.route(
"/users/{id}/feed.rss",
@@ -171,8 +191,14 @@ fn federation_html_routes() -> Router<AppState> {
"/social/blocked",
routing::get(handlers::html::get_blocked_actors_page),
)
.route("/social/block", routing::post(handlers::html::post_block_actor_html))
.route("/social/unblock", routing::post(handlers::html::post_unblock_actor))
.route(
"/social/block",
routing::post(handlers::html::post_block_actor_html),
)
.route(
"/social/unblock",
routing::post(handlers::html::post_unblock_actor),
)
}
fn api_routes(rate_limit: u64) -> Router<AppState> {
@@ -216,17 +242,48 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
)
.route("/users", routing::get(handlers::api::list_users))
.route("/users/{id}", routing::get(handlers::api::get_user_profile))
.route("/import/sessions", routing::post(handlers::import::api_post_session))
.route("/import/sessions/{id}", routing::get(handlers::import::api_get_session))
.route("/import/sessions/{id}/mapping", routing::put(handlers::import::api_put_mapping))
.route("/import/sessions/{id}/confirm", routing::post(handlers::import::api_post_confirm))
.route("/import/profiles", routing::get(handlers::import::api_get_profiles).post(handlers::import::api_post_profile))
.route("/import/profiles/{id}", routing::delete(handlers::import::api_delete_profile))
.route("/profile", routing::get(handlers::api::get_profile).put(handlers::api::update_profile_handler))
.route("/profile/fields", routing::put(handlers::api::update_profile_fields_handler))
.route(
"/import/sessions",
routing::post(handlers::import::api_post_session),
)
.route(
"/import/sessions/{id}",
routing::get(handlers::import::api_get_session),
)
.route(
"/import/sessions/{id}/mapping",
routing::put(handlers::import::api_put_mapping),
)
.route(
"/import/sessions/{id}/confirm",
routing::post(handlers::import::api_post_confirm),
)
.route(
"/import/profiles",
routing::get(handlers::import::api_get_profiles)
.post(handlers::import::api_post_profile),
)
.route(
"/import/profiles/{id}",
routing::delete(handlers::import::api_delete_profile),
)
.route(
"/profile",
routing::get(handlers::api::get_profile).put(handlers::api::update_profile_handler),
)
.route(
"/profile/fields",
routing::put(handlers::api::update_profile_fields_handler),
)
.route("/search", routing::get(handlers::api::get_search))
.route("/people/{id}", routing::get(handlers::api::get_person_handler))
.route("/people/{id}/credits", routing::get(handlers::api::get_person_credits_handler))
.route(
"/people/{id}",
routing::get(handlers::api::get_person_handler),
)
.route(
"/people/{id}/credits",
routing::get(handlers::api::get_person_credits_handler),
)
.route(
"/watchlist",
routing::get(handlers::api::get_watchlist_handler)
@@ -282,7 +339,16 @@ fn federation_api_routes() -> Router<AppState> {
"/admin/blocked-domains/{domain}",
routing::delete(handlers::api::remove_blocked_domain_admin),
)
.route("/social/block", routing::post(handlers::api::block_actor_api))
.route("/social/unblock", routing::post(handlers::api::unblock_actor_api))
.route("/social/blocked", routing::get(handlers::api::get_blocked_actors_api))
.route(
"/social/block",
routing::post(handlers::api::block_actor_api),
)
.route(
"/social/unblock",
routing::post(handlers::api::unblock_actor_api),
)
.route(
"/social/blocked",
routing::get(handlers::api::get_blocked_actors_api),
)
}

View File

@@ -1,4 +1,4 @@
use super::extractors::{make_test_state, Panic};
use super::extractors::{Panic, make_test_state};
use axum::{
Router,
body::Body,
@@ -14,7 +14,10 @@ use uuid::Uuid;
struct SearchPortStub;
#[async_trait::async_trait]
impl domain::ports::SearchPort for SearchPortStub {
async fn search(&self, _: &domain::models::SearchQuery) -> Result<domain::models::SearchResults, DomainError> {
async fn search(
&self,
_: &domain::models::SearchQuery,
) -> Result<domain::models::SearchResults, DomainError> {
Ok(domain::models::SearchResults {
movies: domain::models::collections::Paginated {
items: vec![],
@@ -36,13 +39,22 @@ impl domain::ports::SearchPort for SearchPortStub {
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_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> {
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> {
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> {
@@ -104,7 +116,10 @@ async fn person_endpoint_returns_404_for_unknown_id() {
// Override the person_query with our stub
state.app_ctx.person_query = Arc::new(PersonQueryStub);
let app = Router::new()
.route("/api/v1/people/{id}", get(crate::handlers::api::get_person_handler))
.route(
"/api/v1/people/{id}",
get(crate::handlers::api::get_person_handler),
)
.with_state(state);
let unknown_id = Uuid::new_v4();
@@ -127,7 +142,10 @@ async fn person_credits_endpoint_returns_404_for_unknown_id() {
// Override the person_query with our stub
state.app_ctx.person_query = Arc::new(PersonQueryStub);
let app = Router::new()
.route("/api/v1/people/{id}/credits", get(crate::handlers::api::get_person_credits_handler))
.route(
"/api/v1/people/{id}/credits",
get(crate::handlers::api::get_person_credits_handler),
)
.with_state(state);
let unknown_id = Uuid::new_v4();
@@ -150,7 +168,10 @@ async fn person_credits_endpoint_returns_404_for_unknown_id() {
async fn get_watchlist_requires_auth() {
let state = make_test_state(Arc::new(Panic));
let app = Router::new()
.route("/api/v1/watchlist", get(crate::handlers::api::get_watchlist_handler))
.route(
"/api/v1/watchlist",
get(crate::handlers::api::get_watchlist_handler),
)
.with_state(state);
let resp = app

View File

@@ -10,21 +10,20 @@ use domain::{
errors::DomainError,
events::DomainEvent,
models::{
DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, UserStats,
DiaryEntry, DiaryFilter, EntityType, FeedEntry, IndexableDocument, Movie, Person,
PersonCredits, PersonId, Review, ReviewHistory, SearchQuery, SearchResults, UserStats,
UserTrends,
collections::{PageParams, Paginated},
PersonId, EntityType, IndexableDocument, Person, PersonCredits,
SearchQuery, SearchResults,
},
ports::{
AuthService, DiaryRepository, EventPublisher, GeneratedToken, ImageStorage,
MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, ReviewRepository,
StatsRepository, UserRepository, WatchlistRepository,
PersonCommand, PersonQuery, SearchPort, SearchCommand,
AuthService, DiaryRepository, EventPublisher, GeneratedToken, ImageStorage, MetadataClient,
MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserRepository,
WatchlistRepository,
},
value_objects::{
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl,
ReleaseYear, ReviewId, UserId,
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl, ReleaseYear,
ReviewId, UserId,
},
};
use std::sync::Arc;
@@ -58,7 +57,12 @@ impl MovieRepository for Panic {
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!()
}
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> {
async fn list_movies(
&self,
_: &domain::models::collections::PageParams,
_: &domain::models::MovieFilter,
) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError>
{
panic!()
}
}
@@ -123,10 +127,7 @@ impl DiaryRepository for Panic {
#[cfg(feature = "federation")]
#[async_trait::async_trait]
impl domain::ports::SocialQueryPort for Panic {
async fn get_accepted_following_urls(
&self,
_: uuid::Uuid,
) -> Result<Vec<String>, DomainError> {
async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result<Vec<String>, DomainError> {
panic!()
}
async fn list_all_followed_remote_actors(
@@ -167,9 +168,15 @@ impl PosterFetcherClient for Panic {
}
#[async_trait::async_trait]
impl ImageStorage for Panic {
async fn store(&self, _: &str, _: &[u8]) -> Result<String, DomainError> { panic!() }
async fn get(&self, _: &str) -> Result<Vec<u8>, DomainError> { panic!() }
async fn delete(&self, _: &str) -> Result<(), DomainError> { panic!() }
async fn store(&self, _: &str, _: &[u8]) -> Result<String, DomainError> {
panic!()
}
async fn get(&self, _: &str) -> Result<Vec<u8>, DomainError> {
panic!()
}
async fn delete(&self, _: &str) -> Result<(), DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl AuthService for Panic {
@@ -191,19 +198,13 @@ impl PasswordHasher for Panic {
}
#[async_trait::async_trait]
impl UserRepository for Panic {
async fn find_by_email(
&self,
_: &Email,
) -> Result<Option<domain::models::User>, DomainError> {
async fn find_by_email(&self, _: &Email) -> Result<Option<domain::models::User>, DomainError> {
panic!()
}
async fn save(&self, _: &domain::models::User) -> Result<(), DomainError> {
panic!()
}
async fn find_by_id(
&self,
_: &UserId,
) -> Result<Option<domain::models::User>, DomainError> {
async fn find_by_id(&self, _: &UserId) -> Result<Option<domain::models::User>, DomainError> {
panic!()
}
async fn find_by_username(
@@ -215,14 +216,32 @@ impl UserRepository for Panic {
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
panic!()
}
async fn update_profile(&self, _: &UserId, _: Option<String>, _: Option<String>, _: Option<String>, _: Option<String>) -> Result<(), DomainError> {
async fn update_profile(
&self,
_: &UserId,
_: Option<String>,
_: Option<String>,
_: Option<String>,
_: Option<String>,
) -> Result<(), DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl domain::ports::UserProfileFieldsRepository for Panic {
async fn get_fields(&self, _: &UserId) -> Result<Vec<domain::models::ProfileField>, DomainError> { panic!() }
async fn set_fields(&self, _: &UserId, _: Vec<domain::models::ProfileField>) -> Result<(), DomainError> { panic!() }
async fn get_fields(
&self,
_: &UserId,
) -> Result<Vec<domain::models::ProfileField>, DomainError> {
panic!()
}
async fn set_fields(
&self,
_: &UserId,
_: Vec<domain::models::ProfileField>,
) -> Result<(), DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl EventPublisher for Panic {
@@ -232,33 +251,104 @@ impl EventPublisher for Panic {
}
#[async_trait::async_trait]
impl domain::ports::ImportSessionRepository for Panic {
async fn create(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { panic!() }
async fn get(&self, _: &domain::value_objects::ImportSessionId, _: &UserId) -> Result<Option<domain::models::ImportSession>, DomainError> { panic!() }
async fn update(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { panic!() }
async fn delete(&self, _: &domain::value_objects::ImportSessionId) -> Result<(), DomainError> { panic!() }
async fn delete_expired(&self) -> Result<u64, DomainError> { panic!() }
async fn delete_expired_for_user(&self, _: &UserId) -> Result<(), DomainError> { panic!() }
async fn create(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> {
panic!()
}
async fn get(
&self,
_: &domain::value_objects::ImportSessionId,
_: &UserId,
) -> Result<Option<domain::models::ImportSession>, DomainError> {
panic!()
}
async fn update(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> {
panic!()
}
async fn delete(&self, _: &domain::value_objects::ImportSessionId) -> Result<(), DomainError> {
panic!()
}
async fn delete_expired(&self) -> Result<u64, DomainError> {
panic!()
}
async fn delete_expired_for_user(&self, _: &UserId) -> Result<(), DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl domain::ports::ImportProfileRepository for Panic {
async fn save(&self, _: &domain::models::ImportProfile) -> Result<(), DomainError> { panic!() }
async fn list_for_user(&self, _: &UserId) -> Result<Vec<domain::models::ImportProfile>, DomainError> { panic!() }
async fn get(&self, _: &domain::value_objects::ImportProfileId, _: &UserId) -> Result<Option<domain::models::ImportProfile>, DomainError> { panic!() }
async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { panic!() }
async fn save(&self, _: &domain::models::ImportProfile) -> Result<(), DomainError> {
panic!()
}
async fn list_for_user(
&self,
_: &UserId,
) -> Result<Vec<domain::models::ImportProfile>, DomainError> {
panic!()
}
async fn get(
&self,
_: &domain::value_objects::ImportProfileId,
_: &UserId,
) -> Result<Option<domain::models::ImportProfile>, DomainError> {
panic!()
}
async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl WatchlistRepository for Panic {
async fn add(&self, _: &domain::models::WatchlistEntry) -> Result<(), DomainError> { panic!() }
async fn remove(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result<(), DomainError> { panic!() }
async fn remove_if_present(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result<bool, DomainError> { Ok(false) }
async fn get_for_user(&self, _: &domain::value_objects::UserId, _: &domain::models::collections::PageParams) -> Result<domain::models::collections::Paginated<domain::models::WatchlistWithMovie>, DomainError> { panic!() }
async fn contains(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result<bool, DomainError> { Ok(false) }
async fn add(&self, _: &domain::models::WatchlistEntry) -> Result<(), DomainError> {
panic!()
}
async fn remove(
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::MovieId,
) -> Result<(), DomainError> {
panic!()
}
async fn remove_if_present(
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::MovieId,
) -> Result<bool, DomainError> {
Ok(false)
}
async fn get_for_user(
&self,
_: &domain::value_objects::UserId,
_: &domain::models::collections::PageParams,
) -> Result<
domain::models::collections::Paginated<domain::models::WatchlistWithMovie>,
DomainError,
> {
panic!()
}
async fn contains(
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::MovieId,
) -> Result<bool, DomainError> {
Ok(false)
}
}
#[async_trait::async_trait]
impl domain::ports::MovieProfileRepository for Panic {
async fn upsert(&self, _: &domain::models::MovieProfile) -> Result<(), DomainError> { panic!() }
async fn get_by_movie_id(&self, _: &domain::value_objects::MovieId) -> Result<Option<domain::models::MovieProfile>, DomainError> { Ok(None) }
async fn list_stale(&self) -> Result<Vec<(domain::value_objects::MovieId, String)>, DomainError> { Ok(vec![]) }
async fn upsert(&self, _: &domain::models::MovieProfile) -> Result<(), DomainError> {
panic!()
}
async fn get_by_movie_id(
&self,
_: &domain::value_objects::MovieId,
) -> Result<Option<domain::models::MovieProfile>, DomainError> {
Ok(None)
}
async fn list_stale(
&self,
) -> Result<Vec<(domain::value_objects::MovieId, String)>, DomainError> {
Ok(vec![])
}
}
#[async_trait::async_trait]
impl domain::ports::DiaryExporter for Panic {
@@ -272,10 +362,18 @@ impl domain::ports::DiaryExporter for Panic {
}
impl domain::ports::DocumentParser for Panic {
fn parse(&self, _: &[u8], _: domain::models::FileFormat) -> Result<domain::models::ParsedFile, domain::models::ImportError> {
fn parse(
&self,
_: &[u8],
_: domain::models::FileFormat,
) -> Result<domain::models::ParsedFile, domain::models::ImportError> {
panic!()
}
fn apply_mapping(&self, _: &domain::models::ParsedFile, _: &[domain::models::FieldMapping]) -> Vec<domain::models::AnnotatedRow> {
fn apply_mapping(
&self,
_: &domain::models::ParsedFile,
_: &[domain::models::FieldMapping],
) -> Vec<domain::models::AnnotatedRow> {
panic!()
}
}
@@ -312,10 +410,7 @@ impl crate::ports::HtmlRenderer for Panic {
) -> Result<String, String> {
panic!()
}
fn render_users_page(
&self,
_: application::ports::UsersPageData,
) -> Result<String, String> {
fn render_users_page(&self, _: application::ports::UsersPageData) -> Result<String, String> {
panic!()
}
fn render_profile_page(
@@ -342,13 +437,48 @@ impl crate::ports::HtmlRenderer for Panic {
) -> Result<String, String> {
panic!()
}
fn render_import_upload_page(&self, _: application::ports::ImportUploadPageData) -> Result<String, String> { panic!() }
fn render_import_mapping_page(&self, _: application::ports::ImportMappingPageData) -> Result<String, String> { panic!() }
fn render_import_preview_page(&self, _: application::ports::ImportPreviewPageData) -> Result<String, String> { panic!() }
fn render_profile_settings_page(&self, _: application::ports::ProfileSettingsPageData) -> Result<String, String> { panic!() }
fn render_blocked_domains_page(&self, _: application::ports::BlockedDomainsPageData) -> Result<String, String> { panic!() }
fn render_blocked_actors_page(&self, _: application::ports::BlockedActorsPageData) -> Result<String, String> { panic!() }
fn render_watchlist_page(&self, _: application::ports::WatchlistPageData) -> Result<String, String> { panic!() }
fn render_import_upload_page(
&self,
_: application::ports::ImportUploadPageData,
) -> Result<String, String> {
panic!()
}
fn render_import_mapping_page(
&self,
_: application::ports::ImportMappingPageData,
) -> Result<String, String> {
panic!()
}
fn render_import_preview_page(
&self,
_: application::ports::ImportPreviewPageData,
) -> Result<String, String> {
panic!()
}
fn render_profile_settings_page(
&self,
_: application::ports::ProfileSettingsPageData,
) -> Result<String, String> {
panic!()
}
fn render_blocked_domains_page(
&self,
_: application::ports::BlockedDomainsPageData,
) -> Result<String, String> {
panic!()
}
fn render_blocked_actors_page(
&self,
_: application::ports::BlockedActorsPageData,
) -> Result<String, String> {
panic!()
}
fn render_watchlist_page(
&self,
_: application::ports::WatchlistPageData,
) -> Result<String, String> {
panic!()
}
}
impl crate::ports::RssFeedRenderer for Panic {
fn render_feed(&self, _: &[DiaryEntry], _: &str) -> Result<String, String> {
@@ -369,32 +499,67 @@ impl AuthService for RejectingAuth {
#[async_trait::async_trait]
impl PersonCommand for Panic {
async fn upsert_batch(&self, _: &[Person]) -> Result<(), DomainError> { panic!() }
async fn upsert_batch(&self, _: &[Person]) -> Result<(), DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl PersonQuery for Panic {
async fn get_by_id(&self, _: &PersonId) -> Result<Option<Person>, DomainError> { panic!() }
async fn get_by_external_id(&self, _: &domain::models::ExternalPersonId) -> Result<Option<Person>, DomainError> { panic!() }
async fn get_credits(&self, _: &PersonId) -> Result<PersonCredits, DomainError> { panic!() }
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError> { panic!() }
async fn get_by_id(&self, _: &PersonId) -> Result<Option<Person>, DomainError> {
panic!()
}
async fn get_by_external_id(
&self,
_: &domain::models::ExternalPersonId,
) -> Result<Option<Person>, DomainError> {
panic!()
}
async fn get_credits(&self, _: &PersonId) -> Result<PersonCredits, DomainError> {
panic!()
}
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl SearchPort for Panic {
async fn search(&self, _: &SearchQuery) -> Result<SearchResults, DomainError> { panic!() }
async fn search(&self, _: &SearchQuery) -> Result<SearchResults, DomainError> {
panic!()
}
}
#[async_trait::async_trait]
impl SearchCommand for Panic {
async fn index(&self, _: IndexableDocument) -> Result<(), DomainError> { panic!() }
async fn remove(&self, _: EntityType, _: &str) -> Result<(), DomainError> { panic!() }
async fn index(&self, _: IndexableDocument) -> Result<(), DomainError> {
panic!()
}
async fn remove(&self, _: EntityType, _: &str) -> Result<(), DomainError> {
panic!()
}
}
#[cfg(feature = "federation")]
#[async_trait::async_trait]
impl domain::ports::RemoteWatchlistRepository for Panic {
async fn save(&self, _: domain::models::RemoteWatchlistEntry) -> Result<(), DomainError> { Ok(()) }
async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { Ok(()) }
async fn get_by_actor_url(&self, _: &str) -> Result<Vec<domain::models::RemoteWatchlistEntry>, DomainError> { Ok(vec![]) }
async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> { Ok(()) }
async fn get_by_derived_uuid(&self, _: uuid::Uuid) -> Result<Vec<domain::models::RemoteWatchlistEntry>, DomainError> { Ok(vec![]) }
async fn save(&self, _: domain::models::RemoteWatchlistEntry) -> Result<(), DomainError> {
Ok(())
}
async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> {
Ok(())
}
async fn get_by_actor_url(
&self,
_: &str,
) -> Result<Vec<domain::models::RemoteWatchlistEntry>, DomainError> {
Ok(vec![])
}
async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> {
Ok(())
}
async fn get_by_derived_uuid(
&self,
_: uuid::Uuid,
) -> Result<Vec<domain::models::RemoteWatchlistEntry>, DomainError> {
Ok(vec![])
}
}
// --- Single state factory — only auth_service varies ---

View File

@@ -10,15 +10,16 @@ use axum::{
use domain::{
errors::DomainError,
events::DomainEvent,
models::{Movie, User, PersonId, Person, PersonCredits, EntityType, IndexableDocument, SearchQuery, SearchResults, ExternalPersonId},
models::{
EntityType, ExternalPersonId, IndexableDocument, Movie, Person, PersonCredits, PersonId,
SearchQuery, SearchResults, User,
},
ports::{
AuthService, EventPublisher, GeneratedToken, ImageStorage, MetadataClient, MetadataSearchCriteria,
PasswordHasher, PosterFetcherClient, UserRepository,
PersonCommand, PersonQuery, SearchPort, SearchCommand,
},
value_objects::{
Email, ExternalMetadataId, PasswordHash, PosterUrl, UserId,
AuthService, EventPublisher, GeneratedToken, ImageStorage, MetadataClient,
MetadataSearchCriteria, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
SearchCommand, SearchPort, UserRepository,
},
value_objects::{Email, ExternalMetadataId, PasswordHash, PosterUrl, UserId},
};
use http_body_util::BodyExt;
use presentation::{routes, state::AppState};
@@ -61,9 +62,15 @@ impl PosterFetcherClient for PanicFetcher {
struct PanicImageStorage;
#[async_trait]
impl ImageStorage for PanicImageStorage {
async fn store(&self, _: &str, _: &[u8]) -> Result<String, DomainError> { panic!() }
async fn get(&self, _: &str) -> Result<Vec<u8>, DomainError> { panic!() }
async fn delete(&self, _: &str) -> Result<(), DomainError> { panic!() }
async fn store(&self, _: &str, _: &[u8]) -> Result<String, DomainError> {
panic!()
}
async fn get(&self, _: &str) -> Result<Vec<u8>, DomainError> {
panic!()
}
async fn delete(&self, _: &str) -> Result<(), DomainError> {
panic!()
}
}
struct PanicHasher;
@@ -109,7 +116,14 @@ impl UserRepository for NobodyUserRepo {
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
panic!()
}
async fn update_profile(&self, _: &UserId, _: Option<String>, _: Option<String>, _: Option<String>, _: Option<String>) -> Result<(), DomainError> {
async fn update_profile(
&self,
_: &UserId,
_: Option<String>,
_: Option<String>,
_: Option<String>,
_: Option<String>,
) -> Result<(), DomainError> {
Ok(())
}
}
@@ -117,8 +131,19 @@ impl UserRepository for NobodyUserRepo {
struct PanicProfileFields;
#[async_trait]
impl domain::ports::UserProfileFieldsRepository for PanicProfileFields {
async fn get_fields(&self, _: &UserId) -> Result<Vec<domain::models::ProfileField>, DomainError> { Ok(vec![]) }
async fn set_fields(&self, _: &UserId, _: Vec<domain::models::ProfileField>) -> Result<(), DomainError> { panic!() }
async fn get_fields(
&self,
_: &UserId,
) -> Result<Vec<domain::models::ProfileField>, DomainError> {
Ok(vec![])
}
async fn set_fields(
&self,
_: &UserId,
_: Vec<domain::models::ProfileField>,
) -> Result<(), DomainError> {
panic!()
}
}
struct PanicExporter;
@@ -136,20 +161,44 @@ impl domain::ports::DiaryExporter for PanicExporter {
struct PanicImportSession;
#[async_trait]
impl domain::ports::ImportSessionRepository for PanicImportSession {
async fn create(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { panic!() }
async fn get(&self, _: &domain::value_objects::ImportSessionId, _: &UserId) -> Result<Option<domain::models::ImportSession>, DomainError> { panic!() }
async fn update(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> { panic!() }
async fn delete(&self, _: &domain::value_objects::ImportSessionId) -> Result<(), DomainError> { panic!() }
async fn delete_expired(&self) -> Result<u64, DomainError> { panic!() }
async fn delete_expired_for_user(&self, _: &UserId) -> Result<(), DomainError> { panic!() }
async fn create(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> {
panic!()
}
async fn get(
&self,
_: &domain::value_objects::ImportSessionId,
_: &UserId,
) -> Result<Option<domain::models::ImportSession>, DomainError> {
panic!()
}
async fn update(&self, _: &domain::models::ImportSession) -> Result<(), DomainError> {
panic!()
}
async fn delete(&self, _: &domain::value_objects::ImportSessionId) -> Result<(), DomainError> {
panic!()
}
async fn delete_expired(&self) -> Result<u64, DomainError> {
panic!()
}
async fn delete_expired_for_user(&self, _: &UserId) -> Result<(), DomainError> {
panic!()
}
}
struct PanicDocumentParser;
impl domain::ports::DocumentParser for PanicDocumentParser {
fn parse(&self, _: &[u8], _: domain::models::FileFormat) -> Result<domain::models::ParsedFile, domain::models::ImportError> {
fn parse(
&self,
_: &[u8],
_: domain::models::FileFormat,
) -> Result<domain::models::ParsedFile, domain::models::ImportError> {
panic!("DocumentParser not wired in tests")
}
fn apply_mapping(&self, _: &domain::models::ParsedFile, _: &[domain::models::FieldMapping]) -> Vec<domain::models::AnnotatedRow> {
fn apply_mapping(
&self,
_: &domain::models::ParsedFile,
_: &[domain::models::FieldMapping],
) -> Vec<domain::models::AnnotatedRow> {
panic!("DocumentParser not wired in tests")
}
}
@@ -159,54 +208,128 @@ struct PanicImportProfile;
struct PanicMovieProfile;
#[async_trait]
impl domain::ports::MovieProfileRepository for PanicMovieProfile {
async fn upsert(&self, _: &domain::models::MovieProfile) -> Result<(), DomainError> { panic!() }
async fn get_by_movie_id(&self, _: &domain::value_objects::MovieId) -> Result<Option<domain::models::MovieProfile>, DomainError> { Ok(None) }
async fn list_stale(&self) -> Result<Vec<(domain::value_objects::MovieId, String)>, DomainError> { Ok(vec![]) }
async fn upsert(&self, _: &domain::models::MovieProfile) -> Result<(), DomainError> {
panic!()
}
async fn get_by_movie_id(
&self,
_: &domain::value_objects::MovieId,
) -> Result<Option<domain::models::MovieProfile>, DomainError> {
Ok(None)
}
async fn list_stale(
&self,
) -> Result<Vec<(domain::value_objects::MovieId, String)>, DomainError> {
Ok(vec![])
}
}
#[async_trait]
impl domain::ports::ImportProfileRepository for PanicImportProfile {
async fn save(&self, _: &domain::models::ImportProfile) -> Result<(), DomainError> { panic!() }
async fn list_for_user(&self, _: &UserId) -> Result<Vec<domain::models::ImportProfile>, DomainError> { panic!() }
async fn get(&self, _: &domain::value_objects::ImportProfileId, _: &UserId) -> Result<Option<domain::models::ImportProfile>, DomainError> { panic!() }
async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> { panic!() }
async fn save(&self, _: &domain::models::ImportProfile) -> Result<(), DomainError> {
panic!()
}
async fn list_for_user(
&self,
_: &UserId,
) -> Result<Vec<domain::models::ImportProfile>, DomainError> {
panic!()
}
async fn get(
&self,
_: &domain::value_objects::ImportProfileId,
_: &UserId,
) -> Result<Option<domain::models::ImportProfile>, DomainError> {
panic!()
}
async fn delete(&self, _: &domain::value_objects::ImportProfileId) -> Result<(), DomainError> {
panic!()
}
}
struct PanicWatchlist;
#[async_trait]
impl domain::ports::WatchlistRepository for PanicWatchlist {
async fn add(&self, _: &domain::models::WatchlistEntry) -> Result<(), DomainError> { panic!() }
async fn remove(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result<(), DomainError> { panic!() }
async fn remove_if_present(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result<bool, DomainError> { Ok(false) }
async fn get_for_user(&self, _: &domain::value_objects::UserId, _: &domain::models::collections::PageParams) -> Result<domain::models::collections::Paginated<domain::models::WatchlistWithMovie>, DomainError> { panic!() }
async fn contains(&self, _: &domain::value_objects::UserId, _: &domain::value_objects::MovieId) -> Result<bool, DomainError> { Ok(false) }
async fn add(&self, _: &domain::models::WatchlistEntry) -> Result<(), DomainError> {
panic!()
}
async fn remove(
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::MovieId,
) -> Result<(), DomainError> {
panic!()
}
async fn remove_if_present(
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::MovieId,
) -> Result<bool, DomainError> {
Ok(false)
}
async fn get_for_user(
&self,
_: &domain::value_objects::UserId,
_: &domain::models::collections::PageParams,
) -> Result<
domain::models::collections::Paginated<domain::models::WatchlistWithMovie>,
DomainError,
> {
panic!()
}
async fn contains(
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::MovieId,
) -> Result<bool, DomainError> {
Ok(false)
}
}
struct PanicPersonCommand;
#[async_trait]
impl PersonCommand for PanicPersonCommand {
async fn upsert_batch(&self, _: &[Person]) -> Result<(), DomainError> { panic!() }
async fn upsert_batch(&self, _: &[Person]) -> Result<(), DomainError> {
panic!()
}
}
struct PanicPersonQuery;
#[async_trait]
impl PersonQuery for PanicPersonQuery {
async fn get_by_id(&self, _: &PersonId) -> Result<Option<Person>, DomainError> { panic!() }
async fn get_by_external_id(&self, _: &ExternalPersonId) -> Result<Option<Person>, DomainError> { panic!() }
async fn get_credits(&self, _: &PersonId) -> Result<PersonCredits, DomainError> { panic!() }
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError> { panic!() }
async fn get_by_id(&self, _: &PersonId) -> Result<Option<Person>, DomainError> {
panic!()
}
async fn get_by_external_id(
&self,
_: &ExternalPersonId,
) -> Result<Option<Person>, DomainError> {
panic!()
}
async fn get_credits(&self, _: &PersonId) -> Result<PersonCredits, DomainError> {
panic!()
}
async fn list_orphaned_persons(&self) -> Result<Vec<PersonId>, DomainError> {
panic!()
}
}
struct PanicSearchPort;
#[async_trait]
impl SearchPort for PanicSearchPort {
async fn search(&self, _: &SearchQuery) -> Result<SearchResults, DomainError> { panic!() }
async fn search(&self, _: &SearchQuery) -> Result<SearchResults, DomainError> {
panic!()
}
}
struct PanicSearchCommand;
#[async_trait]
impl SearchCommand for PanicSearchCommand {
async fn index(&self, _: IndexableDocument) -> Result<(), DomainError> { panic!() }
async fn remove(&self, _: EntityType, _: &str) -> Result<(), DomainError> { panic!() }
async fn index(&self, _: IndexableDocument) -> Result<(), DomainError> {
panic!()
}
async fn remove(&self, _: EntityType, _: &str) -> Result<(), DomainError> {
panic!()
}
}
#[cfg(feature = "federation")]
@@ -217,19 +340,32 @@ struct PanicRemoteWatchlist;
#[cfg(feature = "federation")]
#[async_trait::async_trait]
impl domain::ports::RemoteWatchlistRepository for PanicRemoteWatchlist {
async fn save(&self, _: domain::models::RemoteWatchlistEntry) -> Result<(), DomainError> { Ok(()) }
async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> { Ok(()) }
async fn get_by_actor_url(&self, _: &str) -> Result<Vec<domain::models::RemoteWatchlistEntry>, DomainError> { Ok(vec![]) }
async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> { Ok(()) }
async fn get_by_derived_uuid(&self, _: uuid::Uuid) -> Result<Vec<domain::models::RemoteWatchlistEntry>, DomainError> { Ok(vec![]) }
async fn save(&self, _: domain::models::RemoteWatchlistEntry) -> Result<(), DomainError> {
Ok(())
}
async fn remove_by_ap_id(&self, _: &str, _: &str) -> Result<(), DomainError> {
Ok(())
}
async fn get_by_actor_url(
&self,
_: &str,
) -> Result<Vec<domain::models::RemoteWatchlistEntry>, DomainError> {
Ok(vec![])
}
async fn remove_all_by_actor(&self, _: &str) -> Result<(), DomainError> {
Ok(())
}
async fn get_by_derived_uuid(
&self,
_: uuid::Uuid,
) -> Result<Vec<domain::models::RemoteWatchlistEntry>, DomainError> {
Ok(vec![])
}
}
#[cfg(feature = "federation")]
#[async_trait::async_trait]
impl domain::ports::SocialQueryPort for PanicSocialQuery {
async fn get_accepted_following_urls(
&self,
_: uuid::Uuid,
) -> Result<Vec<String>, DomainError> {
async fn get_accepted_following_urls(&self, _: uuid::Uuid) -> Result<Vec<String>, DomainError> {
panic!()
}
async fn list_all_followed_remote_actors(
@@ -406,7 +542,10 @@ async fn tags_other_redirects_to_search() {
.await
.unwrap();
assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
assert_eq!(response.headers().get("location").unwrap(), "/?search=batman");
assert_eq!(
response.headers().get("location").unwrap(),
"/?search=batman"
);
}
#[tokio::test]