structural refactor and codebase improvements

This commit is contained in:
2026-08-09 14:58:14 +02:00
parent 22b1dd3f56
commit c9715baab8
247 changed files with 11515 additions and 3063 deletions

View File

@@ -6,24 +6,9 @@ description = "Self-hosted movie diary with REST API and ActivityPub federation"
license = "MIT"
[features]
default = ["sqlite", "sqlite-federation"]
sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search", "infra-wiring/sqlite"]
postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search", "infra-wiring/postgres"]
nats = ["dep:nats", "infra-wiring/nats"]
default = ["federation"]
# Meta-feature: true when any federation adapter is active — keeps all #[cfg(feature = "federation")] gates working
federation = ["application/federation"]
sqlite-federation = [
"sqlite",
"dep:sqlite-federation",
"dep:activitypub",
"federation",
]
postgres-federation = [
"postgres",
"dep:postgres-federation",
"dep:activitypub",
"federation",
]
[dependencies]
tower-http = { version = "0.6.8", features = ["cors", "fs", "trace", "tracing"] }
@@ -47,38 +32,14 @@ futures = { workspace = true }
api-types = { workspace = true }
domain = { workspace = true }
application = { workspace = true }
auth = { workspace = true }
metadata = { workspace = true }
poster-fetcher = { workspace = true }
object-storage = { workspace = true }
template-askama = { workspace = true }
nats = { workspace = true, optional = true }
rss = { workspace = true }
export = { workspace = true }
importer = { workspace = true }
jellyfin = { workspace = true }
plex = { workspace = true }
sqlx = { workspace = true }
infra-wiring = { workspace = true }
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] }
utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] }
# Optional — database backends
sqlite = { workspace = true, optional = true }
postgres = { workspace = true, optional = true }
sqlite-event-queue = { workspace = true, optional = true }
postgres-event-queue = { workspace = true, optional = true }
sqlite-search = { workspace = true, optional = true }
postgres-search = { workspace = true, optional = true }
# Optional — federation
activitypub = { workspace = true, optional = true }
sqlite-federation = { workspace = true, optional = true }
postgres-federation = { workspace = true, optional = true }
[dev-dependencies]
bytes = { workspace = true }
futures = { workspace = true }
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
domain = { workspace = true, features = ["test-helpers"] }
composition = { workspace = true }

View File

@@ -1,73 +1,15 @@
use std::sync::Arc;
use domain::ports::{
AuthService, DiaryExporter, DiaryQuery, DocumentParser, EventPublisher, FederatedProfileQuery,
FederationAdminQuery, GoalCommand, GoalQuery, ImportProfileRepository, ImportSessionRepository,
MetadataClient, MovieCommand, MovieProfileRepository, MovieQuery, ObjectStorage,
PasswordHasher, PersonCommand, PersonEnrichmentClient, PersonQuery, PosterFetcherClient,
RefreshSessionRepository, RemoteGoalRepository, RemoteWatchlistRepository, ReviewRepository,
SearchCommand, SearchPort, SocialCommand, SocialQuery, StatsRepository,
UserProfileFieldsRepository, UserRepository, UserSettingsRepository, WatchEventCommand,
WatchEventQuery, WatchlistRepository, WebhookTokenRepository, WrapUpRepository,
WrapUpStatsQuery,
};
use application::config::AppConfig;
use application::ports::ReviewLogger;
#[derive(Clone)]
pub struct Repositories {
pub movie_command: Arc<dyn MovieCommand>,
pub movie_query: Arc<dyn MovieQuery>,
pub review: Arc<dyn ReviewRepository>,
pub diary: Arc<dyn DiaryQuery>,
pub stats: Arc<dyn StatsRepository>,
pub user: Arc<dyn UserRepository>,
pub import_session: Arc<dyn ImportSessionRepository>,
pub import_profile: Arc<dyn ImportProfileRepository>,
pub movie_profile: Arc<dyn MovieProfileRepository>,
pub watchlist: Arc<dyn WatchlistRepository>,
pub watch_event_command: Arc<dyn WatchEventCommand>,
pub watch_event_query: Arc<dyn WatchEventQuery>,
pub webhook_token: Arc<dyn WebhookTokenRepository>,
pub person_command: Arc<dyn PersonCommand>,
pub person_query: Arc<dyn PersonQuery>,
pub search_port: Arc<dyn SearchPort>,
pub search_command: Arc<dyn SearchCommand>,
pub profile_fields: Arc<dyn UserProfileFieldsRepository>,
pub remote_watchlist: Arc<dyn RemoteWatchlistRepository>,
pub social_command: Arc<dyn SocialCommand>,
pub social_query_unified: Arc<dyn SocialQuery>,
pub federation_admin: Arc<dyn FederationAdminQuery>,
pub wrapup_stats: Arc<dyn WrapUpStatsQuery>,
pub wrapup_repo: Arc<dyn WrapUpRepository>,
pub goal_command: Arc<dyn GoalCommand>,
pub goal_query: Arc<dyn GoalQuery>,
pub user_settings: Arc<dyn UserSettingsRepository>,
pub remote_goal: Arc<dyn RemoteGoalRepository>,
pub refresh_session: Arc<dyn RefreshSessionRepository>,
pub federated_profile: Option<Arc<dyn FederatedProfileQuery>>,
}
#[derive(Clone)]
pub struct Services {
pub auth: Arc<dyn AuthService>,
pub password_hasher: Arc<dyn PasswordHasher>,
pub metadata: Arc<dyn MetadataClient>,
pub poster_fetcher: Arc<dyn PosterFetcherClient>,
pub object_storage: Arc<dyn ObjectStorage>,
pub event_publisher: Arc<dyn EventPublisher>,
pub diary_exporter: Arc<dyn DiaryExporter>,
pub document_parser: Arc<dyn DocumentParser>,
pub review_logger: Arc<dyn ReviewLogger>,
pub person_enrichment: Option<Arc<dyn PersonEnrichmentClient>>,
#[cfg(feature = "federation")]
pub ap_service: Arc<dyn activitypub::ActivityPubPort>,
}
#[derive(Clone)]
pub struct AppContext {
pub repos: Repositories,
pub services: Services,
pub deps: Arc<application::Deps>,
pub services: application::Services,
pub config: AppConfig,
pub instance: domain::value_objects::InstanceIdentity,
#[cfg(feature = "federation")]
pub ap_document: Arc<dyn domain::ports::ApDocumentPort>,
#[cfg(feature = "federation")]
pub ap_blocklist: Arc<dyn domain::ports::InstanceBlocklistPort>,
}

View File

@@ -111,17 +111,17 @@ where
let AuthenticatedUser(user_id) =
AuthenticatedUser::from_request_parts(parts, state).await?;
let app_state = AppState::from_ref(state);
let user = app_state
.app_ctx
.repos
.user
.find_by_id(&user_id)
.await
.map_err(ApiError)?
.ok_or_else(|| ApiError(DomainError::NotFound("user not found".into())))?;
match user.role() {
domain::models::UserRole::Admin => Ok(AdminApiUser(user_id)),
_ => Err(ApiError(DomainError::Forbidden("admin only".into()))),
let is_admin = application::users::authorize_admin::execute(
&app_state.app_ctx.deps.users.authorize_admin,
user_id.value(),
)
.await
.map_err(ApiError)?
.ok_or_else(|| ApiError(DomainError::NotFound("user not found".into())))?;
if is_admin {
Ok(AdminApiUser(user_id))
} else {
Err(ApiError(DomainError::Forbidden("admin only".into())))
}
}
}
@@ -139,17 +139,17 @@ where
let app_state = AppState::from_ref(state);
let RequiredCookieUser(user_id) =
RequiredCookieUser::from_request_parts(parts, state).await?;
let user = app_state
.app_ctx
.repos
.user
.find_by_id(&user_id)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?
.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
match user.role() {
domain::models::UserRole::Admin => Ok(AdminUser(user_id)),
_ => Err(StatusCode::FORBIDDEN.into_response()),
let is_admin = application::users::authorize_admin::execute(
&app_state.app_ctx.deps.users.authorize_admin,
user_id.value(),
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())?
.ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?;
if is_admin {
Ok(AdminUser(user_id))
} else {
Err(StatusCode::FORBIDDEN.into_response())
}
}
}

View File

@@ -1,167 +0,0 @@
use std::sync::Arc;
use anyhow::Context;
use domain::ports::{
AuthService, LocalApContentQuery, MetadataClient, ObjectStorage, PasswordHasher,
PosterFetcherClient, RefreshSessionRepository, UserProfileFieldsRepository, WatchEventCommand,
WatchEventQuery, WebhookTokenRepository,
};
pub use infra_wiring::DbPool;
pub struct DatabaseOutput {
pub movie_command: Arc<dyn domain::ports::MovieCommand>,
pub movie_query: Arc<dyn domain::ports::MovieQuery>,
pub review: Arc<dyn domain::ports::ReviewRepository>,
pub diary: Arc<dyn domain::ports::DiaryQuery>,
pub stats: Arc<dyn domain::ports::StatsRepository>,
pub user: Arc<dyn domain::ports::UserRepository>,
pub import_session: Arc<dyn domain::ports::ImportSessionRepository>,
pub import_profile: Arc<dyn domain::ports::ImportProfileRepository>,
pub movie_profile: Arc<dyn domain::ports::MovieProfileRepository>,
pub watchlist: Arc<dyn domain::ports::WatchlistRepository>,
pub watch_event_command: Arc<dyn WatchEventCommand>,
pub watch_event_query: Arc<dyn WatchEventQuery>,
pub webhook_token: Arc<dyn WebhookTokenRepository>,
pub person_command: Arc<dyn domain::ports::PersonCommand>,
pub person_query: Arc<dyn domain::ports::PersonQuery>,
pub search_port: Arc<dyn domain::ports::SearchPort>,
pub search_command: Arc<dyn domain::ports::SearchCommand>,
pub profile_fields: Arc<dyn UserProfileFieldsRepository>,
pub ap_content: Arc<dyn LocalApContentQuery>,
pub wrapup_stats: Arc<dyn domain::ports::WrapUpStatsQuery>,
pub wrapup_repo: Arc<dyn domain::ports::WrapUpRepository>,
pub goal_command: Arc<dyn domain::ports::GoalCommand>,
pub goal_query: Arc<dyn domain::ports::GoalQuery>,
pub user_settings: Arc<dyn domain::ports::UserSettingsRepository>,
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub remote_goal: Arc<dyn domain::ports::RemoteGoalRepository>,
pub refresh_session: Arc<dyn RefreshSessionRepository>,
pub db_pool: DbPool,
}
pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result<DatabaseOutput> {
match backend {
#[cfg(feature = "postgres")]
"postgres" => {
let w = postgres::wire(url)
.await
.context("PostgreSQL connection failed")?;
let (pc, pq) = postgres::create_person_adapter(w.pool.clone());
let (sc, sp) = postgres_search::create_search_adapter(w.pool.clone());
let pf = postgres::create_profile_fields_repo(w.pool.clone());
let we = Arc::new(postgres::PostgresWatchEventRepository::new(w.pool.clone()));
let wt: Arc<dyn WebhookTokenRepository> = Arc::new(
postgres::PostgresWebhookTokenRepository::new(w.pool.clone()),
);
Ok(DatabaseOutput {
movie_command: w.movie_command,
movie_query: w.movie_query,
review: w.review,
diary: w.diary,
stats: w.stats,
user: w.user,
import_session: w.import_session,
import_profile: w.import_profile,
movie_profile: w.movie_profile,
watchlist: w.watchlist,
watch_event_command: we.clone() as _,
watch_event_query: we as _,
webhook_token: wt,
person_command: pc,
person_query: pq,
search_port: sp,
search_command: sc,
profile_fields: pf,
ap_content: w.ap_content,
wrapup_stats: w.wrapup_stats,
wrapup_repo: w.wrapup_repo,
goal_command: w.goal_command,
goal_query: w.goal_query,
user_settings: w.user_settings,
federation_settings: w.federation_settings,
remote_goal: w.remote_goal,
refresh_session: Arc::new(postgres::PostgresRefreshSessionAdapter::new(
w.pool.clone(),
)) as _,
db_pool: DbPool::Postgres(w.pool),
})
}
#[cfg(feature = "sqlite")]
_ => {
let w = sqlite::wire(url)
.await
.context("SQLite connection failed")?;
let (pc, pq) = sqlite::create_person_adapter(w.pool.clone());
let (sc, sp) = sqlite_search::create_search_adapter(w.pool.clone());
let pf = sqlite::create_profile_fields_repo(w.pool.clone());
let we = Arc::new(sqlite::SqliteWatchEventRepository::new(w.pool.clone()));
let wt: Arc<dyn WebhookTokenRepository> =
Arc::new(sqlite::SqliteWebhookTokenRepository::new(w.pool.clone()));
Ok(DatabaseOutput {
movie_command: w.movie_command,
movie_query: w.movie_query,
review: w.review,
diary: w.diary,
stats: w.stats,
user: w.user,
import_session: w.import_session,
import_profile: w.import_profile,
movie_profile: w.movie_profile,
watchlist: w.watchlist,
watch_event_command: we.clone() as _,
watch_event_query: we as _,
webhook_token: wt,
person_command: pc,
person_query: pq,
search_port: sp,
search_command: sc,
profile_fields: pf,
ap_content: w.ap_content,
wrapup_stats: w.wrapup_stats,
wrapup_repo: w.wrapup_repo,
goal_command: w.goal_command,
goal_query: w.goal_query,
user_settings: w.user_settings,
federation_settings: w.federation_settings,
remote_goal: w.remote_goal,
refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone()))
as _,
db_pool: DbPool::Sqlite(w.pool),
})
}
#[cfg(not(feature = "sqlite"))]
_ => anyhow::bail!(
"DATABASE_BACKEND={backend} is not supported by this build (enable sqlite or postgres feature)"
),
}
}
pub fn build_auth_adapters() -> anyhow::Result<(Arc<dyn AuthService>, Arc<dyn PasswordHasher>)> {
auth::create()
}
pub fn build_metadata_client() -> anyhow::Result<Arc<dyn MetadataClient>> {
metadata::create()
}
pub fn build_poster_fetcher() -> anyhow::Result<Arc<dyn PosterFetcherClient>> {
poster_fetcher::create()
}
pub fn build_object_storage() -> anyhow::Result<Arc<dyn ObjectStorage>> {
object_storage::create()
}
pub fn build_profile_fields_repo(
pool: &DbPool,
) -> anyhow::Result<Arc<dyn UserProfileFieldsRepository>> {
match pool {
#[cfg(feature = "postgres")]
DbPool::Postgres(pool) => Ok(postgres::create_profile_fields_repo(pool.clone())),
#[cfg(feature = "sqlite")]
DbPool::Sqlite(pool) => Ok(sqlite::create_profile_fields_repo(pool.clone())),
#[cfg(not(feature = "sqlite"))]
_ => anyhow::bail!("no profile fields repo for this backend"),
}
}

View File

