feat: MovieDto enrichment, movie detail page, PWA, watchlist, watchlist federation

This commit is contained in:
2026-05-13 00:23:45 +02:00
parent 2fd8734d23
commit 53df90ab1f
84 changed files with 2755 additions and 398 deletions

View File

@@ -9,7 +9,7 @@ sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search"]
postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search"]
nats = ["dep:nats"]
# Meta-feature: true when any federation adapter is active — keeps all #[cfg(feature = "federation")] gates working
federation = []
federation = ["application/federation"]
sqlite-federation = [
"sqlite",
"dep:sqlite-federation",

View File

@@ -2,7 +2,7 @@ use chrono::NaiveDateTime;
use serde::Deserialize;
use uuid::Uuid;
use application::{commands::LogReviewCommand, queries::GetDiaryQuery};
use application::{commands::{LogReviewCommand, MovieInput}, queries::GetDiaryQuery};
use domain::{errors::DomainError, models::SortDirection};
use api_types::{DiaryQueryParams, LogReviewRequest};
@@ -124,6 +124,25 @@ pub struct ActorUrlForm {
pub csrf_token: String,
}
#[derive(serde::Deserialize)]
pub struct WatchlistAddForm {
pub movie_id: Option<uuid::Uuid>,
pub query: Option<String>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub year: Option<u16>,
#[serde(rename = "_csrf", default)]
pub csrf_token: String,
#[serde(default)]
pub redirect_after: Option<String>,
}
#[derive(serde::Deserialize, Default)]
pub struct WatchlistQuery {
pub limit: Option<u32>,
pub offset: Option<u32>,
pub error: Option<String>,
}
#[derive(Deserialize, Default)]
pub struct ProfileQueryParams {
pub view: Option<String>,
@@ -206,14 +225,17 @@ impl TryFrom<LogReviewRequest> for LogReviewData {
impl LogReviewData {
pub fn into_command(self, user_id: Uuid) -> LogReviewCommand {
LogReviewCommand {
external_metadata_id: self.external_metadata_id,
manual_title: self.manual_title,
manual_release_year: self.manual_release_year,
manual_director: self.manual_director,
user_id,
input: MovieInput {
movie_id: None,
external_metadata_id: self.external_metadata_id,
manual_title: self.manual_title,
manual_release_year: self.manual_release_year,
manual_director: self.manual_director,
},
rating: self.rating,
comment: self.comment,
watched_at: self.watched_at,
user_id,
}
}
}

View File

@@ -10,11 +10,13 @@ use std::str::FromStr;
use application::{
commands::{
DeleteReviewCommand, RegisterCommand, SyncPosterCommand,
DeleteReviewCommand, MovieInput, RegisterCommand, SyncPosterCommand,
AddToWatchlistCommand, RemoveFromWatchlistCommand,
},
queries::{
ExportQuery, GetActivityFeedQuery, GetMovieSocialPageQuery, GetMoviesQuery,
GetReviewHistoryQuery, GetUserProfileQuery, GetUsersQuery, LoginQuery,
GetWatchlistQuery, IsOnWatchlistQuery,
},
use_cases::{
delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
@@ -22,11 +24,12 @@ use application::{
get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc,
register as register_uc, sync_poster, update_profile,
search as search_uc, get_person, get_person_credits,
add_to_watchlist, remove_from_watchlist, get_watchlist, is_on_watchlist,
},
};
use domain::{
errors::DomainError,
models::{DiaryEntry, ExportFormat, Movie, Review, PersonId, collections::PageParams},
models::{DiaryEntry, ExportFormat, Movie, MovieSummary, Review, PersonId, collections::PageParams},
services::review_history::Trend,
value_objects::{MovieId, UserId},
};
@@ -44,6 +47,7 @@ use api_types::{
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,
@@ -96,12 +100,14 @@ pub async fn list_movies(
limit: params.limit,
offset: params.offset,
search: params.search,
genre: params.genre,
language: params.language,
},
)
.await?;
Ok(Json(MoviesResponse {
items: page.items.iter().map(movie_to_dto).collect(),
items: page.items.iter().map(summary_to_dto).collect(),
total_count: page.total_count,
limit: page.limit,
offset: page.offset,
@@ -438,12 +444,11 @@ pub async fn update_profile_handler(
}
"avatar" => {
let content_type = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() {
if let Ok(bytes) = field.bytes().await
&& !bytes.is_empty() {
avatar_bytes = Some(bytes.to_vec());
avatar_content_type = content_type;
}
}
}
_ => {}
}
@@ -476,6 +481,26 @@ fn movie_to_dto(movie: &Movie) -> MovieDto {
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()),
genres: vec![],
runtime_minutes: None,
original_language: None,
overview: None,
collection_name: None,
}
}
fn summary_to_dto(summary: &MovieSummary) -> MovieDto {
MovieDto {
id: summary.movie.id().value(),
title: summary.movie.title().value().to_string(),
release_year: summary.movie.release_year().value(),
director: summary.movie.director().map(|d| d.to_string()),
poster_path: summary.movie.poster_path().map(|p| p.value().to_string()),
genres: summary.genres.clone(),
runtime_minutes: summary.runtime_minutes,
original_language: summary.original_language.clone(),
overview: summary.overview.clone(),
collection_name: summary.collection_name.clone(),
}
}
@@ -1233,3 +1258,119 @@ pub async fn get_person_credits_handler(
}
}
}
#[utoipa::path(
get, path = "/api/v1/watchlist",
params(
("limit" = Option<u32>, Query, description = "Max results"),
("offset" = Option<u32>, Query, description = "Offset"),
),
responses(
(status = 200, body = WatchlistResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_watchlist_handler(
State(state): State<AppState>,
user: AuthenticatedUser,
Query(params): Query<PaginationQueryParams>,
) -> Result<Json<WatchlistResponse>, ApiError> {
let page = get_watchlist::execute(
&state.app_ctx,
GetWatchlistQuery {
user_id: user.0.value(),
limit: params.limit,
offset: params.offset,
},
)
.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(),
total_count: page.total_count,
limit: page.limit,
offset: page.offset,
}))
}
#[utoipa::path(
post, path = "/api/v1/watchlist",
request_body = AddToWatchlistRequest,
responses(
(status = 201, description = "Added to watchlist"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Movie not found"),
),
security(("bearer_auth" = []))
)]
pub async fn post_watchlist_add(
State(state): State<AppState>,
user: AuthenticatedUser,
Json(req): Json<AddToWatchlistRequest>,
) -> Result<impl IntoResponse, ApiError> {
add_to_watchlist::execute(
&state.app_ctx,
AddToWatchlistCommand {
user_id: user.0.value(),
input: MovieInput {
movie_id: Some(req.movie_id),
external_metadata_id: None,
manual_title: None,
manual_release_year: None,
manual_director: None,
},
},
)
.await?;
Ok(StatusCode::CREATED)
}
#[utoipa::path(
delete, path = "/api/v1/watchlist/{movie_id}",
params(("movie_id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 204, description = "Removed from watchlist"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not on watchlist"),
),
security(("bearer_auth" = []))
)]
pub async fn delete_watchlist_entry(
State(state): State<AppState>,
user: AuthenticatedUser,
Path(movie_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
remove_from_watchlist::execute(
&state.app_ctx,
RemoveFromWatchlistCommand { user_id: user.0.value(), movie_id },
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
get, path = "/api/v1/watchlist/{movie_id}",
params(("movie_id" = Uuid, Path, description = "Movie ID")),
responses(
(status = 200, body = WatchlistStatusResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_watchlist_status(
State(state): State<AppState>,
user: AuthenticatedUser,
Path(movie_id): Path<Uuid>,
) -> Result<Json<WatchlistStatusResponse>, ApiError> {
let on_watchlist = is_on_watchlist::execute(
&state.app_ctx,
IsOnWatchlistQuery { user_id: user.0.value(), movie_id },
)
.await?;
Ok(Json(WatchlistStatusResponse { on_watchlist }))
}

View File

@@ -15,15 +15,17 @@ use application::ports::{
FollowersPageData, FollowingPageData,
};
use application::{
commands::{DeleteReviewCommand, RegisterCommand},
queries::{ExportQuery, GetMovieSocialPageQuery, LoginQuery},
commands::{AddToWatchlistCommand, DeleteReviewCommand, MovieInput, RegisterCommand, RemoveFromWatchlistCommand},
queries::{ExportQuery, GetMovieSocialPageQuery, GetWatchlistQuery, LoginQuery},
ports::{
HtmlPageContext, LoginPageData, MovieDetailPageData, NewReviewPageData,
ProfileSettingsPageData, RegisterPageData, RemoteActorView,
ProfileSettingsPageData, RegisterPageData, RemoteActorView, WatchlistDisplayEntry,
WatchlistPageData,
},
use_cases::{
delete_review, export_diary as export_diary_uc, get_movie_social_page, log_review,
login as login_uc, register as register_uc, update_profile,
add_to_watchlist, delete_review, export_diary as export_diary_uc, get_movie_social_page,
get_watchlist, log_review, login as login_uc, register as register_uc,
remove_from_watchlist, update_profile,
},
};
use domain::models::ExportFormat;
@@ -383,12 +385,11 @@ pub async fn get_activity_feed(
let mut local_ids = vec![uid.value()];
let mut remote_urls = Vec::new();
for url in urls {
if let Some(suffix) = url.strip_prefix(&format!("{}/users/", base_url)) {
if let Ok(parsed_id) = uuid::Uuid::parse_str(suffix) {
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;
}
}
remote_urls.push(url);
}
Some(domain::ports::FollowingFilter {
@@ -953,7 +954,7 @@ pub async fn get_movie_detail(
Query(params): Query<api_types::PaginationQueryParams>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let ctx = build_page_context(&state, user_id, csrf.0).await;
let ctx = build_page_context(&state, user_id.clone(), csrf.0).await;
let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0);
@@ -973,10 +974,19 @@ pub async fn get_movie_detail(
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) => state.app_ctx.watchlist_repository
.contains(uid, &domain::value_objects::MovieId::from_uuid(movie_id))
.await
.unwrap_or(false),
None => false,
};
let data = MovieDetailPageData {
ctx,
movie: result.movie,
stats: result.stats,
profile: result.profile,
on_watchlist,
current_offset: result.reviews.offset,
has_more,
limit: result.reviews.limit,
@@ -994,6 +1004,206 @@ pub async fn get_movie_detail(
}
}
pub async fn get_watchlist_page(
OptionalCookieUser(viewer_id): OptionalCookieUser,
State(state): State<AppState>,
Path(owner_id): Path<uuid::Uuid>,
Query(params): Query<crate::forms::WatchlistQuery>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let ctx = build_page_context(&state, viewer_id.clone(), csrf.0).await;
let limit = params.limit.unwrap_or(20);
let offset = params.offset.unwrap_or(0);
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
.find_by_id(&domain::value_objects::UserId::from_uuid(owner_id))
.await
.ok()
.flatten();
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 {
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();
(display, has_more, entries.offset, entries.limit)
}
}
} else {
#[cfg(feature = "federation")]
{
let remote_entries = state.app_ctx.remote_watchlist_repository
.get_by_derived_uuid(owner_id)
.await
.unwrap_or_default();
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();
let len = display.len() as u32;
(display, false, 0u32, len)
}
#[cfg(not(feature = "federation"))]
{
(vec![], false, 0u32, 0u32)
}
};
let data = WatchlistPageData {
ctx,
owner_id,
display_entries,
current_offset,
has_more,
limit: page_limit,
is_owner,
error: params.error,
};
match state.html_renderer.render_watchlist_page(data) {
Ok(html) => Html(html).into_response(),
Err(e) => {
tracing::error!("watchlist template error: {}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
pub async fn post_watchlist_add(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
Form(form): Form<crate::forms::WatchlistAddForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let redirect_base = form
.redirect_after
.as_deref()
.filter(|u| u.starts_with('/') && !u.starts_with("//"))
.unwrap_or("/")
.to_string();
let input = if let Some(id) = form.movie_id {
MovieInput {
movie_id: Some(id),
external_metadata_id: None,
manual_title: None,
manual_release_year: None,
manual_director: None,
}
} else {
let query = form.query.as_deref().unwrap_or("").trim().to_string();
let is_external_id = query.starts_with("tmdb:")
|| (query.starts_with("tt")
&& query.len() > 2
&& query[2..].chars().all(|c| c.is_ascii_digit()));
if is_external_id {
MovieInput {
movie_id: None,
external_metadata_id: Some(query),
manual_title: None,
manual_release_year: None,
manual_director: None,
}
} else {
MovieInput {
movie_id: None,
external_metadata_id: None,
manual_title: if query.is_empty() { None } else { Some(query) },
manual_release_year: form.year,
manual_director: None,
}
}
};
match add_to_watchlist::execute(
&state.app_ctx,
AddToWatchlistCommand {
user_id: user_id.value(),
input,
},
)
.await
{
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 url = format!("{}{}error={}", redirect_base, sep, encode_error(&msg));
Redirect::to(&url).into_response()
}
Err(e) => {
tracing::error!("watchlist add error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
pub async fn post_watchlist_remove(
State(state): State<AppState>,
RequiredCookieUser(user_id): RequiredCookieUser,
Extension(csrf): Extension<CsrfToken>,
Path(movie_id): Path<uuid::Uuid>,
Form(form): Form<crate::forms::DeleteRedirectForm>,
) -> impl IntoResponse {
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
match remove_from_watchlist::execute(
&state.app_ctx,
RemoveFromWatchlistCommand {
user_id: user_id.value(),
movie_id,
},
)
.await
{
Ok(()) | Err(DomainError::NotFound(_)) => {
let redirect_url = form
.redirect_after
.filter(|u| u.starts_with('/') && !u.starts_with("//"))
.unwrap_or_else(|| "/".to_string());
Redirect::to(&redirect_url).into_response()
}
Err(e) => {
tracing::error!("watchlist remove error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
#[derive(serde::Deserialize, Default)]
pub struct SavedQuery {
pub saved: Option<String>,
@@ -1219,12 +1429,11 @@ pub async fn post_profile_settings(
}
"avatar" => {
let content_type = field.content_type().map(|s| s.to_string());
if let Ok(bytes) = field.bytes().await {
if !bytes.is_empty() {
if let Ok(bytes) = field.bytes().await
&& !bytes.is_empty() {
avatar_bytes = Some(bytes.to_vec());
avatar_content_type = content_type;
}
}
}
_ => {}
}

View File

@@ -15,8 +15,6 @@ use presentation::{openapi, routes, state::AppState};
use domain::ports::{DiaryExporter, DocumentParser, EventPublisher, ImportProfileRepository, ImportSessionRepository};
#[cfg(feature = "sqlite")]
use sqlite_search;
#[cfg(feature = "postgres")]
use postgres_search;
@@ -54,21 +52,21 @@ 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, person_command, person_query, search_command, search_port, db_pool) =
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) = postgres::wire(&database_url).await?;
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, pc, pq, sc, sp, DbPool::Postgres(pool))
(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) = sqlite::wire(&database_url).await?;
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, pc, pq, sc, sp, DbPool::Sqlite(pool))
(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)"),
@@ -78,8 +76,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
let event_bus = EventBusBackend::from_env()?;
#[cfg(feature = "federation")]
let (event_publisher_arc, ap_router, ap_service, social_query) = {
let (federation_repo, social_query_arc, review_store) = match &db_pool {
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")]
@@ -91,6 +89,8 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
let ap = activitypub::wire(
federation_repo,
review_store,
remote_watchlist_repo.clone(),
Arc::clone(&watchlist_repository),
Arc::clone(&user_repository),
Arc::clone(&movie_repository),
Arc::clone(&review_repository),
@@ -123,7 +123,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
nats::create_publisher(cfg).await?
}
};
(ep, ap_router, ap_service_arc, social_query_arc)
(ep, ap_router, ap_service_arc, social_query_arc, remote_watchlist_repo)
};
#[cfg(not(feature = "federation"))]
@@ -171,6 +171,9 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
import_session_repository: import_session_repository as Arc<dyn ImportSessionRepository>,
import_profile_repository: import_profile_repository as Arc<dyn ImportProfileRepository>,
movie_profile_repository,
watchlist_repository,
#[cfg(feature = "federation")]
remote_watchlist_repository: remote_watchlist_repo,
person_command,
person_query,
search_port,

View File

@@ -5,6 +5,7 @@ mod movies;
mod search;
mod social;
mod users;
mod watchlist;
use axum::Router;
use utoipa::{
@@ -38,6 +39,7 @@ fn build() -> utoipa::openapi::OpenApi {
api.merge(users::UsersDoc::openapi());
api.merge(import::ImportDoc::openapi());
api.merge(search::SearchDoc::openapi());
api.merge(watchlist::WatchlistDoc::openapi());
#[cfg(feature = "federation")]
api.merge(social::SocialDoc::openapi());
SecurityAddon.modify(&mut api);

View File

@@ -0,0 +1,19 @@
use api_types::{AddToWatchlistRequest, WatchlistEntryDto, WatchlistResponse, WatchlistStatusResponse};
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(
paths(
crate::handlers::api::get_watchlist_handler,
crate::handlers::api::post_watchlist_add,
crate::handlers::api::delete_watchlist_entry,
crate::handlers::api::get_watchlist_status,
),
components(schemas(
WatchlistResponse,
WatchlistEntryDto,
AddToWatchlistRequest,
WatchlistStatusResponse,
))
)]
pub struct WatchlistDoc;

View File

@@ -107,7 +107,19 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
routing::get(handlers::html::get_profile_settings)
.post(handlers::html::post_profile_settings),
)
.route("/tags/{tag}", routing::get(handlers::html::get_tag));
.route("/tags/{tag}", routing::get(handlers::html::get_tag))
.route(
"/users/{id}/watchlist",
routing::get(handlers::html::get_watchlist_page),
)
.route(
"/watchlist/add",
routing::post(handlers::html::post_watchlist_add),
)
.route(
"/watchlist/{movie_id}/remove",
routing::post(handlers::html::post_watchlist_remove),
);
#[cfg(feature = "federation")]
let base = base.merge(federation_html_routes());
@@ -213,7 +225,17 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
.route("/profile", routing::get(handlers::api::get_profile).put(handlers::api::update_profile_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}/credits", routing::get(handlers::api::get_person_credits_handler))
.route(
"/watchlist",
routing::get(handlers::api::get_watchlist_handler)
.post(handlers::api::post_watchlist_add),
)
.route(
"/watchlist/{movie_id}",
routing::get(handlers::api::get_watchlist_status)
.delete(handlers::api::delete_watchlist_entry),
);
#[cfg(feature = "federation")]
let base = base.merge(federation_api_routes());

View File

@@ -143,3 +143,48 @@ async fn person_credits_endpoint_returns_404_for_unknown_id() {
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
// --- Watchlist endpoint tests ---
#[tokio::test]
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))
.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);
}
#[tokio::test]
async fn get_watchlist_status_requires_auth() {
let state = make_test_state(Arc::new(Panic));
let app = Router::new()
.route(
"/api/v1/watchlist/{movie_id}",
get(crate::handlers::api::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);
}

View File

@@ -19,7 +19,7 @@ use domain::{
ports::{
AuthService, DiaryRepository, EventPublisher, GeneratedToken, ImageStorage,
MetadataClient, MovieRepository, PasswordHasher, PosterFetcherClient, ReviewRepository,
StatsRepository, UserRepository,
StatsRepository, UserRepository, WatchlistRepository,
PersonCommand, PersonQuery, SearchPort, SearchCommand,
},
value_objects::{
@@ -58,7 +58,7 @@ impl MovieRepository for Panic {
async fn delete_movie(&self, _: &MovieId) -> Result<(), DomainError> {
panic!()
}
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: Option<&str>) -> Result<domain::models::collections::Paginated<Movie>, DomainError> {
async fn list_movies(&self, _: &domain::models::collections::PageParams, _: &domain::models::MovieFilter) -> Result<domain::models::collections::Paginated<domain::models::MovieSummary>, DomainError> {
panic!()
}
}
@@ -242,6 +242,14 @@ impl domain::ports::ImportProfileRepository for 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_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) }
@@ -335,6 +343,7 @@ impl crate::ports::HtmlRenderer for 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> {
@@ -373,6 +382,15 @@ impl SearchCommand for 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![]) }
}
// --- Single state factory — only auth_service varies ---
@@ -395,6 +413,9 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
import_session_repository: Arc::clone(&repo) as _,
import_profile_repository: Arc::clone(&repo) as _,
movie_profile_repository: Arc::clone(&repo) as _,
watchlist_repository: Arc::clone(&repo) as _,
#[cfg(feature = "federation")]
remote_watchlist_repository: Arc::clone(&repo) as _,
person_command: Arc::clone(&repo) as _,
person_query: Arc::clone(&repo) as _,
search_port: Arc::clone(&repo) as _,

View File

@@ -1,45 +1,10 @@
// Re-export imports needed by subtest modules
pub use application::{config::AppConfig, context::AppContext};
pub use axum::{
Router,
body::Body,
http::{Request, StatusCode},
routing::get,
};
pub use domain::{
errors::DomainError,
events::DomainEvent,
models::{
DiaryEntry, DiaryFilter, FeedEntry, Movie, Review, ReviewHistory, 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,
PersonCommand, PersonQuery, SearchPort, SearchCommand,
},
value_objects::{
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl,
ReleaseYear, ReviewId, UserId,
},
};
pub use std::sync::Arc;
pub use tower::ServiceExt;
// API types for tests
pub use api_types::{
LoginRequest, LogReviewRequest, DiaryQueryParams,
};
pub use crate::{
extractors::{AuthenticatedUser, OptionalCookieUser, RequiredCookieUser},
forms::{LogReviewData, LogReviewForm, to_diary_query},
state::AppState,
};
pub use api_types::{DiaryQueryParams, LogReviewRequest};
mod api_handlers;
mod extractors;
mod forms;
mod api_handlers;

View File

@@ -164,6 +164,16 @@ impl domain::ports::ImportProfileRepository for PanicImportProfile {
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) }
}
struct PanicPersonCommand;
#[async_trait]
impl PersonCommand for PanicPersonCommand {
@@ -194,6 +204,18 @@ impl SearchCommand for PanicSearchCommand {
#[cfg(feature = "federation")]
struct PanicSocialQuery;
#[cfg(feature = "federation")]
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![]) }
}
#[cfg(feature = "federation")]
#[async_trait::async_trait]
impl domain::ports::SocialQueryPort for PanicSocialQuery {
@@ -236,6 +258,9 @@ async fn test_app() -> Router {
import_session_repository: Arc::new(PanicImportSession),
import_profile_repository: Arc::new(PanicImportProfile),
movie_profile_repository: Arc::new(PanicMovieProfile),
watchlist_repository: Arc::new(PanicWatchlist),
#[cfg(feature = "federation")]
remote_watchlist_repository: Arc::new(PanicRemoteWatchlist),
person_command: Arc::new(PanicPersonCommand),
person_query: Arc::new(PanicPersonQuery),
search_port: Arc::new(PanicSearchPort),