@@ -7,11 +7,7 @@ use axum::{
use chrono::Utc;
use application::auth::{
commands::RegisterCommand,
deps::{LoginDeps, RefreshDeps, RegisterAndLoginDeps, RegisterDeps},
login as login_uc,
queries::LoginCommand,
register as register_uc,
commands::RegisterCommand, login as login_uc, queries::LoginCommand, register as register_uc,
};
use crate::{
@@ -56,15 +52,8 @@ pub async fn login(
State(state): State<AppState>,
Json(req): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, ApiError> {
let deps = LoginDeps {
user: state.app_ctx.repos.user.clone(),
password_hasher: state.app_ctx.services.password_hasher.clone(),
auth: state.app_ctx.services.auth.clone(),
refresh_session: state.app_ctx.repos.refresh_session.clone(),
config: state.app_ctx.config.clone(),
};
let result = login_uc::execute(
&deps,
&state.app_ctx.deps.auth.login,
LoginCommand {
email: req.email,
password: req.password,
@@ -93,13 +82,8 @@ pub async fn register(
State(state): State<AppState>,
Json(req): Json<RegisterRequest>,
) -> Result<StatusCode, ApiError> {
let deps = RegisterDeps {
user: state.app_ctx.repos.user.clone(),
password_hasher: state.app_ctx.services.password_hasher.clone(),
config: state.app_ctx.config.clone(),
};
register_uc::execute(
&deps,
&state.app_ctx.deps.auth.register,
RegisterCommand {
email: req.email,
username: req.username,
@@ -123,12 +107,9 @@ pub async fn refresh(
State(state): State<AppState>,
Json(req): Json<RefreshRequest>,
) -> Result<Json<RefreshResponse>, ApiError> {
let deps = RefreshDeps {
refresh_session: state.app_ctx.repos.refresh_session.clone(),
auth: state.app_ctx.services.auth.clone(),
config: state.app_ctx.config.clone(),
};
let result = application::auth::refresh::execute(&deps, &req.refresh_token).await?;
let result =
application::auth::refresh::execute(&state.app_ctx.deps.auth.refresh, &req.refresh_token)
.await?;
Ok(Json(RefreshResponse {
token: result.token,
refresh_token: result.refresh_token,
@@ -147,11 +128,8 @@ pub async fn api_logout(
State(state): State<AppState>,
Json(req): Json<LogoutRequest>,
) -> StatusCode {
let _ = application::auth::logout::execute(
state.app_ctx.repos.refresh_session.clone(),
&req.refresh_token,
)
.await;
let _ = application::auth::logout::execute(&state.app_ctx.deps.auth.logout, &req.refresh_token)
.await;
StatusCode::NO_CONTENT
}
@@ -172,6 +150,7 @@ pub async fn get_login_page(
canonical_url: format!("{}/login", state.app_ctx.config.base_url),
csrf_token: csrf.0,
page_rss_url: None,
pending_follow_count: 0,
};
render_page(LoginTemplate {
ctx: &ctx,
@@ -187,15 +166,8 @@ pub async fn post_login(
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let deps = LoginDeps {
user: state.app_ctx.repos.user.clone(),
password_hasher: state.app_ctx.services.password_hasher.clone(),
auth: state.app_ctx.services.auth.clone(),
refresh_session: state.app_ctx.repos.refresh_session.clone(),
config: state.app_ctx.config.clone(),
};
match login_uc::execute(
&deps,
&state.app_ctx.deps.auth.login,
LoginCommand {
email: form.email,
password: form.password,
@@ -242,6 +214,7 @@ pub async fn get_register_page(
canonical_url: format!("{}/register", state.app_ctx.config.base_url),
csrf_token: csrf.0,
page_rss_url: None,
pending_follow_count: 0,
};
render_page(RegisterTemplate {
ctx: &ctx,
@@ -261,15 +234,8 @@ pub async fn post_register(
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let deps = RegisterAndLoginDeps {
user: state.app_ctx.repos.user.clone(),
password_hasher: state.app_ctx.services.password_hasher.clone(),
auth: state.app_ctx.services.auth.clone(),
refresh_session: state.app_ctx.repos.refresh_session.clone(),
config: state.app_ctx.config.clone(),
};
match application::auth::register_and_login::execute(
&deps,
&state.app_ctx.deps.auth.register_and_login,
application::auth::commands::RegisterAndLoginCommand {
email: form.email,
username: form.username,

View File

@@ -8,9 +8,7 @@ use uuid::Uuid;
use application::diary::{
commands::{DeleteReviewCommand, EditReviewCommand},
delete_review,
deps::{DeleteReviewDeps, EditReviewDeps, GetActivityFeedDeps},
edit_review, get_activity_feed as get_feed_uc, get_diary, log_review,
delete_review, edit_review, get_activity_feed as get_feed_uc, get_diary, log_review,
queries::GetActivityFeedQuery,
};
@@ -45,7 +43,8 @@ pub async fn get_diary(
State(state): State<AppState>,
Query(params): Query<DiaryQueryParams>,
) -> Result<Json<DiaryResponse>, ApiError> {
let page = get_diary::execute(&state.app_ctx.repos.diary, to_diary_query(params)).await?;
let page =
get_diary::execute(&state.app_ctx.deps.diary.get_diary, to_diary_query(params)).await?;
Ok(Json(DiaryResponse {
items: page
@@ -103,13 +102,7 @@ pub async fn delete_review(
review_id,
requesting_user_id: user_id.value(),
};
let deps = DeleteReviewDeps {
review: state.app_ctx.repos.review.clone(),
diary: state.app_ctx.repos.diary.clone(),
movie_command: state.app_ctx.repos.movie_command.clone(),
event_publisher: state.app_ctx.services.event_publisher.clone(),
};
delete_review::execute(&deps, cmd).await?;
delete_review::execute(&state.app_ctx.deps.diary.delete_review, cmd).await?;
Ok(StatusCode::NO_CONTENT)
}
@@ -145,11 +138,7 @@ pub async fn patch_review(
watched_at,
watch_medium: req.watch_medium,
};
let deps = EditReviewDeps {
review: state.app_ctx.repos.review.clone(),
event_publisher: state.app_ctx.services.event_publisher.clone(),
};
edit_review::execute(&deps, cmd).await?;
edit_review::execute(&state.app_ctx.deps.diary.edit_review, cmd).await?;
Ok(StatusCode::OK)
}
@@ -180,13 +169,8 @@ pub async fn get_activity_feed(
State(state): State<AppState>,
Query(params): Query<ActivityFeedQueryParams>,
) -> Result<Json<ActivityFeedResponse>, ApiError> {
let deps = GetActivityFeedDeps {
diary: state.app_ctx.repos.diary.clone(),
social_query: state.app_ctx.repos.social_query_unified.clone(),
config: state.app_ctx.config.clone(),
};
let page = get_feed_uc::execute(
&deps,
&state.app_ctx.deps.diary.get_activity_feed,
GetActivityFeedQuery {
limit: params.limit.unwrap_or(20),
offset: params.offset.unwrap_or(0),
@@ -274,13 +258,7 @@ pub async fn post_delete_review_html(
review_id,
requesting_user_id: user_id.value(),
};
let deps = DeleteReviewDeps {
review: state.app_ctx.repos.review.clone(),
diary: state.app_ctx.repos.diary.clone(),
movie_command: state.app_ctx.repos.movie_command.clone(),
event_publisher: state.app_ctx.services.event_publisher.clone(),
};
match delete_review::execute(&deps, cmd).await {
match delete_review::execute(&state.app_ctx.deps.diary.delete_review, cmd).await {
Ok(()) => {
let redirect_url = form
.redirect_after
@@ -335,13 +313,12 @@ pub async fn get_activity_feed_html(
filter_following,
};
let deps = GetActivityFeedDeps {
diary: state.app_ctx.repos.diary.clone(),
social_query: state.app_ctx.repos.social_query_unified.clone(),
config: state.app_ctx.config.clone(),
};
match application::diary::get_activity_feed::execute(&deps, query).await {
match application::diary::get_activity_feed::execute(
&state.app_ctx.deps.diary.get_activity_feed,
query,
)
.await
{
Ok(entries) => {
let entry_limit = entries.limit;
let entry_offset = entries.offset;

View File

@@ -10,8 +10,6 @@ use api_types::{
CreateGoalRequest, GoalDto, GoalsResponse, UpdateGoalRequest, UpdateUserSettingsRequest,
UserSettingsDto,
};
use application::goals::deps::{GoalCommandDeps, GoalQueryDeps};
// ── Shared mapper ────────────────────────────────────────────────────────────
pub fn goal_with_progress_to_dto(g: &domain::models::GoalWithProgress) -> GoalDto {
@@ -39,12 +37,8 @@ pub async fn list_goals(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<GoalsResponse>, ApiError> {
let deps = GoalQueryDeps {
goal_query: state.app_ctx.repos.goal_query.clone(),
stats: state.app_ctx.repos.stats.clone(),
};
let goals = application::goals::list::execute(
&deps,
&state.app_ctx.deps.goals.query,
application::goals::queries::ListGoalsQuery {
user_id: user.0.value(),
},
@@ -69,14 +63,8 @@ pub async fn create_goal(
user: AuthenticatedUser,
Json(req): Json<CreateGoalRequest>,
) -> Result<Json<GoalDto>, ApiError> {
let deps = GoalCommandDeps {
goal_command: state.app_ctx.repos.goal_command.clone(),
goal_query: state.app_ctx.repos.goal_query.clone(),
stats: state.app_ctx.repos.stats.clone(),
event_publisher: state.app_ctx.services.event_publisher.clone(),
};
let g = application::goals::create::execute(
&deps,
&state.app_ctx.deps.goals.command,
application::goals::commands::CreateGoalCommand {
user_id: user.0.value(),
year: req.year,
@@ -103,14 +91,8 @@ pub async fn update_goal(
Path(year): Path<u16>,
Json(req): Json<UpdateGoalRequest>,
) -> Result<Json<GoalDto>, ApiError> {
let deps = GoalCommandDeps {
goal_command: state.app_ctx.repos.goal_command.clone(),
goal_query: state.app_ctx.repos.goal_query.clone(),
stats: state.app_ctx.repos.stats.clone(),
event_publisher: state.app_ctx.services.event_publisher.clone(),
};
let g = application::goals::update::execute(
&deps,
&state.app_ctx.deps.goals.command,
application::goals::commands::UpdateGoalCommand {
user_id: user.0.value(),
year,
@@ -135,14 +117,8 @@ pub async fn delete_goal(
user: AuthenticatedUser,
Path(year): Path<u16>,
) -> Result<StatusCode, ApiError> {
let deps = GoalCommandDeps {
goal_command: state.app_ctx.repos.goal_command.clone(),
goal_query: state.app_ctx.repos.goal_query.clone(),
stats: state.app_ctx.repos.stats.clone(),
event_publisher: state.app_ctx.services.event_publisher.clone(),
};
application::goals::delete::execute(
&deps,
&state.app_ctx.deps.goals.command,
application::goals::commands::DeleteGoalCommand {
user_id: user.0.value(),
year,
@@ -165,12 +141,8 @@ pub async fn get_user_goals(
AuthenticatedUser(_viewer): AuthenticatedUser,
Path(user_id): Path<Uuid>,
) -> Result<Json<GoalsResponse>, ApiError> {
let deps = GoalQueryDeps {
goal_query: state.app_ctx.repos.goal_query.clone(),
stats: state.app_ctx.repos.stats.clone(),
};
let goals = application::goals::list::execute(
&deps,
&state.app_ctx.deps.goals.query,
application::goals::queries::ListGoalsQuery { user_id },
)
.await?;
@@ -194,7 +166,7 @@ pub async fn get_settings(
user: AuthenticatedUser,
) -> Result<Json<UserSettingsDto>, ApiError> {
let settings = application::users::get_settings::execute(
state.app_ctx.repos.user_settings.clone(),
&state.app_ctx.deps.users.get_settings,
user.0.value(),
)
.await?;
@@ -220,7 +192,7 @@ pub async fn update_settings(
Json(req): Json<UpdateUserSettingsRequest>,
) -> Result<StatusCode, ApiError> {
application::users::update_settings::execute(
state.app_ctx.repos.user_settings.clone(),
&state.app_ctx.deps.users.update_settings,
application::users::update_settings::UpdateUserSettingsCommand {
user_id: user.0.value(),
federate_goals: req.federate_goals,

View File

@@ -29,11 +29,7 @@ pub(crate) fn build_export_response(
ExportFormat::Json => ("application/json", "diary.json"),
};
let query = ExportQuery { user_id, format };
let stream = export_diary_uc::execute(
&state.app_ctx.repos.diary,
&state.app_ctx.services.diary_exporter,
query,
);
let stream = export_diary_uc::execute(&state.app_ctx.deps.diary.export_diary, query);
let stream = stream.map(|r| {
if let Err(ref e) = r {
tracing::error!("diary export stream error: {e}");
@@ -146,16 +142,25 @@ pub(crate) async fn build_page_context(
csrf_token: String,
) -> HtmlPageContext {
let uuid = user_id.as_ref().map(|u| u.value());
let (user_email, is_admin) = if let Some(ref id) = user_id {
let user = state.app_ctx.repos.user.find_by_id(id).await.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);
(email, admin)
// Page chrome (email/admin badge, pending-follow badge) degrades rather than
// blanking the page: a failed lookup or count logs a warning and falls back
// to a zeroed `PageViewer`, same as the previous silent `.unwrap_or(0)` — now
// logged and explicit, per the error policy in `get_page_viewer`'s doc comment.
let (user_email, is_admin, pending_follow_count) = if let Some(ref id) = user_id {
match application::users::get_page_viewer::execute(
&state.app_ctx.deps.users.get_page_viewer,
id.value(),
)
.await
{
Ok(viewer) => (viewer.email, viewer.is_admin, viewer.pending_follow_count),
Err(e) => {
tracing::warn!("get_page_viewer failed for {}: {e}", id.value());
(None, false, 0)
}
}
} else {
(None, false)
(None, false, 0)
};
HtmlPageContext {
user_email,
@@ -167,5 +172,6 @@ pub(crate) async fn build_page_context(
canonical_url: state.app_ctx.config.base_url.clone(),
csrf_token,
page_rss_url: None,
pending_follow_count,
}
}

View File

@@ -13,17 +13,14 @@ use std::collections::HashMap;
use crate::render::render_page;
use application::import::{
apply_mapping as apply_import_mapping, apply_profile as apply_import_profile,
apply_mapping as apply_import_mapping, apply_profile_and_map,
commands::{
ApplyImportMappingCommand, ApplyImportProfileCommand, CreateImportSessionCommand,
ApplyImportMappingCommand, ApplyProfileAndMapCommand, CreateImportSessionCommand,
DeleteImportProfileCommand, ExecuteImportCommand, SaveImportProfileCommand,
},
create_session as create_import_session, delete_profile as delete_import_profile,
deps::{
ApplyMappingDeps, ApplyProfileDeps, CreateSessionDeps, ExecuteImportDeps, SaveProfileDeps,
},
execute as execute_import, list_profiles as list_import_profiles,
save_profile as save_import_profile,
execute as execute_import, get_mapping_stage, get_preview_stage, get_session_state,
list_profiles as list_import_profiles, save_profile as save_import_profile,
};
use domain::errors::DomainError;
use domain::models::{
@@ -111,7 +108,7 @@ pub async fn get_import_page(
) -> impl IntoResponse {
let ctx = super::helpers::build_page_context(&state, Some(user_id.clone()), csrf.0).await;
let profiles =
list_import_profiles::execute(state.app_ctx.repos.import_profile.clone(), &user_id)
list_import_profiles::execute(&state.app_ctx.deps.import.list_profiles, &user_id)
.await
.unwrap_or_default()
.into_iter()
@@ -163,10 +160,7 @@ pub async fn post_upload(
};
match create_import_session::execute(
&CreateSessionDeps {
import_session: state.app_ctx.repos.import_session.clone(),
document_parser: state.app_ctx.services.document_parser.clone(),
},
&state.app_ctx.deps.import.create_session,
CreateImportSessionCommand {
user_id: user_id.value(),
bytes,
@@ -194,21 +188,17 @@ pub async fn get_mapping_page(
else {
return Redirect::to("/import").into_response();
};
let Ok(Some(session)) = state
.app_ctx
.repos
.import_session
.get(&session_id, &user_id)
.await
let Ok(stage) = get_mapping_stage::execute(
&state.app_ctx.deps.import.get_mapping_stage,
session_id,
user_id.value(),
)
.await
else {
return Redirect::to("/import").into_response();
};
let Some(parsed) = session.parsed_file else {
return Redirect::to("/import").into_response();
};
let ctx = super::helpers::build_page_context(&state, Some(user_id), csrf.0).await;
let sample_rows: Vec<Vec<String>> = parsed.rows.into_iter().take(5).collect();
let domain_fields: Vec<(&str, &str)> = vec![
("title", "Title"),
("release_year", "Release Year"),
@@ -221,8 +211,8 @@ pub async fn get_mapping_page(
render_page(ImportMappingTemplate {
ctx: &ctx,
session_id: &session_id_str,
columns: &parsed.columns,
sample_rows: &sample_rows,
columns: &stage.columns,
sample_rows: &stage.sample_rows,
domain_fields: &domain_fields,
error: None,
})
@@ -255,11 +245,7 @@ pub async fn post_mapping(
.into_response();
}
match apply_import_mapping::execute(
&ApplyMappingDeps {
import_session: state.app_ctx.repos.import_session.clone(),
document_parser: state.app_ctx.services.document_parser.clone(),
movie_query: state.app_ctx.repos.movie_query.clone(),
},
&state.app_ctx.deps.import.apply_mapping,
ApplyImportMappingCommand {
user_id: user_id.value(),
session_id: session_id.value(),
@@ -290,24 +276,25 @@ pub async fn get_preview_page(
else {
return Redirect::to("/import").into_response();
};
let Ok(Some(session)) = state
.app_ctx
.repos
.import_session
.get(&session_id, &user_id)
.await
let Ok(stage) = get_preview_stage::execute(
&state.app_ctx.deps.import.get_preview_stage,
session_id,
user_id.value(),
)
.await
else {
return Redirect::to("/import").into_response();
};
if session.row_results.is_none() {
return Redirect::to(&format!("/import/{}/mapping", session_id_str)).into_response();
}
let preview = match stage {
get_preview_stage::PreviewStage::Ready(preview) => preview,
get_preview_stage::PreviewStage::NotYetMapped => {
return Redirect::to(&format!("/import/{}/mapping", session_id_str)).into_response();
}
};
let parsed = session.parsed_file.unwrap_or_default();
let annotated: Vec<AnnotatedRow> = session.row_results.unwrap_or_default();
let rows: Vec<ImportPreviewRow> = annotated
let rows: Vec<ImportPreviewRow> = preview
.rows
.iter()
.enumerate()
.map(|(i, a)| annotated_to_preview_row(i, a))
@@ -317,7 +304,7 @@ pub async fn get_preview_page(
render_page(ImportPreviewTemplate {
ctx: &ctx,
session_id: &session_id_str,
columns: &parsed.columns,
columns: &preview.columns,
rows: &rows,
})
.into_response()
@@ -353,10 +340,7 @@ pub async fn post_confirm(
.filter(|n| !n.trim().is_empty());
if let Some(name) = profile_name {
let _ = save_import_profile::execute(
&SaveProfileDeps {
import_session: state.app_ctx.repos.import_session.clone(),
import_profile: state.app_ctx.repos.import_profile.clone(),
},
&state.app_ctx.deps.import.save_profile,
SaveImportProfileCommand {
user_id: user_id.value(),
session_id: session_id.value(),
@@ -374,10 +358,7 @@ pub async fn post_confirm(
.collect();
match execute_import::execute(
&ExecuteImportDeps {
import_session: state.app_ctx.repos.import_session.clone(),
review_logger: state.app_ctx.services.review_logger.clone(),
},
&state.app_ctx.deps.import.execute_import,
ExecuteImportCommand {
user_id: user_id.value(),
session_id: session_id.value(),
@@ -412,7 +393,7 @@ pub async fn post_delete_profile(
}
if let Ok(profile_id) = profile_id_str.parse::<uuid::Uuid>() {
let _ = delete_import_profile::execute(
state.app_ctx.repos.import_profile.clone(),
&state.app_ctx.deps.import.delete_profile,
DeleteImportProfileCommand {
user_id: user_id.value(),
profile_id,
@@ -498,10 +479,7 @@ pub async fn api_post_session(
_ => FileFormat::Csv,
};
let r = create_import_session::execute(
&CreateSessionDeps {
import_session: state.app_ctx.repos.import_session.clone(),
document_parser: state.app_ctx.services.document_parser.clone(),
},
&state.app_ctx.deps.import.create_session,
CreateImportSessionCommand {
user_id: user_id.value(),
bytes,
@@ -535,20 +513,17 @@ pub async fn api_get_session(
.parse::<uuid::Uuid>()
.map(ImportSessionId::from_uuid)
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
let session = state
.app_ctx
.repos
.import_session
.get(&session_id, &user_id)
.await?
.ok_or(DomainError::NotFound("session not found".into()))?;
let parsed = session.parsed_file.unwrap_or_default();
let row_count = parsed.rows.len();
let state_data = get_session_state::execute(
&state.app_ctx.deps.import.get_session_state,
session_id,
user_id.value(),
)
.await?;
Ok(axum::Json(SessionStateResponse {
session_id: session_id_str,
columns: parsed.columns,
has_mappings: session.field_mappings.is_some(),
row_count,
columns: state_data.columns,
has_mappings: state_data.has_mappings,
row_count: state_data.row_count,
}))
}
@@ -596,11 +571,7 @@ pub async fn api_put_mapping(
.collect();
let rows = apply_import_mapping::execute(
&ApplyMappingDeps {
import_session: state.app_ctx.repos.import_session.clone(),
document_parser: state.app_ctx.services.document_parser.clone(),
movie_query: state.app_ctx.repos.movie_query.clone(),
},
&state.app_ctx.deps.import.apply_mapping,
ApplyImportMappingCommand {
user_id: user_id.value(),
session_id: session_id.value(),
@@ -621,15 +592,21 @@ pub async fn api_get_preview(
.map(ImportSessionId::from_uuid)
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
let session = state
.app_ctx
.repos
.import_session
.get(&session_id, &user_id)
.await?
.ok_or(DomainError::NotFound("session not found".into()))?;
let stage = get_preview_stage::execute(
&state.app_ctx.deps.import.get_preview_stage,
session_id,
user_id.value(),
)
.await?;
let annotated: Vec<AnnotatedRow> = session.row_results.unwrap_or_default();
// Not-yet-mapped sessions render as an empty preview here (200, `rows: []`),
// matching this endpoint's behavior before the stage-gate moved into
// `get_preview_stage` — unlike the HTML handler, this API route never
// redirected on the same condition, so its response shape is unchanged.
let annotated: Vec<AnnotatedRow> = match stage {
get_preview_stage::PreviewStage::Ready(preview) => preview.rows,
get_preview_stage::PreviewStage::NotYetMapped => Vec::new(),
};
let rows = annotated
.iter()
.enumerate()
@@ -689,10 +666,7 @@ pub async fn api_post_confirm(
.map(ImportSessionId::from_uuid)
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
let s = execute_import::execute(
&ExecuteImportDeps {
import_session: state.app_ctx.repos.import_session.clone(),
review_logger: state.app_ctx.services.review_logger.clone(),
},
&state.app_ctx.deps.import.execute_import,
ExecuteImportCommand {
user_id: user_id.value(),
session_id: session_id.value(),
@@ -720,7 +694,7 @@ pub async fn api_get_profiles(
AuthenticatedUser(user_id): AuthenticatedUser,
) -> Result<impl IntoResponse, ApiError> {
let profiles =
list_import_profiles::execute(state.app_ctx.repos.import_profile.clone(), &user_id).await?;
list_import_profiles::execute(&state.app_ctx.deps.import.list_profiles, &user_id).await?;
Ok(axum::Json(
profiles
.into_iter()
@@ -756,10 +730,7 @@ pub async fn api_post_profile(
.map(ImportSessionId::from_uuid)
.map_err(|_| DomainError::ValidationError("invalid session id".into()))?;
let id = save_import_profile::execute(
&SaveProfileDeps {
import_session: state.app_ctx.repos.import_session.clone(),
import_profile: state.app_ctx.repos.import_profile.clone(),
},
&state.app_ctx.deps.import.save_profile,
SaveImportProfileCommand {
user_id: user_id.value(),
session_id: session_id.value(),
@@ -791,7 +762,7 @@ pub async fn api_delete_profile(
.parse::<uuid::Uuid>()
.map_err(|_| DomainError::ValidationError("invalid profile id".into()))?;
delete_import_profile::execute(
state.app_ctx.repos.import_profile.clone(),
&state.app_ctx.deps.import.delete_profile,
DeleteImportProfileCommand {
user_id: user_id.value(),
profile_id,
@@ -828,42 +799,14 @@ pub async fn api_apply_profile(
.parse::<uuid::Uuid>()
.map_err(|_| DomainError::ValidationError("invalid profile id".into()))?;
apply_import_profile::execute(
&ApplyProfileDeps {
import_profile: state.app_ctx.repos.import_profile.clone(),
import_session: state.app_ctx.repos.import_session.clone(),
},
ApplyImportProfileCommand {
let rows = apply_profile_and_map::execute(
&state.app_ctx.deps.import.apply_profile_and_map,
ApplyProfileAndMapCommand {
user_id: user_id.value(),
session_id,
profile_id,
},
)
.await?;
let session = state
.app_ctx
.repos
.import_session
.get(&ImportSessionId::from_uuid(session_id), &user_id)
.await?
.ok_or(DomainError::NotFound(
"session not found after profile apply".into(),
))?;
let mappings = session.field_mappings.unwrap_or_default();
let rows = apply_import_mapping::execute(
&ApplyMappingDeps {
import_session: state.app_ctx.repos.import_session.clone(),
document_parser: state.app_ctx.services.document_parser.clone(),
movie_query: state.app_ctx.repos.movie_query.clone(),
},
ApplyImportMappingCommand {
user_id: user_id.value(),
session_id,
mappings,
},
)
.await?;
Ok(axum::Json(serde_json::json!({"row_count": rows.len()})))
}

View File

@@ -41,9 +41,10 @@ pub async fn get_integrations_page(
let query = GetWebhookTokensQuery {
user_id: user_id.value(),
};
let tokens = get_webhook_tokens::execute(state.app_ctx.repos.webhook_token.clone(), query)
.await
.unwrap_or_default();
let tokens =
get_webhook_tokens::execute(&state.app_ctx.deps.integrations.get_webhook_tokens, query)
.await
.unwrap_or_default();
let token_views: Vec<template_askama::WebhookTokenView> = tokens
.iter()
@@ -81,7 +82,12 @@ pub async fn post_generate_token(
label: form.label.filter(|l| !l.trim().is_empty()),
};
match generate_webhook_token::execute(state.app_ctx.repos.webhook_token.clone(), cmd).await {
match generate_webhook_token::execute(
&state.app_ctx.deps.integrations.generate_webhook_token,
cmd,
)
.await
{
Ok(result) => {
let encoded = percent_encoding::utf8_percent_encode(
&result.token_plaintext,
@@ -112,7 +118,8 @@ pub async fn post_revoke_token(
token_id,
};
if let Err(e) =
revoke_webhook_token::execute(state.app_ctx.repos.webhook_token.clone(), cmd).await
revoke_webhook_token::execute(&state.app_ctx.deps.integrations.revoke_webhook_token, cmd)
.await
{
tracing::error!("revoke token failed: {:?}", e);
}
@@ -135,7 +142,7 @@ pub async fn get_watch_queue_page(
let query = GetWatchQueueQuery {
user_id: user_id.value(),
};
let events = get_watch_queue::execute(state.app_ctx.repos.watch_event_query.clone(), query)
let events = get_watch_queue::execute(&state.app_ctx.deps.integrations.get_watch_queue, query)
.await
.unwrap_or_default();
@@ -172,13 +179,8 @@ pub async fn post_confirm_single(
}],
};
match confirm_watch_events::execute(
state.app_ctx.repos.watch_event_command.clone(),
state.app_ctx.repos.watch_event_query.clone(),
state.app_ctx.services.review_logger.clone(),
cmd,
)
.await
match confirm_watch_events::execute(&state.app_ctx.deps.integrations.confirm_watch_events, cmd)
.await
{
Ok(_) => Redirect::to("/watch-queue").into_response(),
Err(e) => {
@@ -204,12 +206,8 @@ pub async fn post_dismiss_single(
event_ids: vec![event_id],
};
match dismiss_watch_events::execute(
state.app_ctx.repos.watch_event_command.clone(),
state.app_ctx.repos.watch_event_query.clone(),
cmd,
)
.await
match dismiss_watch_events::execute(&state.app_ctx.deps.integrations.dismiss_watch_events, cmd)
.await
{
Ok(_) => Redirect::to("/watch-queue").into_response(),
Err(e) => {

View File

@@ -8,7 +8,6 @@ pub mod integrations;
pub mod movies;
pub mod rss;
pub mod search;
#[cfg(feature = "federation")]
pub mod social;
pub mod users;
pub mod watchlist;

View File

@@ -9,11 +9,10 @@ use uuid::Uuid;
use application::{
diary::{
commands::SyncPosterCommand,
deps::GetMovieSocialPageDeps,
get_movie_social_page, get_review_history,
queries::{GetMovieSocialPageQuery, GetReviewHistoryQuery},
},
movies::{deps::SyncPosterDeps, get_movies, queries::GetMoviesQuery, sync_poster},
movies::{get_movies, queries::GetMoviesQuery, sync_poster},
watchlist::{is_on as is_on_watchlist, queries::IsOnWatchlistQuery},
};
use domain::services::review_history::Trend;
@@ -48,7 +47,7 @@ pub async fn list_movies(
Query(params): Query<MoviesQueryParams>,
) -> Result<Json<MoviesResponse>, ApiError> {
let page = get_movies::execute(
state.app_ctx.repos.movie_query.clone(),
&state.app_ctx.deps.movies.get_movies,
GetMoviesQuery {
limit: params.limit,
offset: params.offset,
@@ -84,7 +83,7 @@ pub async fn get_review_history(
Path(movie_id): Path<Uuid>,
) -> Result<Json<ReviewHistoryResponse>, ApiError> {
let (history, trend) = get_review_history::execute(
&state.app_ctx.repos.diary,
&state.app_ctx.deps.diary.get_review_history,
GetReviewHistoryQuery { movie_id },
)
.await?;
@@ -121,16 +120,7 @@ pub async fn sync_poster(
Path(movie_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
sync_poster::execute(
&SyncPosterDeps {
movie_command: state.app_ctx.repos.movie_command.clone(),
movie_query: state.app_ctx.repos.movie_query.clone(),
movie_profile: state.app_ctx.repos.movie_profile.clone(),
metadata: state.app_ctx.services.metadata.clone(),
poster_fetcher: state.app_ctx.services.poster_fetcher.clone(),
object_storage: state.app_ctx.services.object_storage.clone(),
event_publisher: state.app_ctx.services.event_publisher.clone(),
search_command: state.app_ctx.repos.search_command.clone(),
},
&state.app_ctx.deps.movies.sync_poster,
SyncPosterCommand { movie_id },
)
.await?;
@@ -154,11 +144,7 @@ pub async fn get_movie_detail(
let offset = params.offset.unwrap_or(0);
let result = get_movie_social_page::execute(
&GetMovieSocialPageDeps {
movie_query: state.app_ctx.repos.movie_query.clone(),
diary: state.app_ctx.repos.diary.clone(),
movie_profile: state.app_ctx.repos.movie_profile.clone(),
},
&state.app_ctx.deps.diary.get_movie_social_page,
GetMovieSocialPageQuery {
movie_id,
limit,
@@ -210,7 +196,7 @@ pub async fn get_movie_profile(
) -> impl IntoResponse {
use application::movies::get_movie_profile;
let query = get_movie_profile::GetMovieProfileQuery { movie_id };
match get_movie_profile::execute(state.app_ctx.repos.movie_profile.clone(), query).await {
match get_movie_profile::execute(&state.app_ctx.deps.movies.get_movie_profile, query).await {
Ok(Some(result)) => {
let p = result.profile;
Json(MovieProfileResponse {
@@ -288,11 +274,7 @@ pub async fn get_movie_detail_html(
let offset = params.offset.unwrap_or(0);
match get_movie_social_page::execute(
&GetMovieSocialPageDeps {
movie_query: state.app_ctx.repos.movie_query.clone(),
diary: state.app_ctx.repos.diary.clone(),
movie_profile: state.app_ctx.repos.movie_profile.clone(),
},
&state.app_ctx.deps.diary.get_movie_social_page,
GetMovieSocialPageQuery {
movie_id,
limit,
@@ -314,7 +296,7 @@ pub async fn get_movie_detail_html(
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.repos.watchlist.clone(),
&state.app_ctx.deps.watchlist.is_on_watchlist,
IsOnWatchlistQuery {
user_id: uid.value(),
movie_id,

View File

@@ -5,8 +5,8 @@ use axum::{
};
use uuid::Uuid;
use application::{diary::get_diary, diary::queries::GetDiaryQuery};
use domain::{errors::DomainError, models::ReviewSortBy, value_objects::UserId};
use application::{diary::get_diary, diary::get_user_feed, diary::queries::GetDiaryQuery};
use domain::{errors::DomainError, models::ReviewSortBy};
use crate::{errors::ApiError, state::AppState};
@@ -18,7 +18,7 @@ pub async fn get_feed(State(state): State<AppState>) -> Result<impl IntoResponse
movie_id: None,
user_id: None,
};
let page = get_diary::execute(&state.app_ctx.repos.diary, query).await?;
let page = get_diary::execute(&state.app_ctx.deps.diary.get_diary, query).await?;
let xml = state
.rss_renderer
.render_feed(&page.items, "Movie Diary")
@@ -33,30 +33,18 @@ pub async fn get_user_feed(
State(state): State<AppState>,
Path(user_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
let user = state
.app_ctx
.repos
.user
.find_by_id(&UserId::from_uuid(user_id))
.await
.map_err(ApiError)?
.ok_or_else(|| ApiError(DomainError::NotFound(format!("User {user_id}"))))?;
let feed = get_user_feed::execute(
&state.app_ctx.deps.diary.get_user_feed,
user_id,
super::RSS_FEED_LIMIT,
)
.await?;
let query = GetDiaryQuery {
limit: Some(super::RSS_FEED_LIMIT),
offset: Some(0),
sort_by: Some(ReviewSortBy::Descending),
movie_id: None,
user_id: Some(user_id),
};
let page = get_diary::execute(&state.app_ctx.repos.diary, query).await?;
let display_name = user.email().value().split('@').next().unwrap_or("User");
let title = format!("{}'s Movie Diary", display_name);
let title = format!("{}'s Movie Diary", feed.author.display_name);
let xml = state
.rss_renderer
.render_feed(&page.items, &title)
.render_feed(&feed.entries, &title)
.map_err(|e| ApiError(DomainError::InfrastructureError(e)))?;
Ok((

View File

@@ -5,7 +5,7 @@ use axum::{
};
use application::{
person::{deps::GetPersonDeps, get as get_person, get_credits as get_person_credits},
person::{get as get_person, get_credits as get_person_credits},
search::execute as search_uc,
};
use domain::models::{PersonId, collections::PageParams};
@@ -45,7 +45,7 @@ pub async fn get_search(
},
};
match search_uc::execute(state.app_ctx.repos.search_port.clone(), query).await {
match search_uc::execute(&state.app_ctx.deps.search.execute, query).await {
Ok(results) => axum::Json(SearchResponse {
movies: PaginatedMovieHits {
items: results
@@ -101,11 +101,12 @@ pub async fn get_person_handler(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
) -> impl IntoResponse {
let deps = GetPersonDeps {
person_query: state.app_ctx.repos.person_query.clone(),
event_publisher: state.app_ctx.services.event_publisher.clone(),
};
match get_person::execute(&deps, PersonId::from_uuid(id)).await {
match get_person::execute(
&state.app_ctx.deps.person.get_person,
PersonId::from_uuid(id),
)
.await
{
Ok(Some(person)) => {
axum::Json(crate::mappers::search::person_to_dto(&person)).into_response()
}
@@ -127,11 +128,12 @@ pub async fn get_person_credits_handler(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
) -> impl IntoResponse {
let deps = GetPersonDeps {
person_query: state.app_ctx.repos.person_query.clone(),
event_publisher: state.app_ctx.services.event_publisher.clone(),
};
match get_person_credits::execute(&deps, PersonId::from_uuid(id)).await {
match get_person_credits::execute(
&state.app_ctx.deps.person.get_person,
PersonId::from_uuid(id),
)
.await
{
Ok(credits) => axum::Json(PersonCreditsDto {
person: crate::mappers::search::person_to_dto(&credits.person),
cast: credits

View File

@@ -9,77 +9,58 @@ use uuid::Uuid;
use crate::{
csrf::CsrfToken,
errors::ApiError,
extractors::{AdminApiUser, AuthenticatedUser, RequiredCookieUser},
forms::{
ActorUrlForm, BlockDomainForm, FollowForm, FollowerActionForm, RemoveDomainForm,
UnfollowForm,
},
extractors::{AuthenticatedUser, RequiredCookieUser},
forms::{FollowForm, FollowerActionForm, UnfollowForm},
render::render_page,
state::AppState,
};
#[cfg(feature = "federation")]
use crate::{
extractors::AdminApiUser,
forms::{ActorUrlForm, BlockDomainForm, RemoveDomainForm},
};
use api_types::{
ActorListResponse, ActorUrlRequest, AddBlockedDomainRequest, BlockedActorResponse,
BlockedDomainResponse, FollowRequest, RemoteActorDto,
};
use application::social::deps::{SocialCommandDeps, SocialQueryDeps};
use domain::value_objects::{FollowTarget, SocialActor, SocialIdentity};
use template_askama::{
BlockedActorsTemplate, BlockedDomainsTemplate, FollowersTemplate, FollowingTemplate,
RemoteActorData,
ActorListResponse, ActorUrlRequest, FollowRelationResponse, FollowRequest,
PendingCountResponse, RemoteActorDto,
};
#[cfg(feature = "federation")]
use api_types::{AddBlockedDomainRequest, BlockedActorResponse, BlockedDomainResponse};
use domain::value_objects::{FollowTarget, InstanceIdentity, SocialActor, SocialIdentity};
#[cfg(feature = "federation")]
use template_askama::{BlockedActorsTemplate, BlockedDomainsTemplate};
use template_askama::{FollowersTemplate, FollowingTemplate, RemoteActorData};
use super::helpers::{build_page_context, encode_error};
impl From<&AppState> for SocialCommandDeps {
fn from(state: &AppState) -> Self {
Self {
social_command: state.app_ctx.repos.social_command.clone(),
social_query: state.app_ctx.repos.social_query_unified.clone(),
event_publisher: state.app_ctx.services.event_publisher.clone(),
}
}
}
impl From<&AppState> for SocialQueryDeps {
fn from(state: &AppState) -> Self {
Self {
social_query: state.app_ctx.repos.social_query_unified.clone(),
}
}
}
fn ap_to_domain(e: anyhow::Error) -> domain::errors::DomainError {
tracing::error!("ActivityPub error: {:?}", e);
domain::errors::DomainError::InfrastructureError(e.to_string())
}
fn actor_url(identity: &SocialIdentity) -> String {
match identity {
SocialIdentity::Remote { actor_url } => actor_url.clone(),
SocialIdentity::Local(uid) => format!("local:{}", uid.value()),
}
}
fn social_actor_to_dto(actor: SocialActor) -> RemoteActorDto {
fn social_actor_to_dto(actor: SocialActor, instance: &InstanceIdentity) -> RemoteActorDto {
RemoteActorDto {
url: actor_url(&actor.identity),
handle: actor.handle,
display_name: actor.display_name,
}
}
fn social_actor_to_blocked_dto(actor: SocialActor) -> BlockedActorResponse {
BlockedActorResponse {
url: actor_url(&actor.identity),
url: instance.actor_url_of(&actor.identity),
user_id: match &actor.identity {
SocialIdentity::Local(uid) => Some(uid.value()),
SocialIdentity::Remote { .. } => None,
},
handle: actor.handle,
display_name: actor.display_name,
avatar_url: actor.avatar_url,
}
}
fn social_actor_to_template(actor: SocialActor) -> RemoteActorData {
#[cfg(feature = "federation")]
fn social_actor_to_blocked_dto(
actor: SocialActor,
instance: &InstanceIdentity,
) -> BlockedActorResponse {
BlockedActorResponse {
url: instance.actor_url_of(&actor.identity),
handle: actor.handle,
display_name: actor.display_name,
avatar_url: actor.avatar_url,
}
}
fn social_actor_to_template(actor: SocialActor, instance: &InstanceIdentity) -> RemoteActorData {
RemoteActorData {
url: actor_url(&actor.identity),
url: instance.actor_url_of(&actor.identity),
handle: actor.handle,
display_name: actor.display_name,
avatar_url: actor.avatar_url,
@@ -88,6 +69,7 @@ fn social_actor_to_template(actor: SocialActor) -> RemoteActorData {
// ── API ──────────────────────────────────────────────────────────────────────
#[cfg(feature = "federation")]
#[utoipa::path(
get, path = "/api/v1/admin/blocked-domains",
responses(
@@ -101,13 +83,7 @@ pub async fn get_blocked_domains_admin(
State(state): State<AppState>,
_admin: AdminApiUser,
) -> Result<Json<Vec<BlockedDomainResponse>>, ApiError> {
let domains = state
.app_ctx
.services
.ap_service
.get_blocked_domains()
.await
.map_err(ap_to_domain)?;
let domains = state.app_ctx.ap_blocklist.get_blocked_domains().await?;
Ok(Json(
domains
.into_iter()
@@ -120,6 +96,7 @@ pub async fn get_blocked_domains_admin(
))
}
#[cfg(feature = "federation")]
#[utoipa::path(
post, path = "/api/v1/admin/blocked-domains",
request_body = AddBlockedDomainRequest,
@@ -137,14 +114,13 @@ pub async fn add_blocked_domain_admin(
) -> Result<impl IntoResponse, ApiError> {
state
.app_ctx
.services
.ap_service
.ap_blocklist
.add_blocked_domain(&body.domain, body.reason.as_deref())
.await
.map_err(ap_to_domain)?;
.await?;
Ok(StatusCode::CREATED)
}
#[cfg(feature = "federation")]
#[utoipa::path(
delete, path = "/api/v1/admin/blocked-domains/{domain}",
params(("domain" = String, Path, description = "Domain to unblock")),
@@ -162,14 +138,13 @@ pub async fn remove_blocked_domain_admin(
) -> Result<impl IntoResponse, ApiError> {
state
.app_ctx
.services
.ap_service
.ap_blocklist
.remove_blocked_domain(&domain)
.await
.map_err(ap_to_domain)?;
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[cfg(feature = "federation")]
#[utoipa::path(
post, path = "/api/v1/social/block",
request_body = ActorUrlRequest,
@@ -184,18 +159,19 @@ pub async fn block_actor_api(
user: AuthenticatedUser,
axum::Json(body): axum::Json<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> {
let deps = SocialCommandDeps::from(&state);
let instance = state.app_ctx.instance.clone();
application::social::execute::execute_command(
&deps,
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::Block {
blocker_id: user.0.value(),
target: SocialIdentity::from_actor_url(&body.actor_url, &state.app_ctx.config.base_url),
target: instance.identify(&body.actor_url),
},
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[cfg(feature = "federation")]
#[utoipa::path(
post, path = "/api/v1/social/unblock",
request_body = ActorUrlRequest,
@@ -210,18 +186,19 @@ pub async fn unblock_actor_api(
user: AuthenticatedUser,
axum::Json(body): axum::Json<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> {
let deps = SocialCommandDeps::from(&state);
let instance = state.app_ctx.instance.clone();
application::social::execute::execute_command(
&deps,
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::Unblock {
blocker_id: user.0.value(),
target: SocialIdentity::from_actor_url(&body.actor_url, &state.app_ctx.config.base_url),
target: instance.identify(&body.actor_url),
},
)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[cfg(feature = "federation")]
#[utoipa::path(
get, path = "/api/v1/social/blocked",
responses(
@@ -234,18 +211,14 @@ pub async fn get_blocked_actors_api(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<Vec<BlockedActorResponse>>, ApiError> {
let deps = SocialQueryDeps::from(&state);
let identities = application::social::execute::execute_query(
&deps,
application::social::queries::SocialQry::GetBlocked {
user_id: user.0.value(),
},
)
.await?;
let instance = state.app_ctx.instance.clone();
let identities =
application::social::get_blocked::execute(&state.app_ctx.deps.social.query, user.0.value())
.await?;
Ok(Json(
identities
.into_iter()
.map(social_actor_to_blocked_dto)
.map(|a| social_actor_to_blocked_dto(a, &instance))
.collect(),
))
}
@@ -262,16 +235,17 @@ pub async fn get_following(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<ActorListResponse>, ApiError> {
let deps = SocialQueryDeps::from(&state);
let identities = application::social::execute::execute_query(
&deps,
application::social::queries::SocialQry::GetFollowing {
user_id: user.0.value(),
},
let instance = state.app_ctx.instance.clone();
let identities = application::social::get_following::execute(
&state.app_ctx.deps.social.query,
user.0.value(),
)
.await?;
Ok(Json(ActorListResponse {
actors: identities.into_iter().map(social_actor_to_dto).collect(),
actors: identities
.into_iter()
.map(|a| social_actor_to_dto(a, &instance))
.collect(),
}))
}
@@ -287,16 +261,17 @@ pub async fn get_followers(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<ActorListResponse>, ApiError> {
let deps = SocialQueryDeps::from(&state);
let identities = application::social::execute::execute_query(
&deps,
application::social::queries::SocialQry::GetFollowers {
user_id: user.0.value(),
},
let instance = state.app_ctx.instance.clone();
let identities = application::social::get_followers::execute(
&state.app_ctx.deps.social.query,
user.0.value(),
)
.await?;
Ok(Json(ActorListResponse {
actors: identities.into_iter().map(social_actor_to_dto).collect(),
actors: identities
.into_iter()
.map(|a| social_actor_to_dto(a, &instance))
.collect(),
}))
}
@@ -305,14 +280,15 @@ pub async fn get_user_following(
_user: AuthenticatedUser,
Path(user_id): Path<Uuid>,
) -> Result<Json<ActorListResponse>, ApiError> {
let deps = SocialQueryDeps::from(&state);
let identities = application::social::execute::execute_query(
&deps,
application::social::queries::SocialQry::GetFollowing { user_id },
)
.await?;
let instance = state.app_ctx.instance.clone();
let identities =
application::social::get_following::execute(&state.app_ctx.deps.social.query, user_id)
.await?;
Ok(Json(ActorListResponse {
actors: identities.into_iter().map(social_actor_to_dto).collect(),
actors: identities
.into_iter()
.map(|a| social_actor_to_dto(a, &instance))
.collect(),
}))
}
@@ -321,14 +297,15 @@ pub async fn get_user_followers(
_user: AuthenticatedUser,
Path(user_id): Path<Uuid>,
) -> Result<Json<ActorListResponse>, ApiError> {
let deps = SocialQueryDeps::from(&state);
let identities = application::social::execute::execute_query(
&deps,
application::social::queries::SocialQry::GetFollowers { user_id },
)
.await?;
let instance = state.app_ctx.instance.clone();
let identities =
application::social::get_followers::execute(&state.app_ctx.deps.social.query, user_id)
.await?;
Ok(Json(ActorListResponse {
actors: identities.into_iter().map(social_actor_to_dto).collect(),
actors: identities
.into_iter()
.map(|a| social_actor_to_dto(a, &instance))
.collect(),
}))
}
@@ -346,9 +323,8 @@ pub async fn follow(
user: AuthenticatedUser,
Json(body): Json<FollowRequest>,
) -> Result<impl IntoResponse, ApiError> {
let deps = SocialCommandDeps::from(&state);
application::social::execute::execute_command(
&deps,
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::Follow {
follower_id: user.0.value(),
target: FollowTarget::Handle(body.handle),
@@ -372,12 +348,12 @@ pub async fn unfollow(
user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> {
let deps = SocialCommandDeps::from(&state);
let instance = state.app_ctx.instance.clone();
application::social::execute::execute_command(
&deps,
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::Unfollow {
follower_id: user.0.value(),
target: SocialIdentity::from_actor_url(&body.actor_url, &state.app_ctx.config.base_url),
target: instance.identify(&body.actor_url),
},
)
.await?;
@@ -398,15 +374,12 @@ pub async fn accept_follower(
user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> {
let deps = SocialCommandDeps::from(&state);
let instance = state.app_ctx.instance.clone();
application::social::execute::execute_command(
&deps,
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::AcceptFollow {
owner_id: user.0.value(),
requester: SocialIdentity::from_actor_url(
&body.actor_url,
&state.app_ctx.config.base_url,
),
requester: instance.identify(&body.actor_url),
},
)
.await?;
@@ -427,15 +400,12 @@ pub async fn reject_follower(
user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> {
let deps = SocialCommandDeps::from(&state);
let instance = state.app_ctx.instance.clone();
application::social::execute::execute_command(
&deps,
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::RejectFollow {
owner_id: user.0.value(),
requester: SocialIdentity::from_actor_url(
&body.actor_url,
&state.app_ctx.config.base_url,
),
requester: instance.identify(&body.actor_url),
},
)
.await?;
@@ -456,15 +426,12 @@ pub async fn remove_follower(
user: AuthenticatedUser,
Json(body): Json<ActorUrlRequest>,
) -> Result<impl IntoResponse, ApiError> {
let deps = SocialCommandDeps::from(&state);
let instance = state.app_ctx.instance.clone();
application::social::execute::execute_command(
&deps,
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::RemoveFollower {
owner_id: user.0.value(),
follower: SocialIdentity::from_actor_url(
&body.actor_url,
&state.app_ctx.config.base_url,
),
follower: instance.identify(&body.actor_url),
},
)
.await?;
@@ -483,22 +450,101 @@ pub async fn get_pending_followers(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<ActorListResponse>, ApiError> {
let deps = SocialQueryDeps::from(&state);
let identities = application::social::execute::execute_query(
&deps,
application::social::queries::SocialQry::GetPending {
user_id: user.0.value(),
},
let instance = state.app_ctx.instance.clone();
let identities = application::social::get_pending_followers::execute(
&state.app_ctx.deps.social.query,
user.0.value(),
)
.await?;
Ok(Json(ActorListResponse {
actors: identities.into_iter().map(social_actor_to_dto).collect(),
actors: identities
.into_iter()
.map(|a| social_actor_to_dto(a, &instance))
.collect(),
}))
}
#[utoipa::path(
get, path = "/api/v1/social/followers/pending/count",
responses(
(status = 200, body = PendingCountResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_pending_follower_count(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<PendingCountResponse>, ApiError> {
let count = application::social::count_pending_followers::execute(
&state.app_ctx.deps.social.query,
user.0.value(),
)
.await?;
Ok(Json(PendingCountResponse { count }))
}
#[utoipa::path(
get, path = "/api/v1/social/following/pending",
responses(
(status = 200, body = ActorListResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_pending_following(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> Result<Json<ActorListResponse>, ApiError> {
let instance = state.app_ctx.instance.clone();
let actors = application::social::get_pending_following::execute(
&state.app_ctx.deps.social.query,
user.0.value(),
)
.await?;
Ok(Json(ActorListResponse {
actors: actors
.into_iter()
.map(|a| social_actor_to_dto(a, &instance))
.collect(),
}))
}
#[derive(serde::Deserialize)]
pub struct RelationshipQuery {
pub actor_url: String,
}
#[utoipa::path(
get, path = "/api/v1/social/relationship",
params(("actor_url" = String, Query, description = "Canonical actor URL of the target")),
responses(
(status = 200, body = FollowRelationResponse),
(status = 401, description = "Unauthorized"),
),
security(("bearer_auth" = []))
)]
pub async fn get_relationship(
State(state): State<AppState>,
user: AuthenticatedUser,
Query(q): Query<RelationshipQuery>,
) -> Result<Json<FollowRelationResponse>, ApiError> {
let target = state.app_ctx.instance.identify(&q.actor_url);
let rel = application::social::get_relation::execute(
&state.app_ctx.deps.social.query,
user.0.value(),
target,
)
.await?;
Ok(Json(FollowRelationResponse {
following: rel.following.into(),
followed_by: rel.followed_by.into(),
}))
}
// ── HTML ─────────────────────────────────────────────────────────────────────
pub async fn follow_remote_user(
pub async fn follow_user(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Path(profile_user_uuid): Path<Uuid>,
@@ -518,9 +564,8 @@ pub async fn follow_remote_user(
.unwrap_or(&format!("/users/{}", profile_user_uuid))
.to_string();
let deps = SocialCommandDeps::from(&state);
match application::social::execute::execute_command(
&deps,
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::Follow {
follower_id: user_id.value(),
target: FollowTarget::Handle(form.handle),
@@ -542,7 +587,7 @@ pub async fn follow_remote_user(
}
}
pub async fn unfollow_remote_user(
pub async fn unfollow_user(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
Path(profile_user_uuid): Path<Uuid>,
@@ -555,12 +600,12 @@ pub async fn unfollow_remote_user(
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let deps = SocialCommandDeps::from(&state);
let instance = state.app_ctx.instance.clone();
match application::social::execute::execute_command(
&deps,
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::Unfollow {
follower_id: user_id.value(),
target: SocialIdentity::from_actor_url(&form.actor_url, &state.app_ctx.config.base_url),
target: instance.identify(&form.actor_url),
},
)
.await
@@ -592,15 +637,12 @@ pub async fn accept_follower_html(
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let deps = SocialCommandDeps::from(&state);
let instance = state.app_ctx.instance.clone();
match application::social::execute::execute_command(
&deps,
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::AcceptFollow {
owner_id: user_id.value(),
requester: SocialIdentity::from_actor_url(
&form.actor_url,
&state.app_ctx.config.base_url,
),
requester: instance.identify(&form.actor_url),
},
)
.await
@@ -626,15 +668,12 @@ pub async fn reject_follower_html(
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let deps = SocialCommandDeps::from(&state);
let instance = state.app_ctx.instance.clone();
match application::social::execute::execute_command(
&deps,
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::RejectFollow {
owner_id: user_id.value(),
requester: SocialIdentity::from_actor_url(
&form.actor_url,
&state.app_ctx.config.base_url,
),
requester: instance.identify(&form.actor_url),
},
)
.await
@@ -647,6 +686,7 @@ pub async fn reject_follower_html(
}
}
#[cfg(feature = "federation")]
pub async fn get_followers_collection(
State(state): State<AppState>,
Path(user_id): Path<Uuid>,
@@ -661,8 +701,7 @@ pub async fn get_followers_collection(
let page = params.get("page").and_then(|p| p.parse::<u32>().ok());
return match state
.app_ctx
.services
.ap_service
.ap_document
.followers_collection_json(user_id, page)
.await
{
@@ -674,12 +713,16 @@ pub async fn get_followers_collection(
json,
)
.into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("followers_collection_json error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
};
}
axum::response::Redirect::to(&format!("/users/{}/followers-list", user_id)).into_response()
}
#[cfg(feature = "federation")]
pub async fn get_following_collection(
State(state): State<AppState>,
Path(user_id): Path<Uuid>,
@@ -694,8 +737,7 @@ pub async fn get_following_collection(
let page = params.get("page").and_then(|p| p.parse::<u32>().ok());
return match state
.app_ctx
.services
.ap_service
.ap_document
.following_collection_json(user_id, page)
.await
{
@@ -707,7 +749,10 @@ pub async fn get_following_collection(
json,
)
.into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!("following_collection_json error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
};
}
axum::response::Redirect::to(&format!("/users/{}/following-list", user_id)).into_response()
@@ -729,24 +774,39 @@ pub async fn get_following_page(
"{}/users/{}/following-list",
state.app_ctx.config.base_url, profile_user_uuid
);
let deps = SocialQueryDeps::from(&state);
match application::social::execute::execute_query(
&deps,
application::social::queries::SocialQry::GetFollowing {
user_id: user_id.value(),
},
let instance = state.app_ctx.instance.clone();
match application::social::get_following::execute(
&state.app_ctx.deps.social.query,
user_id.value(),
)
.await
{
Ok(following) => {
let actors: Vec<RemoteActorData> = following
.into_iter()
.map(social_actor_to_template)
.map(|a| social_actor_to_template(a, &instance))
.collect();
let pending_actors: Vec<RemoteActorData> =
match application::social::get_pending_following::execute(
&state.app_ctx.deps.social.query,
user_id.value(),
)
.await
{
Ok(pending) => pending
.into_iter()
.map(|a| social_actor_to_template(a, &instance))
.collect(),
Err(e) => {
tracing::error!("get_pending_following error: {:?}", e);
Vec::new()
}
};
render_page(FollowingTemplate {
ctx,
user_id: profile_user_uuid,
actors,
pending_actors,
error: params.error,
})
.into_response()
@@ -778,19 +838,17 @@ pub async fn get_followers_page(
"{}/users/{}/followers-list",
state.app_ctx.config.base_url, profile_user_uuid
);
let deps = SocialQueryDeps::from(&state);
match application::social::execute::execute_query(
&deps,
application::social::queries::SocialQry::GetFollowers {
user_id: user_id.value(),
},
let instance = state.app_ctx.instance.clone();
match application::social::get_followers::execute(
&state.app_ctx.deps.social.query,
user_id.value(),
)
.await
{
Ok(followers) => {
let actors: Vec<RemoteActorData> = followers
.into_iter()
.map(social_actor_to_template)
.map(|a| social_actor_to_template(a, &instance))
.collect();
render_page(FollowersTemplate {
ctx,
@@ -824,15 +882,12 @@ pub async fn remove_follower_html(
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let deps = SocialCommandDeps::from(&state);
let instance = state.app_ctx.instance.clone();
match application::social::execute::execute_command(
&deps,
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::RemoveFollower {
owner_id: user_id.value(),
follower: SocialIdentity::from_actor_url(
&form.actor_url,
&state.app_ctx.config.base_url,
),
follower: instance.identify(&form.actor_url),
},
)
.await
@@ -851,6 +906,7 @@ pub async fn remove_follower_html(
}
}
#[cfg(feature = "federation")]
pub async fn get_blocked_domains_page(
crate::extractors::AdminUser(user_id): crate::extractors::AdminUser,
State(state): State<AppState>,
@@ -859,13 +915,7 @@ pub async fn get_blocked_domains_page(
let mut ctx = build_page_context(&state, Some(user_id), csrf.0).await;
ctx.page_title = "Blocked Domains — Movies Diary".to_string();
ctx.canonical_url = format!("{}/admin/blocked-domains", state.app_ctx.config.base_url);
match state
.app_ctx
.services
.ap_service
.get_blocked_domains()
.await
{
match state.app_ctx.ap_blocklist.get_blocked_domains().await {
Ok(domains) => {
let entries: Vec<template_askama::BlockedDomainEntry> = domains
.into_iter()
@@ -892,6 +942,7 @@ pub async fn get_blocked_domains_page(
}
}
#[cfg(feature = "federation")]
pub async fn post_blocked_domain(
crate::extractors::AdminUser(_): crate::extractors::AdminUser,
State(state): State<AppState>,
@@ -904,8 +955,7 @@ pub async fn post_blocked_domain(
let reason = form.reason.as_deref().filter(|s| !s.trim().is_empty());
match state
.app_ctx
.services
.ap_service
.ap_blocklist
.add_blocked_domain(&form.domain, reason)
.await
{
@@ -917,6 +967,7 @@ pub async fn post_blocked_domain(
}
}
#[cfg(feature = "federation")]
pub async fn post_remove_blocked_domain(
crate::extractors::AdminUser(_): crate::extractors::AdminUser,
State(state): State<AppState>,
@@ -928,8 +979,7 @@ pub async fn post_remove_blocked_domain(
}
match state
.app_ctx
.services
.ap_service
.ap_blocklist
.remove_blocked_domain(&form.domain)
.await
{
@@ -941,6 +991,7 @@ pub async fn post_remove_blocked_domain(
}
}
#[cfg(feature = "federation")]
pub async fn get_blocked_actors_page(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
@@ -949,12 +1000,10 @@ pub async fn get_blocked_actors_page(
let mut ctx = build_page_context(&state, Some(user_id.clone()), csrf.0).await;
ctx.page_title = "Blocked Users — Movies Diary".to_string();
ctx.canonical_url = format!("{}/social/blocked", state.app_ctx.config.base_url);
let deps = SocialQueryDeps::from(&state);
match application::social::execute::execute_query(
&deps,
application::social::queries::SocialQry::GetBlocked {
user_id: user_id.value(),
},
let instance = state.app_ctx.instance.clone();
match application::social::get_blocked::execute(
&state.app_ctx.deps.social.query,
user_id.value(),
)
.await
{
@@ -962,7 +1011,7 @@ pub async fn get_blocked_actors_page(
let entries: Vec<template_askama::BlockedActorEntry> = blocked
.into_iter()
.map(|a| template_askama::BlockedActorEntry {
url: actor_url(&a.identity),
url: instance.actor_url_of(&a.identity),
handle: a.handle,
display_name: a.display_name,
avatar_url: a.avatar_url,
@@ -985,6 +1034,7 @@ pub async fn get_blocked_actors_page(
}
}
#[cfg(feature = "federation")]
pub async fn post_block_actor_html(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
@@ -994,12 +1044,12 @@ pub async fn post_block_actor_html(
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let deps = SocialCommandDeps::from(&state);
let instance = state.app_ctx.instance.clone();
match application::social::execute::execute_command(
&deps,
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::Block {
blocker_id: user_id.value(),
target: SocialIdentity::from_actor_url(&form.actor_url, &state.app_ctx.config.base_url),
target: instance.identify(&form.actor_url),
},
)
.await
@@ -1012,6 +1062,7 @@ pub async fn post_block_actor_html(
}
}
#[cfg(feature = "federation")]
pub async fn post_unblock_actor(
RequiredCookieUser(user_id): RequiredCookieUser,
State(state): State<AppState>,
@@ -1021,12 +1072,12 @@ pub async fn post_unblock_actor(
if crate::csrf::mismatch(&csrf, &form.csrf_token) {
return StatusCode::FORBIDDEN.into_response();
}
let deps = SocialCommandDeps::from(&state);
let instance = state.app_ctx.instance.clone();
match application::social::execute::execute_command(
&deps,
&state.app_ctx.deps.social.command,
application::social::commands::SocialCmd::Unblock {
blocker_id: user_id.value(),
target: SocialIdentity::from_actor_url(&form.actor_url, &state.app_ctx.config.base_url),
target: instance.identify(&form.actor_url),
},
)
.await
@@ -1038,3 +1089,110 @@ pub async fn post_unblock_actor(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::value_objects::{InstanceIdentity, SocialActor, SocialIdentity, UserId};
fn local_actor(uid: &UserId) -> SocialActor {
SocialActor {
identity: SocialIdentity::Local(uid.clone()),
handle: "@gabriel@md.example".into(),
display_name: None,
avatar_url: None,
}
}
/// The bug: a local actor used to serialize as `local:{uuid}`, which
/// `identify` cannot parse, so every POST echoing this url back resolved
/// to Remote and was routed to ActivityPub.
#[test]
fn local_actor_dto_url_round_trips_back_to_the_same_identity() {
let instance = InstanceIdentity::new("https://md.example");
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
let dto = social_actor_to_dto(local_actor(&uid), &instance);
assert!(!dto.url.starts_with("local:"), "url was {}", dto.url);
assert_eq!(
instance.identify(&dto.url),
SocialIdentity::Local(uid),
"a local actor's dto url must parse back as Local"
);
}
#[cfg(feature = "federation")]
#[test]
fn local_blocked_dto_url_round_trips_back_to_the_same_identity() {
let instance = InstanceIdentity::new("https://md.example");
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
let dto = social_actor_to_blocked_dto(local_actor(&uid), &instance);
assert!(!dto.url.starts_with("local:"), "url was {}", dto.url);
assert_eq!(instance.identify(&dto.url), SocialIdentity::Local(uid));
}
#[test]
fn local_template_url_round_trips_back_to_the_same_identity() {
let instance = InstanceIdentity::new("https://md.example");
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
let data = social_actor_to_template(local_actor(&uid), &instance);
assert!(!data.url.starts_with("local:"), "url was {}", data.url);
assert_eq!(instance.identify(&data.url), SocialIdentity::Local(uid));
}
#[test]
fn remote_actor_dto_url_is_passed_through_unchanged() {
let instance = InstanceIdentity::new("https://md.example");
let actor = SocialActor {
identity: SocialIdentity::Remote {
actor_url: "https://other.example/users/bob".into(),
},
handle: "@bob@other.example".into(),
display_name: None,
avatar_url: None,
};
let dto = social_actor_to_dto(actor, &instance);
assert_eq!(dto.url, "https://other.example/users/bob");
}
#[test]
fn local_actor_dto_carries_user_id_and_avatar() {
let instance = InstanceIdentity::new("https://md.example");
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
let mut actor = local_actor(&uid);
actor.avatar_url = Some("https://md.example/images/a.webp".into());
let dto = social_actor_to_dto(actor, &instance);
assert_eq!(
dto.user_id,
Some(uid.value()),
"local actors must carry user_id for internal links"
);
assert_eq!(
dto.avatar_url.as_deref(),
Some("https://md.example/images/a.webp")
);
}
#[test]
fn remote_actor_dto_has_no_user_id() {
let instance = InstanceIdentity::new("https://md.example");
let actor = SocialActor {
identity: SocialIdentity::Remote {
actor_url: "https://other.example/users/bob".into(),
},
handle: "@bob@other.example".into(),
display_name: None,
avatar_url: None,
};
assert_eq!(social_actor_to_dto(actor, &instance).user_id, None);
}
}

View File

@@ -9,12 +9,12 @@ use axum::{
use uuid::Uuid;
use application::users::{
deps::{GetProfileDeps, UpdateProfileDeps},
get_profile as get_user_profile_uc, get_users,
get_federated_profile, get_federated_profile_stats, get_local_profile,
get_profile_settings as get_profile_settings_uc, get_users,
queries::{GetUserProfileQuery, GetUsersQuery},
update_profile, update_profile_fields,
resolve_username_to_id, update_profile, update_profile_fields,
};
use domain::value_objects::UserId;
use domain::{errors::DomainError, value_objects::Username};
use crate::{
csrf::CsrfToken,
@@ -53,24 +53,20 @@ pub async fn get_profile(
AuthenticatedUser(user_id): AuthenticatedUser,
) -> Result<Json<ProfileResponse>, ApiError> {
let profile = application::users::get_current_profile::execute(
state.app_ctx.repos.user.clone(),
&state.app_ctx.deps.users.get_current_profile,
application::users::queries::GetCurrentProfileQuery {
user_id: user_id.value(),
},
)
.await?;
let base_url = &state.app_ctx.config.base_url;
let instance = &state.app_ctx.instance;
Ok(Json(ProfileResponse {
profile: api_types::UserProfileBase {
username: profile.username,
display_name: profile.display_name,
bio: profile.bio,
avatar_url: profile
.avatar_path
.map(|p| format!("{}/images/{}", base_url, p)),
banner_url: profile
.banner_path
.map(|p| format!("{}/images/{}", base_url, p)),
avatar_url: profile.avatar_path.map(|p| instance.image_url_for(&p)),
banner_url: profile.banner_path.map(|p| instance.image_url_for(&p)),
},
also_known_as: profile.also_known_as,
fields: profile
@@ -113,12 +109,7 @@ pub async fn update_profile_handler(
also_known_as: data.also_known_as,
};
let deps = UpdateProfileDeps {
user: state.app_ctx.repos.user.clone(),
object_storage: state.app_ctx.services.object_storage.clone(),
event_publisher: state.app_ctx.services.event_publisher.clone(),
};
match update_profile::execute(&deps, cmd).await {
match update_profile::execute(&state.app_ctx.deps.users.update_profile, cmd).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => crate::errors::domain_error_response(e),
}
@@ -159,12 +150,7 @@ pub async fn update_profile_fields_handler(
fields,
};
match update_profile_fields::execute(
state.app_ctx.repos.profile_fields.clone(),
state.app_ctx.services.event_publisher.clone(),
cmd,
)
.await
match update_profile_fields::execute(&state.app_ctx.deps.users.update_profile_fields, cmd).await
{
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(e) => crate::errors::domain_error_response(e),
@@ -176,11 +162,8 @@ pub async fn update_profile_fields_handler(
responses((status = 200, body = UsersResponse)),
)]
pub async fn list_users(State(state): State<AppState>) -> Result<Json<UsersResponse>, ApiError> {
let deps = application::users::deps::GetUsersListDeps {
user: state.app_ctx.repos.user.clone(),
federation_admin: state.app_ctx.repos.federation_admin.clone(),
};
let result = get_users::execute(&deps, GetUsersQuery).await?;
let result =
get_users::execute(&state.app_ctx.deps.users.get_users_list, GetUsersQuery).await?;
Ok(Json(UsersResponse {
users: result
.users
@@ -220,44 +203,15 @@ pub async fn get_user_profile(
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
};
let local_user = match state
.app_ctx
.repos
.user
.find_by_id(&UserId::from_uuid(user_id))
.await
{
Ok(u) => u,
Err(e) => {
return crate::errors::domain_error_response(e);
}
};
if local_user.is_none() {
if let Some(ref fed_query) = state.app_ctx.repos.federated_profile
&& let Ok(Some(fed)) = fed_query.get_federated_profile(user_id).await
{
return build_federated_profile_response(&state, user_id, fed, profile_view, &params)
.await;
}
return StatusCode::NOT_FOUND.into_response();
}
let user = local_user.unwrap();
let get_profile_deps = GetProfileDeps {
stats: state.app_ctx.repos.stats.clone(),
diary: state.app_ctx.repos.diary.clone(),
social_query: state.app_ctx.repos.social_query_unified.clone(),
};
let profile = match get_user_profile_uc::execute(
&get_profile_deps,
let profile = match get_local_profile::execute(
&state.app_ctx.deps.users.get_local_profile,
GetUserProfileQuery {
user_id,
view: profile_view,
limit: params.limit,
offset: params.offset,
sort_by: domain::models::FeedSortBy::Date,
search: params.search,
search: params.search.clone(),
is_own_profile: viewer_id.value() == user_id,
include_remote: false,
},
@@ -265,6 +219,24 @@ pub async fn get_user_profile(
.await
{
Ok(p) => p,
Err(DomainError::NotFound(_)) => {
if let Ok(Some(fed)) = get_federated_profile::execute(
&state.app_ctx.deps.users.get_federated_profile,
user_id,
)
.await
{
return build_federated_profile_response(
&state,
user_id,
fed,
profile_view,
&params,
)
.await;
}
return StatusCode::NOT_FOUND.into_response();
}
Err(e) => return crate::errors::domain_error_response(e),
};
@@ -306,15 +278,11 @@ pub async fn get_user_profile(
Json(UserProfileResponse {
user_id,
profile: api_types::UserProfileBase {
username: user.username().value().to_string(),
avatar_url: user
.avatar_path()
.map(|p| format!("{}/images/{}", state.app_ctx.config.base_url, p)),
banner_url: user
.banner_path()
.map(|p| format!("{}/images/{}", state.app_ctx.config.base_url, p)),
display_name: None,
bio: None,
username: profile.identity.username.clone(),
avatar_url: profile.identity.avatar_url.clone(),
banner_url: profile.identity.banner_url.clone(),
display_name: profile.identity.display_name.clone(),
bio: profile.identity.bio.clone(),
},
stats: UserStatsDto {
total_movies: profile.stats.total_movies,
@@ -327,10 +295,7 @@ pub async fn get_user_profile(
view_data,
goals: {
let goals_list = application::goals::list::execute(
&application::goals::deps::GoalQueryDeps {
goal_query: state.app_ctx.repos.goal_query.clone(),
stats: state.app_ctx.repos.stats.clone(),
},
&state.app_ctx.deps.goals.query,
application::goals::queries::ListGoalsQuery { user_id },
)
.await
@@ -342,8 +307,8 @@ pub async fn get_user_profile(
}
},
is_federated: false,
handle: None,
actor_url: None,
handle: Some(profile.identity.handle.clone()),
actor_url: Some(profile.identity.actor_url.clone()),
})
.into_response()
}
@@ -355,13 +320,8 @@ async fn build_federated_profile_response(
profile_view: application::users::queries::ProfileView,
params: &UserProfileQueryParams,
) -> axum::response::Response {
let get_profile_deps = GetProfileDeps {
stats: state.app_ctx.repos.stats.clone(),
diary: state.app_ctx.repos.diary.clone(),
social_query: state.app_ctx.repos.social_query_unified.clone(),
};
let profile = match get_user_profile_uc::execute(
&get_profile_deps,
let profile = match get_federated_profile_stats::execute(
&state.app_ctx.deps.users.get_federated_profile_stats,
GetUserProfileQuery {
user_id,
view: profile_view,
@@ -481,12 +441,8 @@ pub async fn get_users_list(
ctx.page_title = "Members — Movies Diary".to_string();
ctx.canonical_url = format!("{}/users", state.app_ctx.config.base_url);
let users_deps = application::users::deps::GetUsersListDeps {
user: state.app_ctx.repos.user.clone(),
federation_admin: state.app_ctx.repos.federation_admin.clone(),
};
match application::users::get_users::execute(
&users_deps,
&state.app_ctx.deps.users.get_users_list,
application::users::queries::GetUsersQuery,
)
.await
@@ -517,14 +473,14 @@ pub async fn get_user_by_username(
State(state): State<AppState>,
Path(username): Path<String>,
) -> impl IntoResponse {
let uname = match domain::value_objects::Username::new(username) {
let uname = match Username::new(username) {
Ok(u) => u,
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
match state.app_ctx.repos.user.find_by_username(&uname).await {
Ok(Some(user)) => {
axum::response::Redirect::permanent(&format!("/users/{}", user.id().value()))
.into_response()
match resolve_username_to_id::execute(&state.app_ctx.deps.users.resolve_username, &uname).await
{
Ok(Some(uid)) => {
axum::response::Redirect::permanent(&format!("/users/{}", uid.value())).into_response()
}
_ => StatusCode::NOT_FOUND.into_response(),
}
@@ -607,10 +563,7 @@ async fn fetch_profile_goals(
user_id: Uuid,
) -> Vec<template_askama::GoalViewData> {
let goals_list = application::goals::list::execute(
&application::goals::deps::GoalQueryDeps {
goal_query: state.app_ctx.repos.goal_query.clone(),
stats: state.app_ctx.repos.stats.clone(),
},
&state.app_ctx.deps.goals.query,
application::goals::queries::ListGoalsQuery { user_id },
)
.await
@@ -647,8 +600,7 @@ pub async fn get_user_profile_html(
if accept.contains("application/activity+json") || accept.contains("application/ld+json") {
return match state
.app_ctx
.services
.ap_service
.ap_document
.actor_json(&profile_user_uuid.to_string())
.await
{
@@ -681,25 +633,6 @@ pub async fn get_user_profile_html(
}
};
let profile_user = match state
.app_ctx
.repos
.user
.find_by_id(&domain::value_objects::UserId::from_uuid(profile_user_uuid))
.await
{
Ok(Some(u)) => u,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => return crate::errors::domain_error_response(e),
};
let display_name = profile_user.username().value();
ctx.page_title = format!("{}'s Diary — Movies Diary", display_name);
ctx.canonical_url = format!(
"{}/users/{}",
state.app_ctx.config.base_url, profile_user_uuid
);
let sort_by_str = match params.sort_by.as_str() {
"date_asc" => "date_asc",
"rating" => "rating",
@@ -727,19 +660,25 @@ pub async fn get_user_profile_html(
include_remote: false,
};
let html_profile_deps = GetProfileDeps {
stats: state.app_ctx.repos.stats.clone(),
diary: state.app_ctx.repos.diary.clone(),
social_query: state.app_ctx.repos.social_query_unified.clone(),
};
match application::users::get_profile::execute(&html_profile_deps, query).await {
match get_local_profile::execute(&state.app_ctx.deps.users.get_local_profile, query).await {
Ok(profile) => {
ctx.page_title = format!("{}'s Diary — Movies Diary", profile.identity.username);
ctx.canonical_url = format!(
"{}/users/{}",
state.app_ctx.config.base_url, profile_user_uuid
);
let pag = compute_pagination(profile.entries.as_ref());
if !is_own_profile {
ctx.page_rss_url = Some(format!("/users/{}/feed.rss", profile_user_uuid));
}
let email = profile_user.email().value().to_string();
let display_name = email.split('@').next().unwrap_or("?").to_string();
let display_name = profile
.identity
.email
.split('@')
.next()
.unwrap_or("?")
.to_string();
let stats_disp = build_stats_display(&profile.stats);
let history = profile.history.map(application::users::group_by_month);
let heatmap = history.as_deref().map(build_heatmap).unwrap_or_default();
@@ -809,6 +748,7 @@ pub async fn get_user_profile_html(
.into_response()
}
}
Err(DomainError::NotFound(_)) => StatusCode::NOT_FOUND.into_response(),
Err(e) => crate::errors::domain_error_response(e),
}
}
@@ -828,43 +768,26 @@ 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.repos.user.find_by_id(&user_id).await {
Ok(Some(u)) => u,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
let settings = match get_profile_settings_uc::execute(
&state.app_ctx.deps.users.get_profile_settings,
user_id.value(),
)
.await
{
Ok(s) => s,
Err(DomainError::NotFound(_)) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => return crate::errors::domain_error_response(e),
};
let base_url = &state.app_ctx.config.base_url;
let avatar_url = user
.avatar_path()
.map(|path| format!("{}/images/{}", base_url, path));
let banner_url = user
.banner_path()
.map(|path| format!("{}/images/{}", base_url, path));
let profile_fields: Vec<(String, String)> = state
.app_ctx
.repos
.profile_fields
.get_fields(&user_id)
.await
.unwrap_or_default()
.into_iter()
.map(|f| (f.name, f.value))
.collect();
let saved = params.saved.as_deref() == Some("1");
let bio = user.bio().map(|s| s.to_string());
let also_known_as = user.also_known_as().map(|s| s.to_string());
render_page(ProfileSettingsTemplate {
ctx: &ctx,
bio: bio.as_deref(),
avatar_url: avatar_url.as_deref(),
banner_url: banner_url.as_deref(),
also_known_as: also_known_as.as_deref(),
profile_fields: &profile_fields,
bio: settings.bio.as_deref(),
avatar_url: settings.avatar_url.as_deref(),
banner_url: settings.banner_url.as_deref(),
also_known_as: settings.also_known_as.as_deref(),
profile_fields: &settings.fields,
saved,
embed_url: format!(
"{}/users/{}?embed=true",
@@ -892,12 +815,7 @@ pub async fn post_profile_settings(
banner_content_type: data.banner_content_type,
also_known_as: data.also_known_as,
};
let update_deps = UpdateProfileDeps {
user: state.app_ctx.repos.user.clone(),
object_storage: state.app_ctx.services.object_storage.clone(),
event_publisher: state.app_ctx.services.event_publisher.clone(),
};
if let Err(e) = update_profile::execute(&update_deps, cmd).await {
if let Err(e) = update_profile::execute(&state.app_ctx.deps.users.update_profile, cmd).await {
tracing::error!("update_profile error: {:?}", e);
return axum::response::Redirect::to(&format!(
"/settings/profile?error={}",
@@ -925,12 +843,9 @@ pub async fn post_profile_settings(
user_id: user_id.value(),
fields,
};
if let Err(e) = update_profile_fields::execute(
state.app_ctx.repos.profile_fields.clone(),
state.app_ctx.services.event_publisher.clone(),
fields_cmd,
)
.await
if let Err(e) =
update_profile_fields::execute(&state.app_ctx.deps.users.update_profile_fields, fields_cmd)
.await
{
tracing::error!("update_profile_fields error: {:?}", e);
return axum::response::Redirect::to(&format!(

View File

@@ -11,8 +11,7 @@ use application::{
watchlist::{
add as add_to_watchlist,
commands::{AddToWatchlistCommand, RemoveFromWatchlistCommand},
deps::WatchlistAddDeps,
get as get_watchlist, is_on as is_on_watchlist,
get as get_watchlist, get_watchlist_for_owner, is_on as is_on_watchlist,
queries::{GetWatchlistQuery, IsOnWatchlistQuery},
remove as remove_from_watchlist,
},
@@ -54,7 +53,7 @@ pub async fn get_watchlist_handler(
Query(params): Query<PaginationQueryParams>,
) -> Result<Json<WatchlistResponse>, ApiError> {
let page = get_watchlist::execute(
state.app_ctx.repos.watchlist.clone(),
&state.app_ctx.deps.watchlist.get_watchlist,
GetWatchlistQuery {
user_id: user.0.value(),
limit: params.limit,
@@ -94,15 +93,8 @@ pub async fn post_watchlist_add(
user: AuthenticatedUser,
Json(req): Json<AddToWatchlistRequest>,
) -> Result<impl IntoResponse, ApiError> {
let deps = WatchlistAddDeps {
movie_command: state.app_ctx.repos.movie_command.clone(),
movie_query: state.app_ctx.repos.movie_query.clone(),
metadata: state.app_ctx.services.metadata.clone(),
watchlist: state.app_ctx.repos.watchlist.clone(),
event_publisher: state.app_ctx.services.event_publisher.clone(),
};
add_to_watchlist::execute(
&deps,
&state.app_ctx.deps.watchlist.add,
AddToWatchlistCommand {
user_id: user.0.value(),
input: MovieInput {
@@ -134,8 +126,7 @@ pub async fn delete_watchlist_entry(
Path(movie_id): Path<Uuid>,
) -> Result<impl IntoResponse, ApiError> {
remove_from_watchlist::execute(
state.app_ctx.repos.watchlist.clone(),
state.app_ctx.services.event_publisher.clone(),
&state.app_ctx.deps.watchlist.remove_from_watchlist,
RemoveFromWatchlistCommand {
user_id: user.0.value(),
movie_id,
@@ -160,7 +151,7 @@ pub async fn get_watchlist_status(
Path(movie_id): Path<Uuid>,
) -> Result<Json<WatchlistStatusResponse>, ApiError> {
let on_watchlist = is_on_watchlist::execute(
state.app_ctx.repos.watchlist.clone(),
&state.app_ctx.deps.watchlist.is_on_watchlist,
IsOnWatchlistQuery {
user_id: user.0.value(),
movie_id,
@@ -182,39 +173,25 @@ pub async fn get_watchlist_page(
let ctx = build_page_context(&state, viewer_id.clone(), csrf.0).await;
let is_owner = viewer_id.map(|u| u.value() == owner_id).unwrap_or(false);
let user_id = domain::value_objects::UserId::from_uuid(owner_id);
let is_local = state
.app_ctx
.repos
.user
.find_by_id(&user_id)
.await
.map(|u| u.is_some())
.unwrap_or(false);
let view = match get_watchlist_for_owner::execute(
&state.app_ctx.deps.watchlist.get_watchlist_for_owner,
owner_id,
params.limit.or(Some(20)),
params.offset.or(Some(0)),
)
.await
{
Ok(view) => view,
Err(e) => return crate::errors::domain_error_response(e),
};
let result = if is_local {
match get_watchlist::execute(
state.app_ctx.repos.watchlist.clone(),
application::watchlist::queries::GetWatchlistQuery {
user_id: owner_id,
limit: params.limit.or(Some(20)),
offset: params.offset.or(Some(0)),
},
)
.await
{
Ok(page) => crate::mappers::watchlist::build_watchlist_page(page, is_owner),
Err(e) => return crate::errors::domain_error_response(e),
let result = match view {
get_watchlist_for_owner::WatchlistView::Local(page) => {
crate::mappers::watchlist::build_watchlist_page(page, is_owner)
}
get_watchlist_for_owner::WatchlistView::Remote(entries) => {
crate::mappers::watchlist::build_remote_watchlist_page(entries)
}
} else {
let remote_entries = state
.app_ctx
.repos
.remote_watchlist
.get_by_derived_uuid(owner_id)
.await
.unwrap_or_default();
crate::mappers::watchlist::build_remote_watchlist_page(remote_entries)
};
render_page(WatchlistTemplate {
@@ -280,16 +257,8 @@ pub async fn post_watchlist_add_html(
}
};
let deps = WatchlistAddDeps {
movie_command: state.app_ctx.repos.movie_command.clone(),
movie_query: state.app_ctx.repos.movie_query.clone(),
metadata: state.app_ctx.services.metadata.clone(),
watchlist: state.app_ctx.repos.watchlist.clone(),
event_publisher: state.app_ctx.services.event_publisher.clone(),
};
match add_to_watchlist::execute(
&deps,
&state.app_ctx.deps.watchlist.add,
AddToWatchlistCommand {
user_id: user_id.value(),
input,
@@ -323,8 +292,7 @@ pub async fn post_watchlist_remove_html(
return StatusCode::FORBIDDEN.into_response();
}
match remove_from_watchlist::execute(
state.app_ctx.repos.watchlist.clone(),
state.app_ctx.services.event_publisher.clone(),
&state.app_ctx.deps.watchlist.remove_from_watchlist,
RemoveFromWatchlistCommand {
user_id: user_id.value(),
movie_id,

View File

@@ -15,10 +15,9 @@ use application::integrations::{
ConfirmWatchEventsCommand, DismissWatchEventsCommand, GenerateWebhookTokenCommand,
IngestWatchEventCommand, RevokeWebhookTokenCommand, WatchEventConfirmation,
},
confirm as confirm_watch_events,
deps::IngestWatchEventDeps,
dismiss as dismiss_watch_events, generate_token as generate_webhook_token,
get_queue as get_watch_queue, get_tokens as get_webhook_tokens, ingest as ingest_watch_event,
confirm as confirm_watch_events, dismiss as dismiss_watch_events,
generate_token as generate_webhook_token, get_queue as get_watch_queue,
get_tokens as get_webhook_tokens, ingest as ingest_watch_event,
queries::{GetWatchQueueQuery, GetWebhookTokensQuery},
revoke_token as revoke_webhook_token,
};
@@ -72,7 +71,12 @@ pub async fn post_jellyfin_webhook(
source: WatchEventSource::Jellyfin,
};
run_ingest(&state, cmd, &jellyfin::JellyfinParser).await
run_ingest(
&state,
cmd,
&*state.app_ctx.deps.integrations.jellyfin_parser,
)
.await
}
// ── Plex webhook (multipart form data with `payload` JSON field) ──────────────
@@ -119,7 +123,7 @@ pub async fn post_plex_webhook(
source: WatchEventSource::Plex,
};
run_ingest(&state, cmd, &plex::PlexParser).await
run_ingest(&state, cmd, &*state.app_ctx.deps.integrations.plex_parser).await
}
async fn run_ingest(
@@ -127,13 +131,13 @@ async fn run_ingest(
cmd: IngestWatchEventCommand,
parser: &dyn domain::ports::MediaServerParser,
) -> StatusCode {
let deps = IngestWatchEventDeps {
webhook_token: state.app_ctx.repos.webhook_token.clone(),
watch_event_command: state.app_ctx.repos.watch_event_command.clone(),
watch_event_query: state.app_ctx.repos.watch_event_query.clone(),
event_publisher: state.app_ctx.services.event_publisher.clone(),
};
match ingest_watch_event::execute(&deps, cmd, parser).await {
match ingest_watch_event::execute(
&state.app_ctx.deps.integrations.ingest_watch_event,
cmd,
parser,
)
.await
{
Ok(()) => StatusCode::OK,
Err(e) => crate::errors::domain_error_status(&e),
}
@@ -166,8 +170,11 @@ pub async fn post_generate_webhook_token(
label: req.label,
};
let result =
generate_webhook_token::execute(state.app_ctx.repos.webhook_token.clone(), cmd).await?;
let result = generate_webhook_token::execute(
&state.app_ctx.deps.integrations.generate_webhook_token,
cmd,
)
.await?;
let base_url = &state.app_ctx.config.base_url;
let webhook_url = format!("{base_url}/api/v1/webhooks/{provider}");
@@ -195,7 +202,8 @@ pub async fn get_webhook_tokens(
user_id: user.0.value(),
};
let tokens =
get_webhook_tokens::execute(state.app_ctx.repos.webhook_token.clone(), query).await?;
get_webhook_tokens::execute(&state.app_ctx.deps.integrations.get_webhook_tokens, query)
.await?;
let dtos = tokens
.into_iter()
@@ -230,7 +238,8 @@ pub async fn delete_webhook_token(
user_id: user.0.value(),
token_id: id,
};
revoke_webhook_token::execute(state.app_ctx.repos.webhook_token.clone(), cmd).await?;
revoke_webhook_token::execute(&state.app_ctx.deps.integrations.revoke_webhook_token, cmd)
.await?;
Ok(StatusCode::NO_CONTENT)
}
@@ -252,7 +261,7 @@ pub async fn get_watch_queue(
user_id: user.0.value(),
};
let events =
get_watch_queue::execute(state.app_ctx.repos.watch_event_query.clone(), query).await?;
get_watch_queue::execute(&state.app_ctx.deps.integrations.get_watch_queue, query).await?;
let dtos = events
.into_iter()
@@ -297,13 +306,9 @@ pub async fn post_confirm_watch_events(
.collect(),
};
let confirmed = confirm_watch_events::execute(
state.app_ctx.repos.watch_event_command.clone(),
state.app_ctx.repos.watch_event_query.clone(),
state.app_ctx.services.review_logger.clone(),
cmd,
)
.await?;
let confirmed =
confirm_watch_events::execute(&state.app_ctx.deps.integrations.confirm_watch_events, cmd)
.await?;
Ok(Json(ConfirmWatchResponse { confirmed }))
}
@@ -327,11 +332,8 @@ pub async fn post_dismiss_watch_events(
event_ids: req.event_ids,
};
let dismissed = dismiss_watch_events::execute(
state.app_ctx.repos.watch_event_command.clone(),
state.app_ctx.repos.watch_event_query.clone(),
cmd,
)
.await?;
let dismissed =
dismiss_watch_events::execute(&state.app_ctx.deps.integrations.dismiss_watch_events, cmd)
.await?;
Ok(Json(DismissWatchResponse { dismissed }))
}

View File

@@ -9,11 +9,11 @@ use uuid::Uuid;
use application::wrapup::{
commands::RequestWrapUpCommand,
delete as delete_wrapup, generate, get_wrapup,
delete as delete_wrapup, generate, get_ready_report, get_wrapup,
list_wrapups::{self, ListWrapUpsQuery},
};
use domain::errors::DomainError;
use domain::models::wrapup::{WrapUpRecord, WrapUpReport, WrapUpStatus};
use domain::models::wrapup::{WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus};
use domain::value_objects::WrapUpId;
use crate::{
@@ -66,12 +66,7 @@ pub async fn post_generate(
start_date: start,
end_date: end,
};
let id = generate::execute(
state.app_ctx.repos.wrapup_repo.clone(),
state.app_ctx.services.event_publisher.clone(),
cmd,
)
.await?;
let id = generate::execute(&state.app_ctx.deps.wrapup.generate, cmd).await?;
Ok(Json(WrapUpGeneratedResponse {
id: id.value().to_string(),
}))
@@ -90,7 +85,7 @@ pub async fn get_list(
user: AuthenticatedUser,
) -> Result<Json<WrapUpListResponse>, ApiError> {
let records = list_wrapups::execute(
state.app_ctx.repos.wrapup_repo.clone(),
&state.app_ctx.deps.wrapup.list_wrapups,
ListWrapUpsQuery {
user_id: Some(user.0.value()),
},
@@ -117,7 +112,7 @@ pub async fn get_status(
Path(id): Path<Uuid>,
) -> Result<Json<WrapUpStatusResponse>, ApiError> {
let record = get_wrapup::execute(
state.app_ctx.repos.wrapup_repo.clone(),
&state.app_ctx.deps.wrapup.get_wrapup,
WrapUpId::from_uuid(id),
)
.await?
@@ -142,7 +137,7 @@ pub async fn get_report(
Path(id): Path<Uuid>,
) -> impl IntoResponse {
match get_wrapup::execute(
state.app_ctx.repos.wrapup_repo.clone(),
&state.app_ctx.deps.wrapup.get_wrapup,
WrapUpId::from_uuid(id),
)
.await
@@ -177,7 +172,7 @@ pub async fn delete_wrapup_handler(
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
delete_wrapup::execute(
state.app_ctx.repos.wrapup_repo.clone(),
&state.app_ctx.deps.wrapup.delete_wrapup,
WrapUpId::from_uuid(id),
)
.await?;
@@ -243,30 +238,15 @@ pub async fn get_user_wrapup_html(
Path((user_id, year)): Path<(Uuid, i32)>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let start = match NaiveDate::from_ymd_opt(year, 1, 1) {
Some(d) => d,
None => return StatusCode::BAD_REQUEST.into_response(),
};
let end = match NaiveDate::from_ymd_opt(year + 1, 1, 1) {
Some(d) => d,
None => return StatusCode::BAD_REQUEST.into_response(),
};
let record = match state
.app_ctx
.repos
.wrapup_repo
.find_existing(Some(user_id), start, end)
.await
{
Ok(Some(r)) if r.status == WrapUpStatus::Ready => r,
_ => return StatusCode::NOT_FOUND.into_response(),
};
let report = match record.report {
Some(r) => r,
None => return StatusCode::NOT_FOUND.into_response(),
};
let scope = WrapUpScope::User(user_id);
let report =
match get_ready_report::execute(&state.app_ctx.deps.wrapup.get_ready_report, scope, year)
.await
{
Ok(report) => report,
Err(DomainError::ValidationError(_)) => return StatusCode::BAD_REQUEST.into_response(),
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
let ctx = super::helpers::build_page_context(&state, viewer, csrf.0).await;
render_wrapup(&report, year, &ctx)
@@ -278,29 +258,16 @@ pub async fn get_global_wrapup_html(
Path(year): Path<i32>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let start = match NaiveDate::from_ymd_opt(year, 1, 1) {
Some(d) => d,
None => return StatusCode::BAD_REQUEST.into_response(),
};
let end = match NaiveDate::from_ymd_opt(year + 1, 1, 1) {
Some(d) => d,
None => return StatusCode::BAD_REQUEST.into_response(),
};
let record = match state
.app_ctx
.repos
.wrapup_repo
.find_existing(None, start, end)
.await
let report = match get_ready_report::execute(
&state.app_ctx.deps.wrapup.get_ready_report,
WrapUpScope::Global,
year,
)
.await
{
Ok(Some(r)) if r.status == WrapUpStatus::Ready => r,
_ => return StatusCode::NOT_FOUND.into_response(),
};
let report = match record.report {
Some(r) => r,
None => return StatusCode::NOT_FOUND.into_response(),
Ok(report) => report,
Err(DomainError::ValidationError(_)) => return StatusCode::BAD_REQUEST.into_response(),
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
let ctx = super::helpers::build_page_context(&state, viewer, csrf.0).await;

View File

@@ -2,7 +2,6 @@ pub mod context;
pub mod csrf;
pub mod errors;
pub mod extractors;
pub mod factory;
pub mod forms;
pub mod handlers;
pub mod mappers;

View File

@@ -1,273 +0,0 @@
use std::sync::Arc;
use anyhow::Context;
use tokio::net::TcpListener;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use application::config::AppConfig;
use export::ExportAdapter;
use importer::ImporterDocumentParser;
use presentation::context::{AppContext, Repositories, Services};
use presentation::{factory, openapi, routes, state::AppState};
use rss::RssAdapter;
use domain::ports::{DiaryExporter, DocumentParser, EventPublisher};
use infra_wiring::EventBusBackend;
#[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"
);
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
init_tracing();
let (state, ap_router) = wire_dependencies()
.await
.context("Failed to wire dependencies")?;
let app = openapi::serve(routes::build_router(state, ap_router));
let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
let port = std::env::var("PORT").unwrap_or_else(|_| "3000".to_string());
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?;
Ok(())
}
async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
let app_config = AppConfig::from_env();
let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?;
let backend = std::env::var("DATABASE_BACKEND").unwrap_or_else(|_| "sqlite".to_string());
let (auth_service, password_hasher) = factory::build_auth_adapters()?;
let metadata_client = factory::build_metadata_client()?;
let poster_fetcher = factory::build_poster_fetcher()?;
let object_storage = factory::build_object_storage()?;
let db = factory::build_database_adapters(&backend, &database_url).await?;
let ap_content_repo = db.ap_content;
let db_pool = db.db_pool;
// Wire up event channel, federation service, and ap_router
let event_bus = EventBusBackend::from_env()?;
#[cfg(feature = "federation")]
let (
event_publisher_arc,
ap_router,
ap_service,
social_query,
remote_watchlist_repo,
social_command_arc,
social_query_unified_arc,
) = {
let fed_repos = match &db_pool {
#[cfg(feature = "postgres-federation")]
factory::DbPool::Postgres(pool) => postgres_federation::wire(pool.clone()),
#[cfg(feature = "sqlite-federation")]
factory::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 = create_event_publisher(event_bus, &db_pool).await?;
let ap = activitypub::wire(activitypub::ActivityPubDeps {
activity_repo: fed_repos.activity,
follow_repo: fed_repos.follow,
actor_repo: fed_repos.actor,
blocklist_repo: fed_repos.blocklist,
review_store: fed_repos.review_store,
remote_watchlist_repo: fed_repos.remote_watchlist.clone(),
remote_goal_repo: Arc::clone(&db.remote_goal),
local_ap_content: Arc::clone(&ap_content_repo),
movie_repo: Arc::clone(&db.movie_query),
review_repo: Arc::clone(&db.review),
diary_repo: Arc::clone(&db.diary),
goal_repo: Arc::clone(&db.goal_query),
stats_repo: Arc::clone(&db.stats),
user_repo: Arc::clone(&db.user),
federation_settings: std::sync::Arc::clone(&db.federation_settings),
follow_command: Arc::clone(&fed_repos.follow_command),
follow_query: Arc::clone(&fed_repos.follow_query),
base_url: app_config.base_url.clone(),
allow_registration: app_config.allow_registration,
event_publisher: Arc::clone(&ep),
})
.await?;
let ap_router = ap.router;
let ap_service_arc = ap.service;
let composite_social = Arc::new(activitypub::CompositeSocialAdapter::new(
Arc::clone(&ap_service_arc),
Arc::clone(&db.user),
fed_repos.follow_command,
fed_repos.follow_query,
app_config.base_url.clone(),
));
(
ep,
ap_router,
ap_service_arc,
fed_repos.admin_query,
fed_repos.remote_watchlist,
composite_social.clone() as Arc<dyn domain::ports::SocialCommand>,
composite_social as Arc<dyn domain::ports::SocialQuery>,
)
};
#[cfg(not(feature = "federation"))]
let event_publisher_arc = create_event_publisher(event_bus, &db_pool).await?;
#[cfg(not(feature = "federation"))]
let ap_router = axum::Router::new();
#[cfg(not(feature = "federation"))]
let social_command_arc: Arc<dyn domain::ports::SocialCommand> =
Arc::new(domain::ports::noop::NoopSocialCommand);
#[cfg(not(feature = "federation"))]
let social_query_unified_arc: Arc<dyn domain::ports::SocialQuery> =
Arc::new(domain::ports::noop::NoopSocialQuery);
let review_logger = Arc::new(application::diary::review_logger::DefaultReviewLogger::new(
Arc::clone(&db.movie_command),
Arc::clone(&db.movie_query),
Arc::clone(&db.review),
Arc::clone(&db.watchlist),
Arc::clone(&metadata_client),
Arc::clone(&event_publisher_arc),
));
let app_ctx = AppContext {
repos: Repositories {
movie_command: db.movie_command,
movie_query: db.movie_query,
review: db.review,
diary: db.diary,
stats: db.stats,
user: db.user,
import_session: db.import_session,
import_profile: db.import_profile,
movie_profile: db.movie_profile,
watchlist: db.watchlist,
watch_event_command: db.watch_event_command,
watch_event_query: db.watch_event_query,
webhook_token: db.webhook_token,
person_command: db.person_command,
person_query: db.person_query,
search_port: db.search_port,
search_command: db.search_command,
profile_fields: db.profile_fields,
#[cfg(feature = "federation")]
remote_watchlist: remote_watchlist_repo,
#[cfg(not(feature = "federation"))]
remote_watchlist: Arc::new(domain::ports::noop::NoopRemoteWatchlistRepository),
social_command: social_command_arc,
social_query_unified: social_query_unified_arc,
#[cfg(feature = "federation")]
federation_admin: social_query.clone(),
#[cfg(not(feature = "federation"))]
federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery),
wrapup_stats: db.wrapup_stats,
wrapup_repo: db.wrapup_repo,
goal_command: db.goal_command,
goal_query: db.goal_query,
user_settings: db.user_settings,
remote_goal: db.remote_goal,
refresh_session: db.refresh_session,
#[cfg(feature = "federation")]
federated_profile: Some({
match &db_pool {
#[cfg(feature = "sqlite-federation")]
factory::DbPool::Sqlite(pool) => {
sqlite_federation::create_federated_profile_query(pool.clone())
}
#[cfg(feature = "postgres-federation")]
factory::DbPool::Postgres(pool) => {
postgres_federation::create_federated_profile_query(pool.clone())
}
#[cfg(not(feature = "sqlite-federation"))]
_ => unreachable!(),
}
}),
#[cfg(not(feature = "federation"))]
federated_profile: None,
},
services: Services {
auth: auth_service,
password_hasher,
metadata: metadata_client,
poster_fetcher,
object_storage,
event_publisher: event_publisher_arc,
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
review_logger,
person_enrichment: None,
#[cfg(feature = "federation")]
ap_service,
},
config: app_config,
};
let state = AppState {
app_ctx,
rss_renderer: Arc::new(RssAdapter::new(
std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()),
)),
};
Ok((state, ap_router))
}
async fn create_event_publisher(
event_bus: EventBusBackend,
db_pool: &factory::DbPool,
) -> anyhow::Result<Arc<dyn EventPublisher>> {
match event_bus {
EventBusBackend::Db => {
tracing::info!("event bus: DB queue");
Ok(match db_pool {
#[cfg(feature = "postgres")]
factory::DbPool::Postgres(pool) => {
postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone()).await?
}
#[cfg(feature = "sqlite")]
factory::DbPool::Sqlite(pool) => {
sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await?
}
})
}
#[cfg(feature = "nats")]
EventBusBackend::Nats => {
let cfg = nats::NatsConfig::from_env()
.context("EVENT_BUS_BACKEND=nats requires NATS_URL to be set")?;
tracing::info!("event bus: NATS ({})", cfg.url);
Ok(nats::create_publisher(cfg).await?)
}
}
}
fn init_tracing() {
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG")
.unwrap_or_else(|_| "presentation=debug,tower_http=debug".into()),
))
.with(tracing_subscriber::fmt::layer())
.init();
}

View File

@@ -3,7 +3,5 @@ pub mod import;
pub mod integrations;
pub mod movies;
pub mod search;
#[cfg(feature = "federation")]
pub mod social;
pub mod users;
pub mod watchlist;

View File

@@ -1,9 +0,0 @@
use api_types::RemoteActorDto;
pub fn remote_actor_to_dto(a: activitypub::RemoteActor) -> RemoteActorDto {
RemoteActorDto {
handle: a.handle,
display_name: a.display_name,
url: a.url,
}
}

View File

@@ -1,4 +1,4 @@
use application::users::get_profile::PendingFollowerView;
use application::users::get_local_profile::PendingFollowerView;
use domain::models::RemoteActorInfo;
use domain::models::UserSummary;
use template_askama::{RemoteActorData, RemoteActorDisplay, UserSummaryView};

View File

@@ -1,7 +1,8 @@
#[cfg(feature = "federation")]
use api_types::{
ActorListResponse, ActorUrlRequest, AddBlockedDomainRequest, BlockedActorResponse,
BlockedDomainResponse, FollowRequest, RemoteActorDto,
BlockedDomainResponse, FollowRelationResponse, FollowRequest, PendingCountResponse,
RemoteActorDto,
};
#[cfg(feature = "federation")]
use utoipa::OpenApi;
@@ -13,6 +14,9 @@ use utoipa::OpenApi;
crate::handlers::social::get_following,
crate::handlers::social::get_followers,
crate::handlers::social::get_pending_followers,
crate::handlers::social::get_pending_follower_count,
crate::handlers::social::get_pending_following,
crate::handlers::social::get_relationship,
crate::handlers::social::follow,
crate::handlers::social::unfollow,
crate::handlers::social::accept_follower,
@@ -33,6 +37,8 @@ use utoipa::OpenApi;
BlockedDomainResponse,
AddBlockedDomainRequest,
BlockedActorResponse,
FollowRelationResponse,
PendingCountResponse,
))
)]
pub struct SocialDoc;

View File

@@ -184,22 +184,22 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
routing::get(handlers::wrapup::get_global_wrapup_html),
);
let base = base.merge(social_html_routes());
#[cfg(feature = "federation")]
let base = base.merge(federation_html_routes());
base.layer(axum::middleware::from_fn(crate::csrf::csrf_middleware))
}
#[cfg(feature = "federation")]
fn federation_html_routes() -> Router<AppState> {
Router::new()
fn social_html_routes() -> Router<AppState> {
let base = Router::new()
.route(
"/users/{id}/follow",
routing::post(handlers::social::follow_remote_user),
routing::post(handlers::social::follow_user),
)
.route(
"/users/{id}/unfollow",
routing::post(handlers::social::unfollow_remote_user),
routing::post(handlers::social::unfollow_user),
)
.route(
"/users/{id}/followers/accept",
@@ -210,12 +210,8 @@ fn federation_html_routes() -> Router<AppState> {
routing::post(handlers::social::reject_follower_html),
)
.route(
"/users/{id}/followers",
routing::get(handlers::social::get_followers_collection),
)
.route(
"/users/{id}/following",
routing::get(handlers::social::get_following_collection),
"/users/{id}/followers/remove",
routing::post(handlers::social::remove_follower_html),
)
.route(
"/users/{id}/following-list",
@@ -224,10 +220,40 @@ fn federation_html_routes() -> Router<AppState> {
.route(
"/users/{id}/followers-list",
routing::get(handlers::social::get_followers_page),
);
#[cfg(not(feature = "federation"))]
let base = base
.route(
"/users/{id}/followers",
routing::get(
|axum::extract::Path(id): axum::extract::Path<uuid::Uuid>| async move {
axum::response::Redirect::permanent(&format!("/users/{}/followers-list", id))
},
),
)
.route(
"/users/{id}/followers/remove",
routing::post(handlers::social::remove_follower_html),
"/users/{id}/following",
routing::get(
|axum::extract::Path(id): axum::extract::Path<uuid::Uuid>| async move {
axum::response::Redirect::permanent(&format!("/users/{}/following-list", id))
},
),
);
base
}
#[cfg(feature = "federation")]
fn federation_html_routes() -> Router<AppState> {
Router::new()
.route(
"/users/{id}/followers",
routing::get(handlers::social::get_followers_collection),
)
.route(
"/users/{id}/following",
routing::get(handlers::social::get_following_collection),
)
.route(
"/admin/blocked-domains",
@@ -455,6 +481,7 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
routing::get(handlers::goals::get_settings).put(handlers::goals::update_settings),
);
let base = base.merge(social_api_routes());
#[cfg(feature = "federation")]
let base = base.merge(federation_api_routes());
@@ -481,8 +508,7 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
.layer(cors_layer())
}
#[cfg(feature = "federation")]
fn federation_api_routes() -> Router<AppState> {
fn social_api_routes() -> Router<AppState> {
Router::new()
.route(
"/social/following",
@@ -496,6 +522,18 @@ fn federation_api_routes() -> Router<AppState> {
"/social/followers/pending",
routing::get(handlers::social::get_pending_followers),
)
.route(
"/social/followers/pending/count",
routing::get(handlers::social::get_pending_follower_count),
)
.route(
"/social/following/pending",
routing::get(handlers::social::get_pending_following),
)
.route(
"/social/relationship",
routing::get(handlers::social::get_relationship),
)
.route("/social/follow", routing::post(handlers::social::follow))
.route(
"/social/unfollow",
@@ -513,6 +551,19 @@ fn federation_api_routes() -> Router<AppState> {
"/social/followers/remove",
routing::post(handlers::social::remove_follower),
)
.route(
"/users/{id}/following",
routing::get(handlers::social::get_user_following),
)
.route(
"/users/{id}/followers",
routing::get(handlers::social::get_user_followers),
)
}
#[cfg(feature = "federation")]
fn federation_api_routes() -> Router<AppState> {
Router::new()
.route(
"/admin/blocked-domains",
routing::get(handlers::social::get_blocked_domains_admin)
@@ -534,12 +585,4 @@ fn federation_api_routes() -> Router<AppState> {
"/social/blocked",
routing::get(handlers::social::get_blocked_actors_api),
)
.route(
"/users/{id}/following",
routing::get(handlers::social::get_user_following),
)
.route(
"/users/{id}/followers",
routing::get(handlers::social::get_user_followers),
)
}

View File

@@ -1,4 +1,4 @@
use crate::extractors::tests::{Panic, make_test_state};
use crate::extractors::tests::{AcceptingAuth, test_app_state, test_app_state_with_follow_graph};
use axum::{
Router,
body::Body,
@@ -73,9 +73,7 @@ impl domain::ports::PersonQuery for PersonQueryStub {
#[tokio::test]
async fn search_endpoint_returns_200_with_empty_results() {
let mut state = make_test_state(Arc::new(Panic));
// Override the search_port with our stub
state.app_ctx.repos.search_port = Arc::new(SearchPortStub);
let state = crate::extractors::tests::test_app_state_with_search_port(Arc::new(SearchPortStub));
let app = Router::new()
.route("/api/v1/search", get(crate::handlers::search::get_search))
.with_state(state);
@@ -95,9 +93,7 @@ async fn search_endpoint_returns_200_with_empty_results() {
#[tokio::test]
async fn search_endpoint_with_no_query_returns_200() {
let mut state = make_test_state(Arc::new(Panic));
// Override the search_port with our stub
state.app_ctx.repos.search_port = Arc::new(SearchPortStub);
let state = crate::extractors::tests::test_app_state_with_search_port(Arc::new(SearchPortStub));
let app = Router::new()
.route("/api/v1/search", get(crate::handlers::search::get_search))
.with_state(state);
@@ -119,9 +115,8 @@ async fn search_endpoint_with_no_query_returns_200() {
#[tokio::test]
async fn person_endpoint_returns_404_for_unknown_id() {
let mut state = make_test_state(Arc::new(Panic));
// Override the person_query with our stub
state.app_ctx.repos.person_query = Arc::new(PersonQueryStub);
let state =
crate::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub));
let app = Router::new()
.route(
"/api/v1/people/{id}",
@@ -145,9 +140,8 @@ async fn person_endpoint_returns_404_for_unknown_id() {
#[tokio::test]
async fn person_credits_endpoint_returns_404_for_unknown_id() {
let mut state = make_test_state(Arc::new(Panic));
// Override the person_query with our stub
state.app_ctx.repos.person_query = Arc::new(PersonQueryStub);
let state =
crate::extractors::tests::test_app_state_with_person_query(Arc::new(PersonQueryStub));
let app = Router::new()
.route(
"/api/v1/people/{id}/credits",
@@ -169,11 +163,30 @@ async fn person_credits_endpoint_returns_404_for_unknown_id() {
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
/// Proves stub injection reaches the prebuilt deps, not just the `Repositories`
/// passed into `build_test_state_from`. Before the fix that added construct-time
/// stub injection, this failed on the assertion below — exactly the false-green
/// that would have silently broken the person-handler tests in Task 6. `AppContext`
/// no longer carries a `repos` field (Plan C2), so the original companion
/// assertion on `state.app_ctx.repos.person_query` was dropped along with the
/// field; the assertion that matters — that `deps` itself holds the stub — stays.
#[test]
fn injected_stub_reaches_prebuilt_deps() {
let stub: Arc<dyn domain::ports::PersonQuery> = Arc::new(PersonQueryStub);
let state = crate::extractors::tests::test_app_state_with_person_query(Arc::clone(&stub));
assert!(
Arc::ptr_eq(&state.app_ctx.deps.person.get_person.person_query, &stub),
"prebuilt deps must hold the injected stub too, or handlers reading deps \
will silently use the real dependency"
);
}
// --- Watchlist endpoint tests ---
#[tokio::test]
async fn get_watchlist_requires_auth() {
let state = make_test_state(Arc::new(Panic));
let state = test_app_state();
let app = Router::new()
.route(
"/api/v1/watchlist",
@@ -194,9 +207,103 @@ async fn get_watchlist_requires_auth() {
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// --- Pending-follower-count endpoint tests ---
/// Stub `FollowGraphQuery` returning a fixed pending-follower count.
struct FixedPendingCount(usize);
#[async_trait::async_trait]
impl domain::ports::FollowGraphQuery for FixedPendingCount {
async fn get_following(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
unreachable!()
}
async fn get_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
unreachable!()
}
async fn get_pending_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
unreachable!()
}
async fn get_pending_following(
&self,
_: &domain::value_objects::UserId,
) -> Result<Vec<domain::value_objects::SocialActor>, DomainError> {
unreachable!()
}
async fn count_following(
&self,
_: &domain::value_objects::UserId,
) -> Result<usize, DomainError> {
unreachable!()
}
async fn count_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<usize, DomainError> {
unreachable!()
}
async fn count_pending_followers(
&self,
_: &domain::value_objects::UserId,
) -> Result<usize, DomainError> {
Ok(self.0)
}
async fn get_relation(
&self,
_: &domain::value_objects::UserId,
_: &domain::value_objects::SocialIdentity,
) -> Result<domain::value_objects::FollowRelation, DomainError> {
unreachable!()
}
}
/// The point of Task 4: one use case (`social::count_pending_followers`) now
/// serves both the classic UI (via `get_page_viewer`) and this new HTTP
/// endpoint. Seed the query port with a fixed count and assert the endpoint
/// reflects it verbatim.
#[tokio::test]
async fn pending_count_endpoint_reports_the_use_case_result() {
let uid = domain::value_objects::UserId::from_uuid(Uuid::new_v4());
let state = test_app_state_with_follow_graph(
Arc::new(FixedPendingCount(3)),
Arc::new(AcceptingAuth(uid)),
);
let app = Router::new()
.route(
"/api/v1/social/followers/pending/count",
get(crate::handlers::social::get_pending_follower_count),
)
.with_state(state);
let resp = app
.oneshot(
Request::builder()
.uri("/api/v1/social/followers/pending/count")
.header("authorization", "Bearer test-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["count"], 3);
}
#[tokio::test]
async fn get_watchlist_status_requires_auth() {
let state = make_test_state(Arc::new(Panic));
let state = test_app_state();
let app = Router::new()
.route(
"/api/v1/watchlist/{movie_id}",
@@ -216,3 +323,88 @@ async fn get_watchlist_status_requires_auth() {
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
// --- Federation collection error-handling tests ---
#[cfg(feature = "federation")]
struct FailingApDocument;
#[cfg(feature = "federation")]
#[async_trait::async_trait]
impl domain::ports::ApDocumentPort for FailingApDocument {
async fn actor_json(&self, _: &str) -> Result<String, DomainError> {
Err(DomainError::InfrastructureError("boom".into()))
}
async fn followers_collection_json(
&self,
_: Uuid,
_: Option<u32>,
) -> Result<String, DomainError> {
Err(DomainError::InfrastructureError("boom".into()))
}
async fn following_collection_json(
&self,
_: Uuid,
_: Option<u32>,
) -> Result<String, DomainError> {
Err(DomainError::InfrastructureError("boom".into()))
}
}
/// Swapping `ap_document` on the returned state is safe — unlike the `repos`
/// fields, handlers read `app_ctx.ap_document` directly and no prebuilt deps
/// struct carries a copy of it.
#[cfg(feature = "federation")]
fn state_with_failing_ap_document() -> crate::state::AppState {
let mut state = test_app_state();
state.app_ctx.ap_document = Arc::new(FailingApDocument);
state
}
#[cfg(feature = "federation")]
#[tokio::test]
async fn followers_collection_returns_500_on_federation_error() {
let app = Router::new()
.route(
"/users/{id}/followers",
get(crate::handlers::social::get_followers_collection),
)
.with_state(state_with_failing_ap_document());
let resp = app
.oneshot(
Request::builder()
.uri(format!("/users/{}/followers", Uuid::nil()))
.header(axum::http::header::ACCEPT, "application/activity+json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[cfg(feature = "federation")]
#[tokio::test]
async fn following_collection_returns_500_on_federation_error() {
let app = Router::new()
.route(
"/users/{id}/following",
get(crate::handlers::social::get_following_collection),
)
.with_state(state_with_failing_ap_document());
let resp = app
.oneshot(
Request::builder()
.uri(format!("/users/{}/following", Uuid::nil()))
.header(axum::http::header::ACCEPT, "application/activity+json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}

View File

@@ -0,0 +1,35 @@
/// The prebuilt deps must point at the SAME port instances as the `Repositories`
/// they were built from. If build_deps is wired to a different Arc — or is called
/// before a repo is swapped — handlers in Plan B silently talk to the wrong
/// dependency, and no other test in Plan A would catch it.
///
/// `AppContext` no longer carries a `repos` field (Plan C2 deleted it), so this
/// test builds its own `Repositories` locally, keeps a clone of the Arcs it
/// cares about, and hands the original to `build_test_state_from` — the same
/// construction `test_app_state()` uses internally.
#[test]
fn prebuilt_deps_share_port_instances_with_repos() {
use std::sync::Arc;
let repos = crate::extractors::tests::test_repositories();
let user = Arc::clone(&repos.user);
let follow_graph = Arc::clone(&repos.follow_graph);
let state = crate::extractors::tests::build_test_state_from(
repos,
Arc::new(crate::extractors::tests::Panic),
);
assert!(
Arc::ptr_eq(&state.app_ctx.deps.users.get_local_profile.user, &user),
"deps.users.get_local_profile.user must be the same Arc as the Repositories it was built from"
);
assert!(
Arc::ptr_eq(&state.app_ctx.deps.social.query.follow_graph, &follow_graph),
"deps.social.query.follow_graph must be the same Arc as the Repositories it was built from"
);
assert_eq!(
state.app_ctx.deps.users.get_local_profile.instance, state.app_ctx.instance,
"prebuilt deps must carry the context's InstanceIdentity"
);
}

View File

@@ -1,5 +1,5 @@
use super::*;
use crate::context::{AppContext, Repositories, Services};
use crate::context::AppContext;
use application::config::AppConfig;
use axum::{
Router,
@@ -7,6 +7,7 @@ use axum::{
http::{Request, StatusCode},
routing::get,
};
use composition::Repositories;
use domain::{
errors::DomainError,
events::DomainEvent,
@@ -17,11 +18,12 @@ use domain::{
collections::{PageParams, Paginated},
},
ports::{
AuthService, DiaryQuery, EventPublisher, MetadataClient, MovieCommand, MovieQuery,
ObjectStorage, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
AuthService, DiaryQuery, EventPublisher, FollowGraphQuery, MetadataClient, MovieCommand,
MovieQuery, ObjectStorage, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserRepository,
WatchlistRepository,
},
testing::InMemoryUserRepository,
value_objects::{
Email, ExternalMetadataId, MovieId, MovieTitle, PasswordHash, PosterUrl, ReleaseYear,
ReviewId, UserId,
@@ -761,74 +763,232 @@ impl application::ports::ReviewLogger for Panic {
}
}
// --- Single state factory — only auth_service varies ---
// --- Repositories factory — `Panic` stubs everywhere ---
pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppState {
pub(crate) fn test_repositories() -> Repositories {
let repo = Arc::new(Panic);
Repositories {
movie_command: Arc::clone(&repo) as _,
movie_query: Arc::clone(&repo) as _,
review: Arc::clone(&repo) as _,
diary: Arc::clone(&repo) as _,
stats: Arc::clone(&repo) as _,
user: Arc::clone(&repo) as _,
import_session: Arc::clone(&repo) as _,
import_profile: Arc::clone(&repo) as _,
movie_profile: Arc::clone(&repo) as _,
watchlist: Arc::clone(&repo) as _,
watch_event_command: Arc::clone(&repo) as _,
watch_event_query: Arc::clone(&repo) as _,
webhook_token: Arc::clone(&repo) as _,
profile_fields: Arc::clone(&repo) as _,
person_command: Arc::clone(&repo) as _,
person_query: Arc::clone(&repo) as _,
search_port: Arc::clone(&repo) as _,
search_command: Arc::clone(&repo) as _,
remote_watchlist: Arc::clone(&repo) as _,
social_command: Arc::new(domain::ports::noop::NoopSocialCommand) as _,
follow_graph: Arc::new(domain::ports::noop::NoopSocialQuery) as _,
block_query: Arc::new(domain::ports::noop::NoopSocialQuery) as _,
federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery) as _,
wrapup_stats: Arc::clone(&repo) as _,
wrapup_repo: Arc::clone(&repo) as _,
goal_command: Arc::clone(&repo) as _,
goal_query: Arc::clone(&repo) as _,
user_settings: Arc::clone(&repo) as _,
remote_goal: Arc::clone(&repo) as _,
refresh_session: Arc::clone(&repo) as _,
federated_profile: None,
}
}
// --- Single state factory — builds `deps` from whatever `repos` it is given ---
/// Builds an `AppState` from caller-supplied `repos`, running `composition::build_deps`
/// AFTER any overrides the caller already applied to `repos`. This is the only place
/// `deps` gets built for tests, so `repos` and `deps` can never disagree.
pub(crate) fn build_test_state_from(
repos: Repositories,
auth_service: Arc<dyn AuthService>,
) -> crate::state::AppState {
let repo = Arc::new(Panic);
let services = application::Services {
auth: auth_service,
password_hasher: Arc::clone(&repo) as _,
metadata: Arc::clone(&repo) as _,
poster_fetcher: Arc::clone(&repo) as _,
object_storage: Arc::clone(&repo) as _,
event_publisher: Arc::clone(&repo) as _,
diary_exporter: Arc::clone(&repo) as _,
document_parser: Arc::clone(&repo) as _,
review_logger: Arc::clone(&repo) as _,
person_enrichment: None,
};
let config = AppConfig {
allow_registration: false,
base_url: "http://localhost:3000".to_string(),
rate_limit: 20,
refresh_ttl_seconds: 2_592_000,
wrapup: application::config::WrapUpConfig {
font_path: None,
logo_path: None,
bg_dir: None,
},
};
let instance = domain::value_objects::InstanceIdentity::new("http://localhost:3000");
let deps = Arc::new(composition::build_deps(
&repos, &services, &config, &instance,
));
crate::state::AppState {
app_ctx: AppContext {
repos: Repositories {
movie_command: Arc::clone(&repo) as _,
movie_query: Arc::clone(&repo) as _,
review: Arc::clone(&repo) as _,
diary: Arc::clone(&repo) as _,
stats: Arc::clone(&repo) as _,
user: Arc::clone(&repo) as _,
import_session: Arc::clone(&repo) as _,
import_profile: Arc::clone(&repo) as _,
movie_profile: Arc::clone(&repo) as _,
watchlist: Arc::clone(&repo) as _,
watch_event_command: Arc::clone(&repo) as _,
watch_event_query: Arc::clone(&repo) as _,
webhook_token: Arc::clone(&repo) as _,
profile_fields: Arc::clone(&repo) as _,
person_command: Arc::clone(&repo) as _,
person_query: Arc::clone(&repo) as _,
search_port: Arc::clone(&repo) as _,
search_command: Arc::clone(&repo) as _,
remote_watchlist: Arc::clone(&repo) as _,
social_command: Arc::new(domain::ports::noop::NoopSocialCommand) as _,
social_query_unified: Arc::new(domain::ports::noop::NoopSocialQuery) as _,
federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery) as _,
wrapup_stats: Arc::clone(&repo) as _,
wrapup_repo: Arc::clone(&repo) as _,
goal_command: Arc::clone(&repo) as _,
goal_query: Arc::clone(&repo) as _,
user_settings: Arc::clone(&repo) as _,
remote_goal: Arc::clone(&repo) as _,
refresh_session: Arc::clone(&repo) as _,
federated_profile: None,
},
services: Services {
auth: auth_service,
password_hasher: Arc::clone(&repo) as _,
metadata: Arc::clone(&repo) as _,
poster_fetcher: Arc::clone(&repo) as _,
object_storage: Arc::clone(&repo) as _,
event_publisher: Arc::clone(&repo) as _,
diary_exporter: Arc::clone(&repo) as _,
document_parser: Arc::clone(&repo) as _,
review_logger: Arc::clone(&repo) as _,
person_enrichment: None,
#[cfg(feature = "federation")]
ap_service: Arc::new(activitypub::NoopActivityPubService),
},
config: AppConfig {
allow_registration: false,
base_url: "http://localhost:3000".to_string(),
rate_limit: 20,
refresh_ttl_seconds: 2_592_000,
wrapup: application::config::WrapUpConfig {
font_path: None,
logo_path: None,
bg_dir: None,
},
},
deps,
services,
config,
instance,
#[cfg(feature = "federation")]
ap_document: Arc::new(domain::ports::noop::NoopApDocument),
#[cfg(feature = "federation")]
ap_blocklist: Arc::new(domain::ports::noop::NoopInstanceBlocklist),
},
rss_renderer: Arc::new(Panic),
}
}
pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppState {
build_test_state_from(test_repositories(), auth_service)
}
/// Reusable default test `AppState`: `Panic` stubs everywhere, real prebuilt `deps`.
/// Callers that need a specific stub swapped in should use one of the
/// `test_app_state_with_*` constructors below, which inject BEFORE `build_deps`
/// runs — mutating the returned `AppState` afterwards would only reach `repos`,
/// not the prebuilt `deps`.
pub(crate) fn test_app_state() -> crate::state::AppState {
make_test_state(Arc::new(Panic))
}
/// Test state with `person_query` overridden BEFORE `build_deps` runs, so the
/// stub is visible from both `app_ctx.repos.person_query` and every prebuilt
/// deps struct that carries a `person_query` (e.g. `deps.person.get_person`).
pub(crate) fn test_app_state_with_person_query(
person_query: Arc<dyn PersonQuery>,
) -> crate::state::AppState {
let mut repos = test_repositories();
repos.person_query = person_query;
build_test_state_from(repos, Arc::new(Panic))
}
/// Test state with `search_port` overridden BEFORE `build_deps` runs, so the
/// stub is visible from both `app_ctx.repos.search_port` and any prebuilt deps
/// struct that carries a `search_port`.
pub(crate) fn test_app_state_with_search_port(
search_port: Arc<dyn SearchPort>,
) -> crate::state::AppState {
let mut repos = test_repositories();
repos.search_port = search_port;
build_test_state_from(repos, Arc::new(Panic))
}
/// Test state with `follow_graph` overridden BEFORE `build_deps` runs, so the
/// stub is visible from both `app_ctx.repos.follow_graph` and the prebuilt
/// `deps.social.query` that the social handlers actually read from.
pub(crate) fn test_app_state_with_follow_graph(
follow_graph: Arc<dyn FollowGraphQuery>,
auth_service: Arc<dyn AuthService>,
) -> crate::state::AppState {
let mut repos = test_repositories();
repos.follow_graph = follow_graph;
build_test_state_from(repos, auth_service)
}
/// Test state with `user` overridden BEFORE `build_deps` runs, so the stub is
/// visible from both `app_ctx.repos.user` and `deps.users.authorize_admin.user`
/// — the field the `AdminApiUser`/`AdminUser` extractors now read through.
pub(crate) fn test_app_state_with_user(
user: Arc<dyn UserRepository>,
auth_service: Arc<dyn AuthService>,
) -> crate::state::AppState {
let mut repos = test_repositories();
repos.user = user;
build_test_state_from(repos, auth_service)
}
/// A `UserRepository` whose `find_by_id` always fails, for exercising the
/// repository-error rejection path of the admin extractors. Every other
/// method panics — the admin extractors only ever call `find_by_id`.
pub(crate) struct ErroringUserRepo;
#[async_trait::async_trait]
impl UserRepository for ErroringUserRepo {
async fn find_by_email(
&self,
_: &domain::value_objects::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> {
Err(DomainError::InfrastructureError("db down".into()))
}
async fn find_by_username(
&self,
_: &domain::value_objects::Username,
) -> Result<Option<domain::models::User>, DomainError> {
panic!()
}
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
panic!()
}
async fn update_profile(
&self,
_: &UserId,
_: &domain::models::UserProfile,
) -> Result<(), DomainError> {
panic!()
}
}
/// Builds a bare `User` with the given role, for the admin/non-admin extractor
/// tests — only `id()` and `role()` are read by `authorize_admin::execute`.
fn make_user_with_role(id: UserId, role: domain::models::UserRole) -> domain::models::User {
domain::models::User::from_persistence(
id,
domain::value_objects::Email::new("extractor-test@example.com".into()).unwrap(),
domain::value_objects::Username::new("extractor_test_user".into()).unwrap(),
domain::value_objects::PasswordHash::new("hashed".into()).unwrap(),
role,
domain::models::UserProfile {
display_name: None,
bio: None,
avatar_path: None,
banner_path: None,
also_known_as: None,
profile_fields: vec![],
},
)
}
/// Auth stub that accepts any bearer token and resolves it to a fixed user id,
/// for tests that need to authenticate as a specific user rather than merely
/// exercise the unauthenticated-request path.
pub(crate) struct AcceptingAuth(pub UserId);
#[async_trait::async_trait]
impl AuthService for AcceptingAuth {
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> {
panic!()
}
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
Ok(self.0.clone())
}
}
// --- Routers ---
async fn protected_handler(user: AuthenticatedUser) -> String {
@@ -843,6 +1003,12 @@ async fn optional_cookie_handler(user: OptionalCookieUser) -> String {
async fn required_cookie_handler(user: RequiredCookieUser) -> String {
user.0.value().to_string()
}
async fn admin_api_handler(user: AdminApiUser) -> String {
user.0.value().to_string()
}
async fn admin_cookie_handler(user: AdminUser) -> String {
user.0.value().to_string()
}
fn router_protected(state: crate::state::AppState) -> Router {
Router::new()
@@ -854,6 +1020,16 @@ fn router_optional(state: crate::state::AppState) -> Router {
.route("/optional", get(optional_cookie_handler))
.with_state(state)
}
fn router_admin_api(state: crate::state::AppState) -> Router {
Router::new()
.route("/admin-api", get(admin_api_handler))
.with_state(state)
}
fn router_admin_cookie(state: crate::state::AppState) -> Router {
Router::new()
.route("/admin-cookie", get(admin_cookie_handler))
.with_state(state)
}
fn router_required(state: crate::state::AppState) -> Router {
Router::new()
.route("/required", get(required_cookie_handler))
@@ -864,7 +1040,7 @@ fn router_required(state: crate::state::AppState) -> Router {
#[tokio::test]
async fn missing_auth_header_returns_401() {
let app = router_protected(make_test_state(Arc::new(Panic)));
let app = router_protected(test_app_state());
let resp = app
.oneshot(
Request::builder()
@@ -879,7 +1055,7 @@ async fn missing_auth_header_returns_401() {
#[tokio::test]
async fn optional_cookie_user_returns_none_without_cookie() {
let app = router_optional(make_test_state(Arc::new(Panic)));
let app = router_optional(test_app_state());
let resp = app
.oneshot(
Request::builder()
@@ -918,7 +1094,7 @@ async fn optional_cookie_user_returns_none_with_invalid_token() {
#[tokio::test]
async fn required_cookie_user_redirects_without_cookie() {
let app = router_required(make_test_state(Arc::new(Panic)));
let app = router_required(test_app_state());
let resp = app
.oneshot(
Request::builder()
@@ -948,3 +1124,197 @@ async fn required_cookie_user_redirects_with_invalid_token() {
assert_eq!(resp.status(), StatusCode::SEE_OTHER);
assert_eq!(resp.headers().get("location").unwrap(), "/login");
}
// --- AdminApiUser / AdminUser: per-path rejection pinning ---
//
// These pin the exact rejection for every path `authorize_admin::execute` can
// produce, now that both extractors go through it instead of calling
// `repos.user.find_by_id` directly. Each test name states the path; the
// asserted status is the byte-identical-to-before-this-task value recorded in
// the task-4 report's before/after table.
fn admin_id() -> UserId {
UserId::generate()
}
#[tokio::test]
async fn admin_api_user_allows_an_admin_user() {
let id = admin_id();
let user_repo = InMemoryUserRepository::new();
user_repo.store.lock().unwrap().insert(
id.value(),
make_user_with_role(id.clone(), domain::models::UserRole::Admin),
);
let app = router_admin_api(test_app_state_with_user(
user_repo as _,
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-api")
.header("authorization", "Bearer anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn admin_api_user_rejects_a_non_admin_user() {
let id = admin_id();
let user_repo = InMemoryUserRepository::new();
user_repo.store.lock().unwrap().insert(
id.value(),
make_user_with_role(id.clone(), domain::models::UserRole::Standard),
);
let app = router_admin_api(test_app_state_with_user(
user_repo as _,
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-api")
.header("authorization", "Bearer anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn admin_api_user_rejects_an_unknown_user() {
let id = admin_id();
let user_repo = InMemoryUserRepository::new(); // empty — id is not in the store
let app = router_admin_api(test_app_state_with_user(
user_repo as _,
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-api")
.header("authorization", "Bearer anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn admin_api_user_surfaces_a_repository_error_as_500() {
let id = admin_id();
let app = router_admin_api(test_app_state_with_user(
Arc::new(ErroringUserRepo),
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-api")
.header("authorization", "Bearer anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn admin_user_allows_an_admin_user() {
let id = admin_id();
let user_repo = InMemoryUserRepository::new();
user_repo.store.lock().unwrap().insert(
id.value(),
make_user_with_role(id.clone(), domain::models::UserRole::Admin),
);
let app = router_admin_cookie(test_app_state_with_user(
user_repo as _,
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-cookie")
.header("cookie", "token=anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn admin_user_rejects_a_non_admin_user() {
let id = admin_id();
let user_repo = InMemoryUserRepository::new();
user_repo.store.lock().unwrap().insert(
id.value(),
make_user_with_role(id.clone(), domain::models::UserRole::Standard),
);
let app = router_admin_cookie(test_app_state_with_user(
user_repo as _,
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-cookie")
.header("cookie", "token=anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn admin_user_rejects_an_unknown_user() {
let id = admin_id();
let user_repo = InMemoryUserRepository::new(); // empty — id is not in the store
let app = router_admin_cookie(test_app_state_with_user(
user_repo as _,
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-cookie")
.header("cookie", "token=anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn admin_user_surfaces_a_repository_error_as_500() {
let id = admin_id();
let app = router_admin_cookie(test_app_state_with_user(
Arc::new(ErroringUserRepo),
Arc::new(AcceptingAuth(id)),
));
let resp = app
.oneshot(
Request::builder()
.uri("/admin-cookie")
.header("cookie", "token=anything")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}

View File

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

View File

@@ -1,610 +0,0 @@
use std::sync::Arc;
use application::config::AppConfig;
use async_trait::async_trait;
use axum::{
Router,
body::Body,
http::{Request, StatusCode},
};
use domain::{
errors::DomainError,
events::DomainEvent,
models::{
EntityType, ExternalPersonId, GeneratedToken, IndexableDocument, MetadataSearchCriteria,
Movie, Person, PersonCredits, PersonEnrichmentData, PersonId, SearchQuery, SearchResults,
User,
},
ports::{
AuthService, EventPublisher, MetadataClient, ObjectStorage, PasswordHasher, PersonCommand,
PersonQuery, PosterFetcherClient, SearchCommand, SearchPort, UserRepository,
},
value_objects::{Email, ExternalMetadataId, PasswordHash, PosterUrl, UserId},
};
use http_body_util::BodyExt;
use presentation::context::{AppContext, Repositories, Services};
use presentation::{routes, state::AppState};
use rss::RssAdapter;
use sqlite::{
SqliteDiaryRepository, SqliteMovieRepository, SqliteReviewRepository, SqliteStatsRepository,
migrate as sqlite_migrate,
};
use sqlx::SqlitePool;
use tower::ServiceExt;
struct NoopEventPublisher;
#[async_trait]
impl EventPublisher for NoopEventPublisher {
async fn publish(&self, _: &DomainEvent) -> Result<(), DomainError> {
Ok(())
}
}
struct PanicReviewLogger;
#[async_trait]
impl application::ports::ReviewLogger for PanicReviewLogger {
async fn log_review(
&self,
_: application::diary::commands::LogReviewCommand,
) -> Result<(), DomainError> {
panic!("review_logger not wired in tests")
}
}
struct PanicMeta;
#[async_trait]
impl MetadataClient for PanicMeta {
async fn fetch_movie_metadata(&self, _: &MetadataSearchCriteria) -> Result<Movie, DomainError> {
panic!("metadata not wired in tests")
}
async fn get_poster_url(
&self,
_: &ExternalMetadataId,
) -> Result<Option<PosterUrl>, DomainError> {
panic!()
}
}
struct PanicFetcher;
#[async_trait]
impl PosterFetcherClient for PanicFetcher {
async fn fetch_poster_bytes(&self, _: &PosterUrl) -> Result<Vec<u8>, DomainError> {
panic!()
}
}
struct PanicObjectStorage;
#[async_trait]
impl ObjectStorage for PanicObjectStorage {
async fn store(&self, _: &str, _: &[u8]) -> Result<String, DomainError> {
panic!()
}
async fn get(&self, _: &str) -> Result<Vec<u8>, DomainError> {
panic!()
}
async fn get_stream(
&self,
_: &str,
) -> Result<futures::stream::BoxStream<'static, Result<bytes::Bytes, DomainError>>, DomainError>
{
panic!()
}
async fn delete(&self, _: &str) -> Result<(), DomainError> {
panic!()
}
}
struct PanicHasher;
#[async_trait]
impl PasswordHasher for PanicHasher {
async fn hash(&self, _: &str) -> Result<PasswordHash, DomainError> {
panic!()
}
async fn verify(&self, _: &str, _: &PasswordHash) -> Result<bool, DomainError> {
panic!()
}
}
struct PanicAuth;
#[async_trait]
impl AuthService for PanicAuth {
async fn generate_token(&self, _: &UserId) -> Result<GeneratedToken, DomainError> {
panic!()
}
async fn validate_token(&self, _: &str) -> Result<UserId, DomainError> {
panic!()
}
}
struct NobodyUserRepo;
#[async_trait]
impl UserRepository for NobodyUserRepo {
async fn find_by_email(&self, _: &Email) -> Result<Option<User>, DomainError> {
Ok(None)
}
async fn find_by_username(
&self,
_: &domain::value_objects::Username,
) -> Result<Option<User>, DomainError> {
Ok(None)
}
async fn save(&self, _: &User) -> Result<(), DomainError> {
panic!()
}
async fn find_by_id(&self, _: &UserId) -> Result<Option<User>, DomainError> {
panic!()
}
async fn list_with_stats(&self) -> Result<Vec<domain::models::UserSummary>, DomainError> {
panic!()
}
async fn update_profile(
&self,
_: &UserId,
_: &domain::models::UserProfile,
) -> Result<(), DomainError> {
Ok(())
}
}
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!()
}
}
struct PanicExporter;
impl domain::ports::DiaryExporter for PanicExporter {
fn stream_entries(
&self,
_stream: futures::stream::BoxStream<
'static,
Result<domain::models::DiaryEntry, DomainError>,
>,
_format: domain::models::ExportFormat,
) -> futures::stream::BoxStream<'static, Result<bytes::Bytes, DomainError>> {
panic!("PanicExporter::stream_entries")
}
}
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!()
}
}
struct PanicDocumentParser;
impl domain::ports::DocumentParser for PanicDocumentParser {
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> {
panic!("DocumentParser not wired in tests")
}
}
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_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!()
}
}
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 {
async fn upsert_batch(&self, _: &[Person]) -> Result<(), DomainError> {
panic!()
}
async fn backfill_from_credits_batch(
&self,
_batch_size: u32,
) -> Result<(u64, bool), DomainError> {
panic!()
}
async fn update_enrichment(
&self,
_: &PersonId,
_: &PersonEnrichmentData,
) -> 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 list_page(
&self,
_limit: u32,
_offset: u32,
) -> Result<Vec<domain::models::Person>, DomainError> {
panic!()
}
}
struct PanicSearchPort;
#[async_trait]
impl SearchPort for PanicSearchPort {
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!()
}
}
#[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![])
}
}
async fn test_app() -> Router {
let pool = SqlitePool::connect("sqlite::memory:")
.await
.expect("in-memory SQLite failed");
sqlite_migrate(&pool).await.expect("migration failed");
let state = AppState {
app_ctx: AppContext {
repos: Repositories {
movie_command: Arc::new(SqliteMovieRepository::new(pool.clone())) as _,
movie_query: Arc::new(SqliteMovieRepository::new(pool.clone())) as _,
review: Arc::new(SqliteReviewRepository::new(pool.clone())) as _,
diary: Arc::new(SqliteDiaryRepository::new(pool.clone())) as _,
stats: Arc::new(SqliteStatsRepository::new(pool.clone())) as _,
user: Arc::new(NobodyUserRepo),
import_session: Arc::new(PanicImportSession),
import_profile: Arc::new(PanicImportProfile),
movie_profile: Arc::new(PanicMovieProfile),
watchlist: Arc::new(PanicWatchlist),
watch_event_command: Arc::new(domain::testing::PanicWatchEventCommand),
watch_event_query: Arc::new(domain::testing::PanicWatchEventQuery),
webhook_token: Arc::new(domain::testing::PanicWebhookTokenRepository),
profile_fields: Arc::new(PanicProfileFields),
person_command: Arc::new(PanicPersonCommand),
person_query: Arc::new(PanicPersonQuery),
search_port: Arc::new(PanicSearchPort),
search_command: Arc::new(PanicSearchCommand),
remote_watchlist: Arc::new(PanicRemoteWatchlist),
social_command: Arc::new(domain::ports::noop::NoopSocialCommand),
social_query_unified: Arc::new(domain::ports::noop::NoopSocialQuery),
federation_admin: Arc::new(domain::ports::noop::NoopFederationAdminQuery) as _,
wrapup_stats: Arc::new(domain::testing::PanicWrapUpStatsQuery) as _,
wrapup_repo: Arc::new(domain::testing::PanicWrapUpRepository) as _,
goal_command: Arc::new(domain::testing::NoopGoalCommand),
goal_query: Arc::new(domain::testing::NoopGoalQuery),
user_settings: Arc::new(domain::testing::NoopUserSettingsRepository),
remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository),
refresh_session: Arc::new(domain::testing::PanicRefreshSessionRepository),
federated_profile: None,
},
services: Services {
auth: Arc::new(PanicAuth),
password_hasher: Arc::new(PanicHasher),
metadata: Arc::new(PanicMeta),
poster_fetcher: Arc::new(PanicFetcher),
object_storage: Arc::new(PanicObjectStorage),
event_publisher: Arc::new(NoopEventPublisher),
diary_exporter: Arc::new(PanicExporter),
document_parser: Arc::new(PanicDocumentParser),
review_logger: Arc::new(PanicReviewLogger),
person_enrichment: None,
#[cfg(feature = "federation")]
ap_service: Arc::new(activitypub::NoopActivityPubService),
},
config: AppConfig {
allow_registration: false,
base_url: "http://localhost:3000".to_string(),
rate_limit: 20,
refresh_ttl_seconds: 2_592_000,
wrapup: application::config::WrapUpConfig {
font_path: None,
logo_path: None,
bg_dir: None,
},
},
},
rss_renderer: Arc::new(RssAdapter::new("http://localhost:3000".into())),
};
routes::build_router(state, axum::Router::new())
}
/// Inject a fake peer IP so the GovernorLayer can extract ConnectInfo.
fn with_ip(req: Request<Body>) -> Request<Body> {
let addr: std::net::SocketAddr = "127.0.0.1:12345".parse().unwrap();
let mut req = req;
req.extensions_mut()
.insert(axum::extract::ConnectInfo::<std::net::SocketAddr>(addr));
req
}
#[tokio::test]
async fn get_api_diary_returns_empty_list() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.uri("/api/v1/diary")
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(json["total_count"], 0);
assert_eq!(json["items"], serde_json::json!([]));
assert_eq!(json["limit"], 5);
assert_eq!(json["offset"], 0);
}
#[tokio::test]
async fn post_api_reviews_without_auth_returns_401() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.method("POST")
.uri("/api/v1/reviews")
.header("content-type", "application/json")
.body(Body::from(
r#"{"rating":4,"watched_at":"2026-01-01T20:00:00","manual_title":"Dune","manual_release_year":2021}"#,
))
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn post_api_auth_login_unknown_user_returns_401() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.method("POST")
.uri("/api/v1/auth/login")
.header("content-type", "application/json")
.body(Body::from(r#"{"email":"a@b.com","password":"x"}"#))
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn get_api_movie_detail_returns_404_for_unknown_id() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.uri("/api/v1/movies/00000000-0000-0000-0000-000000000000")
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn tags_moviesdiary_redirects_to_home() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.uri("/tags/moviesdiary")
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
assert_eq!(response.headers().get("location").unwrap(), "/");
}
#[tokio::test]
async fn tags_other_redirects_to_search() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.uri("/tags/batman")
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
assert_eq!(
response.headers().get("location").unwrap(),
"/?search=batman"
);
}
#[tokio::test]
async fn get_movie_detail_html_returns_404_for_unknown_id() {
let app = test_app().await;
let response = app
.oneshot(with_ip(
Request::builder()
.uri("/movies/00000000-0000-0000-0000-000000000000")
.body(Body::empty())
.unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}