structural refactor and codebase improvements
This commit is contained in:
@@ -31,3 +31,7 @@ pub struct RegisterAndLoginDeps {
|
||||
pub refresh_session: Arc<dyn RefreshSessionRepository>,
|
||||
pub config: AppConfig,
|
||||
}
|
||||
|
||||
pub struct LogoutDeps {
|
||||
pub refresh_session: Arc<dyn RefreshSessionRepository>,
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
use std::sync::Arc;
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use domain::{errors::DomainError, ports::RefreshSessionRepository};
|
||||
use crate::auth::deps::LogoutDeps;
|
||||
|
||||
pub async fn execute(
|
||||
refresh_session: Arc<dyn RefreshSessionRepository>,
|
||||
refresh_token: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
refresh_session.revoke(refresh_token).await
|
||||
pub async fn execute(deps: &LogoutDeps, refresh_token: &str) -> Result<(), DomainError> {
|
||||
deps.refresh_session.revoke(refresh_token).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -6,7 +6,7 @@ use domain::testing::InMemoryUserRepository;
|
||||
use crate::{
|
||||
auth::{
|
||||
commands::RegisterCommand,
|
||||
deps::{LoginDeps, RefreshDeps, RegisterDeps},
|
||||
deps::{LoginDeps, LogoutDeps, RefreshDeps, RegisterDeps},
|
||||
login, logout,
|
||||
queries::LoginCommand,
|
||||
refresh, register,
|
||||
@@ -53,7 +53,10 @@ async fn logout_revokes_refresh_token() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
logout::execute(b.refresh_session_repo.clone(), &login_result.refresh_token)
|
||||
let logout_deps = LogoutDeps {
|
||||
refresh_session: b.refresh_session_repo.clone(),
|
||||
};
|
||||
logout::execute(&logout_deps, &login_result.refresh_token)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -69,6 +72,9 @@ async fn logout_revokes_refresh_token() {
|
||||
#[tokio::test]
|
||||
async fn logout_with_unknown_token_succeeds() {
|
||||
let b = TestContextBuilder::new();
|
||||
let result = logout::execute(b.refresh_session_repo.clone(), "nonexistent-token").await;
|
||||
let logout_deps = LogoutDeps {
|
||||
refresh_session: b.refresh_session_repo.clone(),
|
||||
};
|
||||
let result = logout::execute(&logout_deps, "nonexistent-token").await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
194
crates/application/src/deps.rs
Normal file
194
crates/application/src/deps.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EventPublisher, MediaServerParser, ObjectStorage, PersonEnrichmentClient};
|
||||
|
||||
use crate::auth::deps::{LoginDeps, LogoutDeps, RefreshDeps, RegisterAndLoginDeps, RegisterDeps};
|
||||
use crate::diary::deps::{
|
||||
DeleteReviewDeps, EditReviewDeps, ExportDiaryDeps, GetActivityFeedDeps, GetDiaryDeps,
|
||||
GetMovieSocialPageDeps, GetReviewHistoryDeps, GetUserFeedDeps,
|
||||
};
|
||||
use crate::goals::deps::{GoalCommandDeps, GoalQueryDeps};
|
||||
use crate::import::deps::{
|
||||
ApplyMappingDeps, ApplyProfileAndMapDeps, ApplyProfileDeps, CreateSessionDeps,
|
||||
DeleteImportProfileDeps, ExecuteImportDeps, GetMappingStageDeps, GetPreviewStageDeps,
|
||||
GetSessionStateDeps, ListImportProfilesDeps, SaveProfileDeps,
|
||||
};
|
||||
use crate::integrations::deps::{
|
||||
ConfirmWatchEventsDeps, DismissWatchEventsDeps, GenerateWebhookTokenDeps, GetWatchQueueDeps,
|
||||
GetWebhookTokensDeps, IngestWatchEventDeps, RevokeWebhookTokenDeps,
|
||||
};
|
||||
use crate::movies::deps::{
|
||||
EnrichMovieDeps, GetMovieProfileDeps, GetMoviesDeps, ReindexSearchDeps, SyncPosterDeps,
|
||||
};
|
||||
use crate::movies::merge_duplicates::MergeDuplicatesDeps;
|
||||
use crate::person::deps::{EnrichPersonDeps, GetPersonDeps};
|
||||
use crate::search::deps::SearchDeps;
|
||||
use crate::social::deps::{SocialCommandDeps, SocialQueryDeps};
|
||||
use crate::users::deps::{
|
||||
AuthorizeAdminDeps, DeleteAccountDeps, GetCurrentProfileDeps, GetFederatedProfileDeps,
|
||||
GetFederatedProfileStatsDeps, GetLocalProfileDeps, GetPageViewerDeps, GetProfileSettingsDeps,
|
||||
GetSettingsDeps, GetUsersListDeps, ResolveUsernameDeps, UpdateProfileDeps,
|
||||
UpdateProfileFieldsDeps, UpdateSettingsDeps,
|
||||
};
|
||||
use crate::watchlist::deps::{
|
||||
GetWatchlistDeps, GetWatchlistForOwnerDeps, IsOnWatchlistDeps, RemoveFromWatchlistDeps,
|
||||
WatchlistAddDeps,
|
||||
};
|
||||
use crate::wrapup::deps::{
|
||||
DeleteWrapUpDeps, GenerateWrapUpDeps, GetReadyReportDeps, GetWrapUpDeps,
|
||||
HandleWrapUpRequestedDeps, ListWrapUpsDeps,
|
||||
};
|
||||
|
||||
pub struct AuthGroup {
|
||||
pub login: LoginDeps,
|
||||
pub register: RegisterDeps,
|
||||
pub refresh: RefreshDeps,
|
||||
pub register_and_login: RegisterAndLoginDeps,
|
||||
pub logout: LogoutDeps,
|
||||
}
|
||||
|
||||
pub struct DiaryGroup {
|
||||
pub delete_review: DeleteReviewDeps,
|
||||
pub edit_review: EditReviewDeps,
|
||||
pub get_movie_social_page: GetMovieSocialPageDeps,
|
||||
pub get_activity_feed: GetActivityFeedDeps,
|
||||
pub get_user_feed: GetUserFeedDeps,
|
||||
pub get_diary: GetDiaryDeps,
|
||||
pub get_review_history: GetReviewHistoryDeps,
|
||||
pub export_diary: ExportDiaryDeps,
|
||||
}
|
||||
|
||||
pub struct GoalsGroup {
|
||||
pub command: GoalCommandDeps,
|
||||
pub query: GoalQueryDeps,
|
||||
}
|
||||
|
||||
pub struct ImportGroup {
|
||||
pub create_session: CreateSessionDeps,
|
||||
pub apply_mapping: ApplyMappingDeps,
|
||||
pub apply_profile: ApplyProfileDeps,
|
||||
pub execute_import: ExecuteImportDeps,
|
||||
pub save_profile: SaveProfileDeps,
|
||||
pub get_mapping_stage: GetMappingStageDeps,
|
||||
pub get_preview_stage: GetPreviewStageDeps,
|
||||
pub get_session_state: GetSessionStateDeps,
|
||||
pub apply_profile_and_map: ApplyProfileAndMapDeps,
|
||||
pub delete_profile: DeleteImportProfileDeps,
|
||||
pub list_profiles: ListImportProfilesDeps,
|
||||
}
|
||||
|
||||
pub struct IntegrationsGroup {
|
||||
pub ingest_watch_event: IngestWatchEventDeps,
|
||||
pub confirm_watch_events: ConfirmWatchEventsDeps,
|
||||
pub dismiss_watch_events: DismissWatchEventsDeps,
|
||||
pub generate_webhook_token: GenerateWebhookTokenDeps,
|
||||
pub get_watch_queue: GetWatchQueueDeps,
|
||||
pub get_webhook_tokens: GetWebhookTokensDeps,
|
||||
pub revoke_webhook_token: RevokeWebhookTokenDeps,
|
||||
/// Webhook payload parsers. Held on the group rather than inside
|
||||
/// `IngestWatchEventDeps` because `ingest::execute` takes the parser as an
|
||||
/// argument — the caller picks which one per route.
|
||||
pub jellyfin_parser: Arc<dyn MediaServerParser>,
|
||||
pub plex_parser: Arc<dyn MediaServerParser>,
|
||||
}
|
||||
|
||||
pub struct MoviesGroup {
|
||||
pub sync_poster: SyncPosterDeps,
|
||||
pub get_movie_profile: GetMovieProfileDeps,
|
||||
pub get_movies: GetMoviesDeps,
|
||||
}
|
||||
|
||||
pub struct PersonGroup {
|
||||
pub get_person: GetPersonDeps,
|
||||
}
|
||||
|
||||
pub struct SearchGroup {
|
||||
pub execute: SearchDeps,
|
||||
}
|
||||
|
||||
pub struct SocialGroup {
|
||||
pub command: SocialCommandDeps,
|
||||
pub query: SocialQueryDeps,
|
||||
}
|
||||
|
||||
pub struct UsersGroup {
|
||||
pub get_local_profile: GetLocalProfileDeps,
|
||||
pub get_federated_profile_stats: GetFederatedProfileStatsDeps,
|
||||
pub get_page_viewer: GetPageViewerDeps,
|
||||
pub resolve_username: ResolveUsernameDeps,
|
||||
pub get_profile_settings: GetProfileSettingsDeps,
|
||||
pub get_users_list: GetUsersListDeps,
|
||||
pub update_profile: UpdateProfileDeps,
|
||||
/// Not reachable from the server binary; see the `Deps`-level note above. Pre-existing dead use case: `users::delete_account::execute` has zero callers anywhere in the workspace, worker included.
|
||||
pub delete_account: DeleteAccountDeps,
|
||||
pub get_current_profile: GetCurrentProfileDeps,
|
||||
pub update_profile_fields: UpdateProfileFieldsDeps,
|
||||
pub get_settings: GetSettingsDeps,
|
||||
pub update_settings: UpdateSettingsDeps,
|
||||
pub authorize_admin: AuthorizeAdminDeps,
|
||||
pub get_federated_profile: GetFederatedProfileDeps,
|
||||
}
|
||||
|
||||
pub struct WatchlistGroup {
|
||||
pub add: WatchlistAddDeps,
|
||||
pub get_watchlist_for_owner: GetWatchlistForOwnerDeps,
|
||||
pub get_watchlist: GetWatchlistDeps,
|
||||
pub is_on_watchlist: IsOnWatchlistDeps,
|
||||
pub remove_from_watchlist: RemoveFromWatchlistDeps,
|
||||
}
|
||||
|
||||
pub struct WrapupGroup {
|
||||
pub get_ready_report: GetReadyReportDeps,
|
||||
pub delete_wrapup: DeleteWrapUpDeps,
|
||||
pub generate: GenerateWrapUpDeps,
|
||||
pub get_wrapup: GetWrapUpDeps,
|
||||
pub list_wrapups: ListWrapUpsDeps,
|
||||
}
|
||||
|
||||
/// Every deps struct a handler can need, built once by the composition root.
|
||||
/// Use cases still receive only their own narrow struct — nothing takes `&Deps`.
|
||||
///
|
||||
/// `composition::build_deps` is called only from `crates/presentation/src/main.rs`.
|
||||
/// The worker-only groups this used to also carry now live in `WorkerDeps`, built by
|
||||
/// `composition::build_worker_deps` and consumed by `crates/worker/src/main.rs` —
|
||||
/// `crates/worker` no longer wires its own deps (its former `db.rs` is gone). Nothing
|
||||
/// in `Deps` below is worker-only anymore, except one field with no consumer anywhere
|
||||
/// in the workspace — see its comment.
|
||||
pub struct Deps {
|
||||
pub auth: AuthGroup,
|
||||
pub diary: DiaryGroup,
|
||||
pub goals: GoalsGroup,
|
||||
pub import: ImportGroup,
|
||||
pub integrations: IntegrationsGroup,
|
||||
pub movies: MoviesGroup,
|
||||
pub person: PersonGroup,
|
||||
pub search: SearchGroup,
|
||||
pub social: SocialGroup,
|
||||
pub users: UsersGroup,
|
||||
pub watchlist: WatchlistGroup,
|
||||
pub wrapup: WrapupGroup,
|
||||
}
|
||||
|
||||
/// Ports the worker binary can actually construct — a strict subset of
|
||||
/// `Services`. The worker has no `auth`, `password_hasher`, `diary_exporter`,
|
||||
/// `document_parser`, or `review_logger`; those ports have no worker-side use case,
|
||||
/// so `WorkerServices` simply does not carry them (see ADR / task-2 brief for why
|
||||
/// this is a separate struct rather than an `Option`-riddled `Services`).
|
||||
pub struct WorkerServices {
|
||||
pub object_storage: Arc<dyn ObjectStorage>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
/// `Option` here mirrors `Services::person_enrichment` — genuine optional
|
||||
/// configuration, not a container-shape workaround.
|
||||
pub person_enrichment: Option<Arc<dyn PersonEnrichmentClient>>,
|
||||
}
|
||||
|
||||
/// The deps structs the worker binary needs, built by `composition::build_worker_deps`.
|
||||
/// These are the five groups that moved out of `Deps` during worker unification —
|
||||
/// they have no consumer the server binary can ever reach.
|
||||
pub struct WorkerDeps {
|
||||
pub enrich_movie: EnrichMovieDeps,
|
||||
pub reindex_search: ReindexSearchDeps,
|
||||
pub merge_duplicates: MergeDuplicatesDeps,
|
||||
pub enrich_person: EnrichPersonDeps,
|
||||
pub handle_requested: HandleWrapUpRequestedDeps,
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
DiaryQuery, EventPublisher, MovieCommand, MovieProfileRepository, MovieQuery, ReviewRepository,
|
||||
SocialQuery,
|
||||
DiaryExporter, DiaryQuery, EventPublisher, FollowGraphQuery, MovieCommand,
|
||||
MovieProfileRepository, MovieQuery, ReviewRepository, UserRepository,
|
||||
};
|
||||
|
||||
use crate::config::AppConfig;
|
||||
@@ -27,6 +27,24 @@ pub struct GetMovieSocialPageDeps {
|
||||
|
||||
pub struct GetActivityFeedDeps {
|
||||
pub diary: Arc<dyn DiaryQuery>,
|
||||
pub social_query: Arc<dyn SocialQuery>,
|
||||
pub social_query: Arc<dyn FollowGraphQuery>,
|
||||
pub config: AppConfig,
|
||||
}
|
||||
|
||||
pub struct GetUserFeedDeps {
|
||||
pub user: Arc<dyn UserRepository>,
|
||||
pub diary: Arc<dyn DiaryQuery>,
|
||||
}
|
||||
|
||||
pub struct GetDiaryDeps {
|
||||
pub diary: Arc<dyn DiaryQuery>,
|
||||
}
|
||||
|
||||
pub struct GetReviewHistoryDeps {
|
||||
pub diary: Arc<dyn DiaryQuery>,
|
||||
}
|
||||
|
||||
pub struct ExportDiaryDeps {
|
||||
pub diary: Arc<dyn DiaryQuery>,
|
||||
pub diary_exporter: Arc<dyn DiaryExporter>,
|
||||
}
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{DiaryExporter, DiaryQuery},
|
||||
value_objects::UserId,
|
||||
};
|
||||
use domain::{errors::DomainError, value_objects::UserId};
|
||||
use futures::stream::BoxStream;
|
||||
|
||||
use crate::diary::deps::ExportDiaryDeps;
|
||||
use crate::diary::queries::ExportQuery;
|
||||
|
||||
pub fn execute(
|
||||
diary: &Arc<dyn DiaryQuery>,
|
||||
diary_exporter: &Arc<dyn DiaryExporter>,
|
||||
deps: &ExportDiaryDeps,
|
||||
query: ExportQuery,
|
||||
) -> BoxStream<'static, Result<Bytes, DomainError>> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
let entry_stream = diary.stream_user_history(user_id);
|
||||
diary_exporter.stream_entries(entry_stream, query.format)
|
||||
let entry_stream = deps.diary.stream_user_history(user_id);
|
||||
deps.diary_exporter
|
||||
.stream_entries(entry_stream, query.format)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
DiaryEntry, DiaryFilter, ReviewSortBy,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::DiaryQuery,
|
||||
value_objects::{MovieId, UserId},
|
||||
};
|
||||
|
||||
use crate::diary::deps::GetDiaryDeps;
|
||||
use crate::diary::queries::GetDiaryQuery;
|
||||
|
||||
pub async fn execute(
|
||||
diary: &Arc<dyn DiaryQuery>,
|
||||
deps: &GetDiaryDeps,
|
||||
query: GetDiaryQuery,
|
||||
) -> Result<Paginated<DiaryEntry>, DomainError> {
|
||||
let page = PageParams::new(query.limit, query.offset)?;
|
||||
@@ -29,7 +27,7 @@ pub async fn execute(
|
||||
include_remote: user_id.is_some(),
|
||||
};
|
||||
|
||||
diary.query_diary(&filter).await
|
||||
deps.diary.query_diary(&filter).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::ReviewHistory,
|
||||
ports::DiaryQuery,
|
||||
services::review_history::{ReviewHistoryAnalyzer, Trend},
|
||||
value_objects::MovieId,
|
||||
};
|
||||
|
||||
use crate::diary::deps::GetReviewHistoryDeps;
|
||||
use crate::diary::queries::GetReviewHistoryQuery;
|
||||
|
||||
pub async fn execute(
|
||||
diary: &Arc<dyn DiaryQuery>,
|
||||
deps: &GetReviewHistoryDeps,
|
||||
query: GetReviewHistoryQuery,
|
||||
) -> Result<(ReviewHistory, Trend), DomainError> {
|
||||
let movie_id = MovieId::from_uuid(query.movie_id);
|
||||
|
||||
let mut history = diary.get_review_history(&movie_id).await?;
|
||||
let mut history = deps.diary.get_review_history(&movie_id).await?;
|
||||
|
||||
let trend = ReviewHistoryAnalyzer::rating_trend(&history)?;
|
||||
|
||||
|
||||
61
crates/application/src/diary/get_user_feed.rs
Normal file
61
crates/application/src/diary/get_user_feed.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use domain::{
|
||||
errors::DomainError, models::DiaryEntry, models::ReviewSortBy, value_objects::UserId,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::diary::deps::{GetDiaryDeps, GetUserFeedDeps};
|
||||
use crate::diary::get_diary;
|
||||
use crate::diary::queries::GetDiaryQuery;
|
||||
|
||||
/// The RSS feed's author line — derived the same way the deleted handler code
|
||||
/// derived its page title: from the local part of the user's email, not their
|
||||
/// username.
|
||||
pub struct FeedAuthor {
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
pub struct UserFeed {
|
||||
pub author: FeedAuthor,
|
||||
pub entries: Vec<DiaryEntry>,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
deps: &GetUserFeedDeps,
|
||||
user_id: Uuid,
|
||||
limit: u32,
|
||||
) -> Result<UserFeed, DomainError> {
|
||||
let user = deps
|
||||
.user
|
||||
.find_by_id(&UserId::from_uuid(user_id))
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("User {user_id}")))?;
|
||||
|
||||
let query = GetDiaryQuery {
|
||||
limit: Some(limit),
|
||||
offset: Some(0),
|
||||
sort_by: Some(ReviewSortBy::Descending),
|
||||
movie_id: None,
|
||||
user_id: Some(user_id),
|
||||
};
|
||||
let get_diary_deps = GetDiaryDeps {
|
||||
diary: deps.diary.clone(),
|
||||
};
|
||||
let page = get_diary::execute(&get_diary_deps, query).await?;
|
||||
|
||||
let display_name = user
|
||||
.email()
|
||||
.value()
|
||||
.split('@')
|
||||
.next()
|
||||
.unwrap_or("User")
|
||||
.to_string();
|
||||
|
||||
Ok(UserFeed {
|
||||
author: FeedAuthor { display_name },
|
||||
entries: page.items,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_user_feed.rs"]
|
||||
mod tests;
|
||||
@@ -7,6 +7,7 @@ pub mod get_activity_feed;
|
||||
pub mod get_diary;
|
||||
pub mod get_movie_social_page;
|
||||
pub mod get_review_history;
|
||||
pub mod get_user_feed;
|
||||
pub mod log_review;
|
||||
pub mod movie_resolver;
|
||||
pub mod queries;
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||
use async_trait::async_trait;
|
||||
use domain::errors::DomainError;
|
||||
use domain::testing::InMemorySocialRepository;
|
||||
use domain::value_objects::{SocialActor, SocialIdentity, UserId};
|
||||
use domain::value_objects::{FollowRelation, SocialActor, SocialIdentity, UserId};
|
||||
|
||||
use crate::{
|
||||
config::AppConfig, diary::deps::GetActivityFeedDeps, diary::get_activity_feed,
|
||||
@@ -66,7 +66,7 @@ async fn returns_feed_with_following_filter() {
|
||||
struct FakeSocialWithFollowing(Vec<SocialActor>);
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::SocialQuery for FakeSocialWithFollowing {
|
||||
impl domain::ports::FollowGraphQuery for FakeSocialWithFollowing {
|
||||
async fn get_following(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(self.0.clone())
|
||||
}
|
||||
@@ -76,17 +76,24 @@ impl domain::ports::SocialQuery for FakeSocialWithFollowing {
|
||||
async fn get_pending_followers(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn get_pending_following(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn count_following(&self, _: &UserId) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn count_followers(&self, _: &UserId) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn get_blocked(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(vec![])
|
||||
async fn count_pending_followers(&self, _: &UserId) -> Result<usize, DomainError> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn is_following(&self, _: &UserId, _: &SocialIdentity) -> Result<bool, DomainError> {
|
||||
Ok(false)
|
||||
async fn get_relation(
|
||||
&self,
|
||||
_: &UserId,
|
||||
_: &SocialIdentity,
|
||||
) -> Result<FollowRelation, DomainError> {
|
||||
Ok(FollowRelation::default())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
use domain::testing::FakeDiaryQuery;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{diary::get_diary, diary::queries::GetDiaryQuery};
|
||||
use crate::{diary::deps::GetDiaryDeps, diary::get_diary, diary::queries::GetDiaryQuery};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_page() {
|
||||
let diary = FakeDiaryQuery::new() as Arc<dyn domain::ports::DiaryQuery>;
|
||||
let deps = GetDiaryDeps { diary };
|
||||
|
||||
let result = get_diary::execute(
|
||||
&diary,
|
||||
&deps,
|
||||
GetDiaryQuery {
|
||||
limit: None,
|
||||
offset: None,
|
||||
|
||||
@@ -7,7 +7,10 @@ use domain::{
|
||||
value_objects::{MovieTitle, ReleaseYear},
|
||||
};
|
||||
|
||||
use crate::{diary::get_review_history, diary::queries::GetReviewHistoryQuery};
|
||||
use crate::{
|
||||
diary::deps::GetReviewHistoryDeps, diary::get_review_history,
|
||||
diary::queries::GetReviewHistoryQuery,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_history() {
|
||||
@@ -23,8 +26,9 @@ async fn returns_empty_history() {
|
||||
let diary = domain::testing::FakeDiaryQuery::new();
|
||||
diary.seed_history(movie, vec![]);
|
||||
let diary: Arc<dyn DiaryQuery> = diary;
|
||||
let deps = GetReviewHistoryDeps { diary };
|
||||
|
||||
let (history, trend) = get_review_history::execute(&diary, GetReviewHistoryQuery { movie_id })
|
||||
let (history, trend) = get_review_history::execute(&deps, GetReviewHistoryQuery { movie_id })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
||||
93
crates/application/src/diary/tests/get_user_feed.rs
Normal file
93
crates/application/src/diary/tests/get_user_feed.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::{DiaryEntry, Movie, Review, UserRole, collections::Paginated};
|
||||
use domain::testing::FakeDiaryQuery;
|
||||
use domain::value_objects::{Email, MovieTitle, Rating, ReleaseYear, UserId};
|
||||
|
||||
use crate::auth::commands::RegisterCommand;
|
||||
use crate::auth::deps::RegisterDeps;
|
||||
use crate::auth::register;
|
||||
use crate::diary::deps::GetUserFeedDeps;
|
||||
use crate::diary::get_user_feed;
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) {
|
||||
let deps = RegisterDeps {
|
||||
user: b.user_repo.clone(),
|
||||
password_hasher: b.password_hasher.clone(),
|
||||
config: b.config.clone(),
|
||||
};
|
||||
register::execute(
|
||||
&deps,
|
||||
RegisterCommand {
|
||||
email: email.into(),
|
||||
username: username.into(),
|
||||
password: "password123".into(),
|
||||
role: UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_feed_carries_author_and_entries() {
|
||||
let b = TestContextBuilder::new();
|
||||
setup_user(&b, "feed@test.com", "feeduser").await;
|
||||
|
||||
let email = Email::new("feed@test.com".into()).unwrap();
|
||||
let user = b.user_repo.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let diary = FakeDiaryQuery::new();
|
||||
let movie = Movie::new(
|
||||
None,
|
||||
MovieTitle::new("Feed Movie".into()).unwrap(),
|
||||
ReleaseYear::new(2020).unwrap(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let review = Review::new(
|
||||
movie.id().clone(),
|
||||
UserId::from_uuid(uid),
|
||||
Rating::new(5).unwrap(),
|
||||
None,
|
||||
chrono::Utc::now().naive_utc(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
diary.set_diary_page(Paginated {
|
||||
items: vec![DiaryEntry::new(movie, review)],
|
||||
total_count: 1,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
let deps = GetUserFeedDeps {
|
||||
user: b.user_repo.clone(),
|
||||
diary: Arc::clone(&diary) as _,
|
||||
};
|
||||
|
||||
let feed = get_user_feed::execute(&deps, uid, 50).await.unwrap();
|
||||
|
||||
assert_eq!(feed.author.display_name, "feed");
|
||||
assert_eq!(feed.entries.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_feed_is_not_found_for_unknown_user() {
|
||||
let b = TestContextBuilder::new();
|
||||
let deps = GetUserFeedDeps {
|
||||
user: b.user_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
};
|
||||
|
||||
let err = match get_user_feed::execute(&deps, Uuid::new_v4(), 50).await {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("expected Err(NotFound) for an unknown user id, got Ok"),
|
||||
};
|
||||
assert!(matches!(err, DomainError::NotFound(_)));
|
||||
}
|
||||
64
crates/application/src/import/apply_profile_and_map.rs
Normal file
64
crates/application/src/import/apply_profile_and_map.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
//! Absorbs `handlers/import.rs::api_apply_profile`'s three-step orchestration:
|
||||
//! apply the saved profile's field mappings onto the session, reload the
|
||||
//! session to read back the mappings `apply_profile` just wrote, then run
|
||||
//! `apply_mapping` to regenerate `row_results` from them. All three steps used
|
||||
//! to live in the handler; this use case is the only caller-visible change —
|
||||
//! the two existing use cases it drives (`apply_profile::execute`,
|
||||
//! `apply_mapping::execute`) are untouched, per this task's constraint against
|
||||
//! reshaping already-existing use-case signatures.
|
||||
|
||||
use domain::{errors::DomainError, value_objects::ImportSessionId};
|
||||
|
||||
use crate::import::{
|
||||
apply_mapping, apply_profile,
|
||||
commands::{ApplyImportMappingCommand, ApplyImportProfileCommand, ApplyProfileAndMapCommand},
|
||||
deps::{ApplyMappingDeps, ApplyProfileAndMapDeps, ApplyProfileDeps},
|
||||
};
|
||||
|
||||
pub async fn execute(
|
||||
deps: &ApplyProfileAndMapDeps,
|
||||
cmd: ApplyProfileAndMapCommand,
|
||||
) -> Result<Vec<domain::models::AnnotatedRow>, DomainError> {
|
||||
let profile_deps = ApplyProfileDeps {
|
||||
import_profile: deps.import_profile.clone(),
|
||||
import_session: deps.import_session.clone(),
|
||||
};
|
||||
apply_profile::execute(
|
||||
&profile_deps,
|
||||
ApplyImportProfileCommand {
|
||||
user_id: cmd.user_id,
|
||||
session_id: cmd.session_id,
|
||||
profile_id: cmd.profile_id,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let session_id = ImportSessionId::from_uuid(cmd.session_id);
|
||||
let user_id = domain::value_objects::UserId::from_uuid(cmd.user_id);
|
||||
let session = deps
|
||||
.import_session
|
||||
.get(&session_id, &user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("session not found after profile apply".into()))?;
|
||||
|
||||
let mappings = session.field_mappings.unwrap_or_default();
|
||||
|
||||
let mapping_deps = ApplyMappingDeps {
|
||||
import_session: deps.import_session.clone(),
|
||||
document_parser: deps.document_parser.clone(),
|
||||
movie_query: deps.movie_query.clone(),
|
||||
};
|
||||
apply_mapping::execute(
|
||||
&mapping_deps,
|
||||
ApplyImportMappingCommand {
|
||||
user_id: cmd.user_id,
|
||||
session_id: cmd.session_id,
|
||||
mappings,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/apply_profile_and_map.rs"]
|
||||
mod tests;
|
||||
@@ -31,6 +31,12 @@ pub struct ApplyImportProfileCommand {
|
||||
pub profile_id: Uuid,
|
||||
}
|
||||
|
||||
pub struct ApplyProfileAndMapCommand {
|
||||
pub user_id: Uuid,
|
||||
pub session_id: Uuid,
|
||||
pub profile_id: Uuid,
|
||||
}
|
||||
|
||||
pub struct DeleteImportProfileCommand {
|
||||
pub user_id: Uuid,
|
||||
pub profile_id: Uuid,
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::import::commands::DeleteImportProfileCommand;
|
||||
use crate::import::deps::DeleteImportProfileDeps;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::ImportProfileRepository,
|
||||
value_objects::{ImportProfileId, UserId},
|
||||
};
|
||||
|
||||
pub async fn execute(
|
||||
import_profile: Arc<dyn ImportProfileRepository>,
|
||||
deps: &DeleteImportProfileDeps,
|
||||
cmd: DeleteImportProfileCommand,
|
||||
) -> Result<(), DomainError> {
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
let profile_id = ImportProfileId::from_uuid(cmd.profile_id);
|
||||
|
||||
import_profile
|
||||
deps.import_profile
|
||||
.get(&profile_id, &user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("import profile".into()))?;
|
||||
import_profile.delete(&profile_id).await
|
||||
deps.import_profile.delete(&profile_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -29,3 +29,35 @@ pub struct SaveProfileDeps {
|
||||
pub import_session: Arc<dyn ImportSessionRepository>,
|
||||
pub import_profile: Arc<dyn ImportProfileRepository>,
|
||||
}
|
||||
|
||||
pub struct GetMappingStageDeps {
|
||||
pub import_session: Arc<dyn ImportSessionRepository>,
|
||||
}
|
||||
|
||||
pub struct GetPreviewStageDeps {
|
||||
pub import_session: Arc<dyn ImportSessionRepository>,
|
||||
}
|
||||
|
||||
pub struct GetSessionStateDeps {
|
||||
pub import_session: Arc<dyn ImportSessionRepository>,
|
||||
}
|
||||
|
||||
pub struct DeleteImportProfileDeps {
|
||||
pub import_profile: Arc<dyn ImportProfileRepository>,
|
||||
}
|
||||
|
||||
pub struct ListImportProfilesDeps {
|
||||
pub import_profile: Arc<dyn ImportProfileRepository>,
|
||||
}
|
||||
|
||||
/// Backs `apply_profile_and_map`, which internally drives `apply_profile::execute`
|
||||
/// then `apply_mapping::execute` — these fields are exactly the union of
|
||||
/// `ApplyProfileDeps` and `ApplyMappingDeps`'s fields, cloned once here and used to
|
||||
/// build each nested deps struct inline at the call site (see that file's doc
|
||||
/// comment for why: no use-case signature changes, per this task's constraints).
|
||||
pub struct ApplyProfileAndMapDeps {
|
||||
pub import_profile: Arc<dyn ImportProfileRepository>,
|
||||
pub import_session: Arc<dyn ImportSessionRepository>,
|
||||
pub document_parser: Arc<dyn DocumentParser>,
|
||||
pub movie_query: Arc<dyn MovieQuery>,
|
||||
}
|
||||
|
||||
47
crates/application/src/import/get_mapping_stage.rs
Normal file
47
crates/application/src/import/get_mapping_stage.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
//! The mapping-page stage gate: a session must exist and have a `parsed_file`
|
||||
//! before its columns/sample rows can be shown for field mapping. Absorbs
|
||||
//! `handlers/import.rs::get_mapping_page`'s two early-return checks (session
|
||||
//! missing, `parsed_file` absent) — both collapse to `NotFound` here since the
|
||||
//! handler redirected to the same place (`/import`) for either.
|
||||
|
||||
use domain::{errors::DomainError, value_objects::ImportSessionId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::import::deps::GetMappingStageDeps;
|
||||
|
||||
/// Cap on sample rows shown on the mapping page — was a bare `.take(5)` in the
|
||||
/// handler.
|
||||
pub const SAMPLE_ROW_LIMIT: usize = 5;
|
||||
|
||||
pub struct MappingStage {
|
||||
pub columns: Vec<String>,
|
||||
pub sample_rows: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
deps: &GetMappingStageDeps,
|
||||
session_id: ImportSessionId,
|
||||
user_id: Uuid,
|
||||
) -> Result<MappingStage, DomainError> {
|
||||
let user_id = domain::value_objects::UserId::from_uuid(user_id);
|
||||
let session = deps
|
||||
.import_session
|
||||
.get(&session_id, &user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("import session".into()))?;
|
||||
|
||||
let parsed = session
|
||||
.parsed_file
|
||||
.ok_or_else(|| DomainError::NotFound("import session has no parsed file".into()))?;
|
||||
|
||||
let sample_rows = parsed.rows.into_iter().take(SAMPLE_ROW_LIMIT).collect();
|
||||
|
||||
Ok(MappingStage {
|
||||
columns: parsed.columns,
|
||||
sample_rows,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_mapping_stage.rs"]
|
||||
mod tests;
|
||||
59
crates/application/src/import/get_preview_stage.rs
Normal file
59
crates/application/src/import/get_preview_stage.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
//! The preview-page stage gate: a session must have `row_results` (i.e. a
|
||||
//! mapping has already been applied) before its rows can be previewed. Serves
|
||||
//! both the HTML preview handler and the API preview handler —
|
||||
//! `handlers/import.rs::get_preview_page` and `::api_get_preview` — which
|
||||
//! render/respond to `NotYetMapped` differently (redirect vs. status code); that
|
||||
//! decision stays in the handlers, not here.
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::AnnotatedRow,
|
||||
value_objects::{ImportSessionId, UserId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::import::deps::GetPreviewStageDeps;
|
||||
|
||||
/// The columns and mapped/annotated rows for a session whose mapping has
|
||||
/// already been applied. `columns` comes from the session's `parsed_file` —
|
||||
/// the HTML preview template renders it as the table header — while `rows`
|
||||
/// comes from `row_results`. Not in the brief's `PreviewStage::Ready(Vec<AnnotatedRow>)`
|
||||
/// sketch: the deleted `get_preview_page` handler code read both
|
||||
/// `session.parsed_file.columns` and `session.row_results` to render the page,
|
||||
/// so dropping `columns` here would either blank the preview table's header or
|
||||
/// force the handler to re-fetch the session itself (forbidden — that's the
|
||||
/// exact repo call this task removes). See task-2 report for detail.
|
||||
pub struct PreviewRows {
|
||||
pub columns: Vec<String>,
|
||||
pub rows: Vec<AnnotatedRow>,
|
||||
}
|
||||
|
||||
pub enum PreviewStage {
|
||||
Ready(PreviewRows),
|
||||
NotYetMapped,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
deps: &GetPreviewStageDeps,
|
||||
session_id: ImportSessionId,
|
||||
user_id: Uuid,
|
||||
) -> Result<PreviewStage, DomainError> {
|
||||
let user_id = UserId::from_uuid(user_id);
|
||||
let session = deps
|
||||
.import_session
|
||||
.get(&session_id, &user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("session not found".into()))?;
|
||||
|
||||
let Some(rows) = session.row_results else {
|
||||
return Ok(PreviewStage::NotYetMapped);
|
||||
};
|
||||
|
||||
let columns = session.parsed_file.map(|p| p.columns).unwrap_or_default();
|
||||
|
||||
Ok(PreviewStage::Ready(PreviewRows { columns, rows }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_preview_stage.rs"]
|
||||
mod tests;
|
||||
43
crates/application/src/import/get_session_state.rs
Normal file
43
crates/application/src/import/get_session_state.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
//! Backs `handlers/import.rs::api_get_session` — a plain state query, not a
|
||||
//! redirect-driving gate (the API has nothing to redirect to; a missing
|
||||
//! session is just a 404).
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{ImportSessionId, UserId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::import::deps::GetSessionStateDeps;
|
||||
|
||||
pub struct SessionState {
|
||||
pub columns: Vec<String>,
|
||||
pub has_mappings: bool,
|
||||
pub row_count: usize,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
deps: &GetSessionStateDeps,
|
||||
session_id: ImportSessionId,
|
||||
user_id: Uuid,
|
||||
) -> Result<SessionState, DomainError> {
|
||||
let user_id = UserId::from_uuid(user_id);
|
||||
let session = deps
|
||||
.import_session
|
||||
.get(&session_id, &user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("session not found".into()))?;
|
||||
|
||||
let parsed = session.parsed_file.unwrap_or_default();
|
||||
let row_count = parsed.rows.len();
|
||||
|
||||
Ok(SessionState {
|
||||
columns: parsed.columns,
|
||||
has_mappings: session.field_mappings.is_some(),
|
||||
row_count,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_session_state.rs"]
|
||||
mod tests;
|
||||
@@ -1,15 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError, models::ImportProfile, ports::ImportProfileRepository,
|
||||
value_objects::UserId,
|
||||
};
|
||||
use crate::import::deps::ListImportProfilesDeps;
|
||||
use domain::{errors::DomainError, models::ImportProfile, value_objects::UserId};
|
||||
|
||||
pub async fn execute(
|
||||
import_profile: Arc<dyn ImportProfileRepository>,
|
||||
deps: &ListImportProfilesDeps,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<ImportProfile>, DomainError> {
|
||||
import_profile.list_for_user(user_id).await
|
||||
deps.import_profile.list_for_user(user_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
pub mod apply_mapping;
|
||||
pub mod apply_profile;
|
||||
pub mod apply_profile_and_map;
|
||||
pub mod cleanup;
|
||||
pub mod commands;
|
||||
pub mod create_session;
|
||||
pub mod delete_profile;
|
||||
pub mod deps;
|
||||
pub mod execute;
|
||||
pub mod get_mapping_stage;
|
||||
pub mod get_preview_stage;
|
||||
pub mod get_session_state;
|
||||
pub mod list_profiles;
|
||||
pub mod save_profile;
|
||||
|
||||
108
crates/application/src/import/tests/apply_profile_and_map.rs
Normal file
108
crates/application/src/import/tests/apply_profile_and_map.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::models::import::{DomainField, Transform};
|
||||
use domain::models::{FieldMapping, FileFormat, ImportProfile};
|
||||
use domain::ports::{ImportProfileRepository, ImportSessionRepository};
|
||||
use domain::testing::{InMemoryImportProfileRepository, InMemoryImportSessionRepository};
|
||||
use domain::value_objects::{ImportProfileId, UserId};
|
||||
|
||||
use crate::import::deps::{ApplyProfileAndMapDeps, CreateSessionDeps};
|
||||
use crate::import::{
|
||||
apply_profile_and_map, commands::ApplyProfileAndMapCommand,
|
||||
commands::CreateImportSessionCommand, create_session,
|
||||
};
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_profile_not_found() {
|
||||
let profiles = InMemoryImportProfileRepository::new();
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let b = TestContextBuilder::new();
|
||||
|
||||
let deps = ApplyProfileAndMapDeps {
|
||||
import_profile: Arc::clone(&profiles) as _,
|
||||
import_session: Arc::clone(&sessions) as _,
|
||||
document_parser: b.document_parser.clone(),
|
||||
movie_query: b.movie_query.clone(),
|
||||
};
|
||||
|
||||
let result = apply_profile_and_map::execute(
|
||||
&deps,
|
||||
ApplyProfileAndMapCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
session_id: Uuid::new_v4(),
|
||||
profile_id: Uuid::new_v4(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn applies_profile_then_regenerates_mapping() {
|
||||
let profiles = InMemoryImportProfileRepository::new();
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let b = TestContextBuilder::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let profile = ImportProfile::new(
|
||||
ImportProfileId::generate(),
|
||||
UserId::from_uuid(user_id),
|
||||
"letterboxd".into(),
|
||||
vec![FieldMapping {
|
||||
source_column: "title".into(),
|
||||
domain_field: DomainField::Title,
|
||||
transform: Transform::Identity,
|
||||
}],
|
||||
Utc::now().naive_utc(),
|
||||
);
|
||||
let profile_id = profile.id.clone();
|
||||
profiles.save(&profile).await.unwrap();
|
||||
|
||||
let create_deps = CreateSessionDeps {
|
||||
import_session: Arc::clone(&sessions) as _,
|
||||
document_parser: b.document_parser.clone(),
|
||||
};
|
||||
let created = create_session::execute(
|
||||
&create_deps,
|
||||
CreateImportSessionCommand {
|
||||
user_id,
|
||||
bytes: b"title\nTest".to_vec(),
|
||||
format: FileFormat::Csv,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let deps = ApplyProfileAndMapDeps {
|
||||
import_profile: Arc::clone(&profiles) as _,
|
||||
import_session: Arc::clone(&sessions) as _,
|
||||
document_parser: b.document_parser.clone(),
|
||||
movie_query: b.movie_query.clone(),
|
||||
};
|
||||
|
||||
let rows = apply_profile_and_map::execute(
|
||||
&deps,
|
||||
ApplyProfileAndMapCommand {
|
||||
user_id,
|
||||
session_id: created.session_id.value(),
|
||||
profile_id: profile_id.value(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!rows.is_empty());
|
||||
|
||||
let updated = sessions
|
||||
.get(&created.session_id, &UserId::from_uuid(user_id))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(updated.row_results.is_some());
|
||||
assert!(updated.field_mappings.is_some());
|
||||
}
|
||||
@@ -3,14 +3,19 @@ use std::sync::Arc;
|
||||
use domain::testing::InMemoryImportProfileRepository;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::import::{commands::DeleteImportProfileCommand, delete_profile};
|
||||
use crate::import::{
|
||||
commands::DeleteImportProfileCommand, delete_profile, deps::DeleteImportProfileDeps,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn fails_when_profile_not_found() {
|
||||
let profiles = InMemoryImportProfileRepository::new();
|
||||
let deps = DeleteImportProfileDeps {
|
||||
import_profile: Arc::clone(&profiles) as _,
|
||||
};
|
||||
|
||||
let result = delete_profile::execute(
|
||||
Arc::clone(&profiles) as _,
|
||||
&deps,
|
||||
DeleteImportProfileCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
profile_id: Uuid::new_v4(),
|
||||
|
||||
68
crates/application/src/import/tests/get_mapping_stage.rs
Normal file
68
crates/application/src/import/tests/get_mapping_stage.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::models::ImportSession;
|
||||
use domain::models::import::ParsedFile;
|
||||
use domain::ports::ImportSessionRepository;
|
||||
use domain::testing::InMemoryImportSessionRepository;
|
||||
use domain::value_objects::{ImportSessionId, UserId};
|
||||
|
||||
use crate::import::deps::GetMappingStageDeps;
|
||||
use crate::import::get_mapping_stage::{self, SAMPLE_ROW_LIMIT};
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_mapping_stage_is_not_found_when_file_not_parsed() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
let session = ImportSession::new(UserId::from_uuid(user_id));
|
||||
let session_id = session.id.clone();
|
||||
sessions.create(&session).await.unwrap();
|
||||
|
||||
let deps = GetMappingStageDeps {
|
||||
import_session: Arc::clone(&sessions) as _,
|
||||
};
|
||||
|
||||
let result = get_mapping_stage::execute(&deps, session_id, user_id).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_mapping_stage_is_not_found_when_session_missing() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let deps = GetMappingStageDeps {
|
||||
import_session: Arc::clone(&sessions) as _,
|
||||
};
|
||||
|
||||
let result =
|
||||
get_mapping_stage::execute(&deps, ImportSessionId::generate(), Uuid::new_v4()).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_mapping_stage_returns_columns_and_capped_sample_rows() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
let mut session = ImportSession::new(UserId::from_uuid(user_id));
|
||||
session.parsed_file = Some(ParsedFile {
|
||||
columns: vec!["Name".into(), "Year".into()],
|
||||
rows: (0..7)
|
||||
.map(|i| vec![format!("row{i}"), "2020".into()])
|
||||
.collect(),
|
||||
});
|
||||
let session_id = session.id.clone();
|
||||
sessions.create(&session).await.unwrap();
|
||||
|
||||
let deps = GetMappingStageDeps {
|
||||
import_session: Arc::clone(&sessions) as _,
|
||||
};
|
||||
|
||||
let stage = get_mapping_stage::execute(&deps, session_id, user_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(stage.columns, vec!["Name".to_string(), "Year".to_string()]);
|
||||
assert_eq!(stage.sample_rows.len(), SAMPLE_ROW_LIMIT);
|
||||
}
|
||||
80
crates/application/src/import/tests/get_preview_stage.rs
Normal file
80
crates/application/src/import/tests/get_preview_stage.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::models::import::{ImportRow, ParsedFile, RowResult};
|
||||
use domain::models::{AnnotatedRow, ImportSession};
|
||||
use domain::ports::ImportSessionRepository;
|
||||
use domain::testing::InMemoryImportSessionRepository;
|
||||
use domain::value_objects::{ImportSessionId, UserId};
|
||||
|
||||
use crate::import::deps::GetPreviewStageDeps;
|
||||
use crate::import::get_preview_stage::{self, PreviewStage};
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_preview_stage_reports_not_yet_mapped_when_row_results_absent() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
let session = ImportSession::new(UserId::from_uuid(user_id));
|
||||
let session_id = session.id.clone();
|
||||
sessions.create(&session).await.unwrap();
|
||||
|
||||
let deps = GetPreviewStageDeps {
|
||||
import_session: Arc::clone(&sessions) as _,
|
||||
};
|
||||
|
||||
let stage = get_preview_stage::execute(&deps, session_id, user_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(stage, PreviewStage::NotYetMapped));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_preview_stage_returns_rows_once_mapped() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
let mut session = ImportSession::new(UserId::from_uuid(user_id));
|
||||
session.parsed_file = Some(ParsedFile {
|
||||
columns: vec!["Name".into()],
|
||||
rows: vec![vec!["Test".into()]],
|
||||
});
|
||||
session.row_results = Some(vec![AnnotatedRow {
|
||||
result: RowResult::Valid(ImportRow {
|
||||
title: Some("Test".into()),
|
||||
..ImportRow::default()
|
||||
}),
|
||||
is_duplicate: false,
|
||||
}]);
|
||||
let session_id = session.id.clone();
|
||||
sessions.create(&session).await.unwrap();
|
||||
|
||||
let deps = GetPreviewStageDeps {
|
||||
import_session: Arc::clone(&sessions) as _,
|
||||
};
|
||||
|
||||
let stage = get_preview_stage::execute(&deps, session_id, user_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
match stage {
|
||||
PreviewStage::Ready(preview) => {
|
||||
assert_eq!(preview.columns, vec!["Name".to_string()]);
|
||||
assert_eq!(preview.rows.len(), 1);
|
||||
}
|
||||
PreviewStage::NotYetMapped => panic!("expected Ready, got NotYetMapped"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_preview_stage_is_not_found_when_session_missing() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let deps = GetPreviewStageDeps {
|
||||
import_session: Arc::clone(&sessions) as _,
|
||||
};
|
||||
|
||||
let result =
|
||||
get_preview_stage::execute(&deps, ImportSessionId::generate(), Uuid::new_v4()).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
50
crates/application/src/import/tests/get_session_state.rs
Normal file
50
crates/application/src/import/tests/get_session_state.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::models::ImportSession;
|
||||
use domain::models::import::ParsedFile;
|
||||
use domain::ports::ImportSessionRepository;
|
||||
use domain::testing::InMemoryImportSessionRepository;
|
||||
use domain::value_objects::{ImportSessionId, UserId};
|
||||
|
||||
use crate::import::deps::GetSessionStateDeps;
|
||||
use crate::import::get_session_state;
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_session_state_is_not_found_when_session_missing() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let deps = GetSessionStateDeps {
|
||||
import_session: Arc::clone(&sessions) as _,
|
||||
};
|
||||
|
||||
let result =
|
||||
get_session_state::execute(&deps, ImportSessionId::generate(), Uuid::new_v4()).await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_session_state_reports_columns_row_count_and_mapping_status() {
|
||||
let sessions = InMemoryImportSessionRepository::new();
|
||||
let user_id = Uuid::new_v4();
|
||||
let mut session = ImportSession::new(UserId::from_uuid(user_id));
|
||||
session.parsed_file = Some(ParsedFile {
|
||||
columns: vec!["Name".into()],
|
||||
rows: vec![vec!["a".into()], vec!["b".into()]],
|
||||
});
|
||||
let session_id = session.id.clone();
|
||||
sessions.create(&session).await.unwrap();
|
||||
|
||||
let deps = GetSessionStateDeps {
|
||||
import_session: Arc::clone(&sessions) as _,
|
||||
};
|
||||
|
||||
let state = get_session_state::execute(&deps, session_id, user_id)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(state.columns, vec!["Name".to_string()]);
|
||||
assert_eq!(state.row_count, 2);
|
||||
assert!(!state.has_mappings);
|
||||
}
|
||||
@@ -4,16 +4,17 @@ use domain::testing::InMemoryImportProfileRepository;
|
||||
use domain::value_objects::UserId;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::import::list_profiles;
|
||||
use crate::import::{deps::ListImportProfilesDeps, list_profiles};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_when_no_profiles() {
|
||||
let profiles = InMemoryImportProfileRepository::new();
|
||||
let deps = ListImportProfilesDeps {
|
||||
import_profile: Arc::clone(&profiles) as _,
|
||||
};
|
||||
|
||||
let user_id = UserId::from_uuid(Uuid::new_v4());
|
||||
let result = list_profiles::execute(Arc::clone(&profiles) as _, &user_id)
|
||||
.await
|
||||
.unwrap();
|
||||
let result = list_profiles::execute(&deps, &user_id).await.unwrap();
|
||||
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::WatchEventStatus,
|
||||
ports::{WatchEventCommand, WatchEventQuery},
|
||||
value_objects::{UserId, WatchEventId},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
diary::commands::{LogReviewCommand, MovieInput},
|
||||
integrations::commands::ConfirmWatchEventsCommand,
|
||||
ports::ReviewLogger,
|
||||
integrations::{commands::ConfirmWatchEventsCommand, deps::ConfirmWatchEventsDeps},
|
||||
};
|
||||
|
||||
pub async fn execute(
|
||||
watch_event_command: Arc<dyn WatchEventCommand>,
|
||||
watch_event_query: Arc<dyn WatchEventQuery>,
|
||||
review_logger: Arc<dyn ReviewLogger>,
|
||||
deps: &ConfirmWatchEventsDeps,
|
||||
cmd: ConfirmWatchEventsCommand,
|
||||
) -> Result<u32, DomainError> {
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
@@ -24,7 +18,8 @@ pub async fn execute(
|
||||
|
||||
for c in cmd.confirmations {
|
||||
let event_id = WatchEventId::from_uuid(c.watch_event_id);
|
||||
let event = watch_event_query
|
||||
let event = deps
|
||||
.watch_event_query
|
||||
.get_by_id(&event_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("WatchEvent {}", c.watch_event_id)))?;
|
||||
@@ -60,9 +55,9 @@ pub async fn execute(
|
||||
watch_medium: Some(domain::value_objects::WatchMedium::MediaServer),
|
||||
};
|
||||
|
||||
review_logger.log_review(review_cmd).await?;
|
||||
deps.review_logger.log_review(review_cmd).await?;
|
||||
|
||||
watch_event_command
|
||||
deps.watch_event_command
|
||||
.update_status(&event_id, WatchEventStatus::Confirmed)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -2,9 +2,38 @@ use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EventPublisher, WatchEventCommand, WatchEventQuery, WebhookTokenRepository};
|
||||
|
||||
use crate::ports::ReviewLogger;
|
||||
|
||||
pub struct IngestWatchEventDeps {
|
||||
pub webhook_token: Arc<dyn WebhookTokenRepository>,
|
||||
pub watch_event_command: Arc<dyn WatchEventCommand>,
|
||||
pub watch_event_query: Arc<dyn WatchEventQuery>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
pub struct ConfirmWatchEventsDeps {
|
||||
pub watch_event_command: Arc<dyn WatchEventCommand>,
|
||||
pub watch_event_query: Arc<dyn WatchEventQuery>,
|
||||
pub review_logger: Arc<dyn ReviewLogger>,
|
||||
}
|
||||
|
||||
pub struct DismissWatchEventsDeps {
|
||||
pub watch_event_command: Arc<dyn WatchEventCommand>,
|
||||
pub watch_event_query: Arc<dyn WatchEventQuery>,
|
||||
}
|
||||
|
||||
pub struct GenerateWebhookTokenDeps {
|
||||
pub webhook_token: Arc<dyn WebhookTokenRepository>,
|
||||
}
|
||||
|
||||
pub struct GetWatchQueueDeps {
|
||||
pub watch_event_query: Arc<dyn WatchEventQuery>,
|
||||
}
|
||||
|
||||
pub struct GetWebhookTokensDeps {
|
||||
pub webhook_token: Arc<dyn WebhookTokenRepository>,
|
||||
}
|
||||
|
||||
pub struct RevokeWebhookTokenDeps {
|
||||
pub webhook_token: Arc<dyn WebhookTokenRepository>,
|
||||
}
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::WatchEventStatus,
|
||||
ports::{WatchEventCommand, WatchEventQuery},
|
||||
value_objects::{UserId, WatchEventId},
|
||||
};
|
||||
|
||||
use crate::integrations::commands::DismissWatchEventsCommand;
|
||||
use crate::integrations::{commands::DismissWatchEventsCommand, deps::DismissWatchEventsDeps};
|
||||
|
||||
pub async fn execute(
|
||||
watch_event_command: Arc<dyn WatchEventCommand>,
|
||||
watch_event_query: Arc<dyn WatchEventQuery>,
|
||||
deps: &DismissWatchEventsDeps,
|
||||
cmd: DismissWatchEventsCommand,
|
||||
) -> Result<u32, DomainError> {
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
@@ -25,7 +21,7 @@ pub async fn execute(
|
||||
.map(|id| WatchEventId::from_uuid(*id))
|
||||
.collect();
|
||||
|
||||
let events = watch_event_query.get_by_ids(&ids).await?;
|
||||
let events = deps.watch_event_query.get_by_ids(&ids).await?;
|
||||
|
||||
if events.len() != ids.len() {
|
||||
return Err(DomainError::NotFound(
|
||||
@@ -38,7 +34,8 @@ pub async fn execute(
|
||||
}
|
||||
}
|
||||
|
||||
let count = watch_event_command
|
||||
let count = deps
|
||||
.watch_event_command
|
||||
.update_status_batch(&ids, WatchEventStatus::Dismissed)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError, models::WebhookToken, ports::WebhookTokenRepository, value_objects::UserId,
|
||||
};
|
||||
use domain::{errors::DomainError, models::WebhookToken, value_objects::UserId};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::integrations::commands::GenerateWebhookTokenCommand;
|
||||
use crate::integrations::{commands::GenerateWebhookTokenCommand, deps::GenerateWebhookTokenDeps};
|
||||
|
||||
pub struct GeneratedWebhookToken {
|
||||
pub token_plaintext: String,
|
||||
@@ -13,7 +9,7 @@ pub struct GeneratedWebhookToken {
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
webhook_token: Arc<dyn WebhookTokenRepository>,
|
||||
deps: &GenerateWebhookTokenDeps,
|
||||
cmd: GenerateWebhookTokenCommand,
|
||||
) -> Result<GeneratedWebhookToken, DomainError> {
|
||||
let plaintext = generate_random_token();
|
||||
@@ -22,7 +18,7 @@ pub async fn execute(
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
let token = WebhookToken::new(user_id, hash, cmd.provider, cmd.label);
|
||||
|
||||
webhook_token.save(&token).await?;
|
||||
deps.webhook_token.save(&token).await?;
|
||||
|
||||
Ok(GeneratedWebhookToken {
|
||||
token_plaintext: plaintext,
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
use domain::{errors::DomainError, models::WatchEvent, value_objects::UserId};
|
||||
|
||||
use domain::{
|
||||
errors::DomainError, models::WatchEvent, ports::WatchEventQuery, value_objects::UserId,
|
||||
};
|
||||
|
||||
use crate::integrations::queries::GetWatchQueueQuery;
|
||||
use crate::integrations::{deps::GetWatchQueueDeps, queries::GetWatchQueueQuery};
|
||||
|
||||
pub async fn execute(
|
||||
watch_event_query: Arc<dyn WatchEventQuery>,
|
||||
deps: &GetWatchQueueDeps,
|
||||
query: GetWatchQueueQuery,
|
||||
) -> Result<Vec<WatchEvent>, DomainError> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
watch_event_query.list_pending(&user_id).await
|
||||
deps.watch_event_query.list_pending(&user_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
use domain::{errors::DomainError, models::WebhookToken, value_objects::UserId};
|
||||
|
||||
use domain::{
|
||||
errors::DomainError, models::WebhookToken, ports::WebhookTokenRepository, value_objects::UserId,
|
||||
};
|
||||
|
||||
use crate::integrations::queries::GetWebhookTokensQuery;
|
||||
use crate::integrations::{deps::GetWebhookTokensDeps, queries::GetWebhookTokensQuery};
|
||||
|
||||
pub async fn execute(
|
||||
webhook_token: Arc<dyn WebhookTokenRepository>,
|
||||
deps: &GetWebhookTokensDeps,
|
||||
query: GetWebhookTokensQuery,
|
||||
) -> Result<Vec<WebhookToken>, DomainError> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
webhook_token.list_by_user(&user_id).await
|
||||
deps.webhook_token.list_by_user(&user_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::WebhookTokenRepository,
|
||||
value_objects::{UserId, WebhookTokenId},
|
||||
};
|
||||
|
||||
use crate::integrations::commands::RevokeWebhookTokenCommand;
|
||||
use crate::integrations::{commands::RevokeWebhookTokenCommand, deps::RevokeWebhookTokenDeps};
|
||||
|
||||
pub async fn execute(
|
||||
webhook_token: Arc<dyn WebhookTokenRepository>,
|
||||
deps: &RevokeWebhookTokenDeps,
|
||||
cmd: RevokeWebhookTokenCommand,
|
||||
) -> Result<(), DomainError> {
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
let token_id = WebhookTokenId::from_uuid(cmd.token_id);
|
||||
webhook_token.delete(&token_id, &user_id).await
|
||||
deps.webhook_token.delete(&token_id, &user_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -8,12 +8,24 @@ use uuid::Uuid;
|
||||
|
||||
use crate::integrations::commands::{ConfirmWatchEventsCommand, WatchEventConfirmation};
|
||||
use crate::integrations::confirm;
|
||||
use crate::integrations::deps::ConfirmWatchEventsDeps;
|
||||
use crate::test_helpers::NoopReviewLogger;
|
||||
|
||||
fn noop_logger() -> Arc<dyn crate::ports::ReviewLogger> {
|
||||
Arc::new(NoopReviewLogger)
|
||||
}
|
||||
|
||||
fn deps(
|
||||
watch_events: &Arc<InMemoryWatchEventRepository>,
|
||||
review_logger: Arc<dyn crate::ports::ReviewLogger>,
|
||||
) -> ConfirmWatchEventsDeps {
|
||||
ConfirmWatchEventsDeps {
|
||||
watch_event_command: Arc::clone(watch_events) as _,
|
||||
watch_event_query: Arc::clone(watch_events) as _,
|
||||
review_logger,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn confirms_watch_event_via_review_logger() {
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
@@ -32,9 +44,7 @@ async fn confirms_watch_event_via_review_logger() {
|
||||
watch_events.save(&event).await.unwrap();
|
||||
|
||||
let result = confirm::execute(
|
||||
Arc::clone(&watch_events) as _,
|
||||
Arc::clone(&watch_events) as _,
|
||||
noop_logger(),
|
||||
&deps(&watch_events, noop_logger()),
|
||||
ConfirmWatchEventsCommand {
|
||||
user_id: uid,
|
||||
confirmations: vec![WatchEventConfirmation {
|
||||
@@ -55,9 +65,7 @@ async fn empty_confirmations_returns_zero() {
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
|
||||
let result = confirm::execute(
|
||||
Arc::clone(&watch_events) as _,
|
||||
Arc::clone(&watch_events) as _,
|
||||
noop_logger(),
|
||||
&deps(&watch_events, noop_logger()),
|
||||
ConfirmWatchEventsCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
confirmations: vec![],
|
||||
@@ -87,9 +95,7 @@ async fn confirms_event_with_external_metadata_id_and_no_movie_id() {
|
||||
watch_events.save(&event).await.unwrap();
|
||||
|
||||
let result = confirm::execute(
|
||||
Arc::clone(&watch_events) as _,
|
||||
Arc::clone(&watch_events) as _,
|
||||
noop_logger(),
|
||||
&deps(&watch_events, noop_logger()),
|
||||
ConfirmWatchEventsCommand {
|
||||
user_id: uid,
|
||||
confirmations: vec![WatchEventConfirmation {
|
||||
@@ -124,9 +130,7 @@ async fn rejects_other_users_event() {
|
||||
watch_events.save(&event).await.unwrap();
|
||||
|
||||
let result = confirm::execute(
|
||||
Arc::clone(&watch_events) as _,
|
||||
Arc::clone(&watch_events) as _,
|
||||
noop_logger(),
|
||||
&deps(&watch_events, noop_logger()),
|
||||
ConfirmWatchEventsCommand {
|
||||
user_id: intruder,
|
||||
confirmations: vec![WatchEventConfirmation {
|
||||
@@ -146,9 +150,7 @@ async fn fails_when_event_not_found() {
|
||||
let watch_events = InMemoryWatchEventRepository::new();
|
||||
|
||||
let result = confirm::execute(
|
||||
Arc::clone(&watch_events) as _,
|
||||
Arc::clone(&watch_events) as _,
|
||||
noop_logger(),
|
||||
&deps(&watch_events, noop_logger()),
|
||||
ConfirmWatchEventsCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
confirmations: vec![WatchEventConfirmation {
|
||||
@@ -208,9 +210,7 @@ async fn confirms_event_with_movie_id() {
|
||||
));
|
||||
|
||||
let result = confirm::execute(
|
||||
Arc::clone(&watch_events) as _,
|
||||
Arc::clone(&watch_events) as _,
|
||||
review_logger,
|
||||
&deps(&watch_events, review_logger),
|
||||
ConfirmWatchEventsCommand {
|
||||
user_id: uid,
|
||||
confirmations: vec![WatchEventConfirmation {
|
||||
@@ -244,9 +244,7 @@ async fn confirms_event_without_movie_id_and_without_external_metadata_id() {
|
||||
watch_events.save(&event).await.unwrap();
|
||||
|
||||
let result = confirm::execute(
|
||||
Arc::clone(&watch_events) as _,
|
||||
Arc::clone(&watch_events) as _,
|
||||
noop_logger(),
|
||||
&deps(&watch_events, noop_logger()),
|
||||
ConfirmWatchEventsCommand {
|
||||
user_id: uid,
|
||||
confirmations: vec![WatchEventConfirmation {
|
||||
@@ -293,9 +291,7 @@ async fn confirms_multiple_events() {
|
||||
watch_events.save(&event2).await.unwrap();
|
||||
|
||||
let result = confirm::execute(
|
||||
Arc::clone(&watch_events) as _,
|
||||
Arc::clone(&watch_events) as _,
|
||||
noop_logger(),
|
||||
&deps(&watch_events, noop_logger()),
|
||||
ConfirmWatchEventsCommand {
|
||||
user_id: uid,
|
||||
confirmations: vec![
|
||||
@@ -336,9 +332,7 @@ async fn confirms_event_without_year() {
|
||||
watch_events.save(&event).await.unwrap();
|
||||
|
||||
let result = confirm::execute(
|
||||
Arc::clone(&watch_events) as _,
|
||||
Arc::clone(&watch_events) as _,
|
||||
noop_logger(),
|
||||
&deps(&watch_events, noop_logger()),
|
||||
ConfirmWatchEventsCommand {
|
||||
user_id: uid,
|
||||
confirmations: vec![WatchEventConfirmation {
|
||||
|
||||
@@ -6,15 +6,22 @@ use domain::testing::InMemoryWatchEventRepository;
|
||||
use domain::value_objects::UserId;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::integrations::deps::DismissWatchEventsDeps;
|
||||
use crate::integrations::{commands::DismissWatchEventsCommand, dismiss};
|
||||
|
||||
fn deps(watch_events: &Arc<InMemoryWatchEventRepository>) -> DismissWatchEventsDeps {
|
||||
DismissWatchEventsDeps {
|
||||
watch_event_command: Arc::clone(watch_events) as _,
|
||||
watch_event_query: Arc::clone(watch_events) as _,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dismisses_empty_list_returns_zero() {
|
||||
let events = InMemoryWatchEventRepository::new();
|
||||
|
||||
let result = dismiss::execute(
|
||||
Arc::clone(&events) as _,
|
||||
Arc::clone(&events) as _,
|
||||
&deps(&events),
|
||||
DismissWatchEventsCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
event_ids: vec![],
|
||||
@@ -31,8 +38,7 @@ async fn fails_when_event_not_found() {
|
||||
let events = InMemoryWatchEventRepository::new();
|
||||
|
||||
let result = dismiss::execute(
|
||||
Arc::clone(&events) as _,
|
||||
Arc::clone(&events) as _,
|
||||
&deps(&events),
|
||||
DismissWatchEventsCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
event_ids: vec![Uuid::new_v4()],
|
||||
@@ -73,8 +79,7 @@ async fn dismisses_existing_events() {
|
||||
watch_events.save(&e2).await.unwrap();
|
||||
|
||||
let result = dismiss::execute(
|
||||
Arc::clone(&watch_events) as _,
|
||||
Arc::clone(&watch_events) as _,
|
||||
&deps(&watch_events),
|
||||
DismissWatchEventsCommand {
|
||||
user_id: uid,
|
||||
event_ids: vec![id1, id2],
|
||||
|
||||
@@ -5,6 +5,7 @@ use domain::ports::WebhookTokenRepository;
|
||||
use domain::testing::InMemoryWebhookTokenRepository;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::integrations::deps::GenerateWebhookTokenDeps;
|
||||
use crate::integrations::{commands::GenerateWebhookTokenCommand, generate_token};
|
||||
|
||||
#[tokio::test]
|
||||
@@ -13,7 +14,9 @@ async fn generates_token_and_saves() {
|
||||
|
||||
let user_id = Uuid::new_v4();
|
||||
let result = generate_token::execute(
|
||||
Arc::clone(&tokens),
|
||||
&GenerateWebhookTokenDeps {
|
||||
webhook_token: Arc::clone(&tokens),
|
||||
},
|
||||
GenerateWebhookTokenCommand {
|
||||
user_id,
|
||||
provider: WatchEventSource::Jellyfin,
|
||||
|
||||
@@ -7,14 +7,21 @@ use domain::testing::InMemoryWatchEventRepository;
|
||||
use domain::value_objects::UserId;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::integrations::deps::GetWatchQueueDeps;
|
||||
use crate::integrations::{get_queue, queries::GetWatchQueueQuery};
|
||||
|
||||
fn deps(events: &Arc<InMemoryWatchEventRepository>) -> GetWatchQueueDeps {
|
||||
GetWatchQueueDeps {
|
||||
watch_event_query: Arc::clone(events) as _,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_when_no_events() {
|
||||
let events = InMemoryWatchEventRepository::new();
|
||||
|
||||
let result = get_queue::execute(
|
||||
Arc::clone(&events) as _,
|
||||
&deps(&events),
|
||||
GetWatchQueueQuery {
|
||||
user_id: Uuid::new_v4(),
|
||||
},
|
||||
@@ -41,7 +48,7 @@ async fn returns_pending_events() {
|
||||
);
|
||||
events.save(&event).await.unwrap();
|
||||
|
||||
let result = get_queue::execute(Arc::clone(&events) as _, GetWatchQueueQuery { user_id })
|
||||
let result = get_queue::execute(&deps(&events), GetWatchQueueQuery { user_id })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -5,17 +5,30 @@ use domain::ports::WebhookTokenRepository;
|
||||
use domain::testing::InMemoryWebhookTokenRepository;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::integrations::deps::{GenerateWebhookTokenDeps, GetWebhookTokensDeps};
|
||||
use crate::integrations::{
|
||||
commands::GenerateWebhookTokenCommand, generate_token, get_tokens,
|
||||
queries::GetWebhookTokensQuery,
|
||||
};
|
||||
|
||||
fn generate_deps(tokens: &Arc<dyn WebhookTokenRepository>) -> GenerateWebhookTokenDeps {
|
||||
GenerateWebhookTokenDeps {
|
||||
webhook_token: Arc::clone(tokens),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_deps(tokens: &Arc<dyn WebhookTokenRepository>) -> GetWebhookTokensDeps {
|
||||
GetWebhookTokensDeps {
|
||||
webhook_token: Arc::clone(tokens),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_when_no_tokens() {
|
||||
let tokens: Arc<dyn WebhookTokenRepository> = InMemoryWebhookTokenRepository::new();
|
||||
|
||||
let result = get_tokens::execute(
|
||||
Arc::clone(&tokens),
|
||||
&get_deps(&tokens),
|
||||
GetWebhookTokensQuery {
|
||||
user_id: Uuid::new_v4(),
|
||||
},
|
||||
@@ -33,7 +46,7 @@ async fn returns_tokens_after_generate() {
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
generate_token::execute(
|
||||
Arc::clone(&tokens),
|
||||
&generate_deps(&tokens),
|
||||
GenerateWebhookTokenCommand {
|
||||
user_id,
|
||||
provider: WatchEventSource::Jellyfin,
|
||||
@@ -44,7 +57,7 @@ async fn returns_tokens_after_generate() {
|
||||
.unwrap();
|
||||
|
||||
generate_token::execute(
|
||||
Arc::clone(&tokens),
|
||||
&generate_deps(&tokens),
|
||||
GenerateWebhookTokenCommand {
|
||||
user_id,
|
||||
provider: WatchEventSource::Plex,
|
||||
@@ -54,7 +67,7 @@ async fn returns_tokens_after_generate() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = get_tokens::execute(Arc::clone(&tokens), GetWebhookTokensQuery { user_id })
|
||||
let result = get_tokens::execute(&get_deps(&tokens), GetWebhookTokensQuery { user_id })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use domain::testing::{
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::integrations::commands::{GenerateWebhookTokenCommand, IngestWatchEventCommand};
|
||||
use crate::integrations::deps::IngestWatchEventDeps;
|
||||
use crate::integrations::deps::{GenerateWebhookTokenDeps, IngestWatchEventDeps};
|
||||
use crate::integrations::{generate_token, ingest};
|
||||
|
||||
struct FakeParser;
|
||||
@@ -35,7 +35,9 @@ async fn ingests_watch_event() {
|
||||
|
||||
let user_id = Uuid::new_v4();
|
||||
let generated = generate_token::execute(
|
||||
Arc::clone(&tokens),
|
||||
&GenerateWebhookTokenDeps {
|
||||
webhook_token: Arc::clone(&tokens),
|
||||
},
|
||||
GenerateWebhookTokenCommand {
|
||||
user_id,
|
||||
provider: WatchEventSource::Jellyfin,
|
||||
|
||||
@@ -5,6 +5,9 @@ use domain::ports::WebhookTokenRepository;
|
||||
use domain::testing::InMemoryWebhookTokenRepository;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::integrations::deps::{
|
||||
GenerateWebhookTokenDeps, GetWebhookTokensDeps, RevokeWebhookTokenDeps,
|
||||
};
|
||||
use crate::integrations::{
|
||||
commands::{GenerateWebhookTokenCommand, RevokeWebhookTokenCommand},
|
||||
generate_token, get_tokens,
|
||||
@@ -19,7 +22,9 @@ async fn revokes_existing_token() {
|
||||
let user_id = Uuid::new_v4();
|
||||
|
||||
let generated = generate_token::execute(
|
||||
Arc::clone(&tokens),
|
||||
&GenerateWebhookTokenDeps {
|
||||
webhook_token: Arc::clone(&tokens),
|
||||
},
|
||||
GenerateWebhookTokenCommand {
|
||||
user_id,
|
||||
provider: WatchEventSource::Jellyfin,
|
||||
@@ -32,15 +37,22 @@ async fn revokes_existing_token() {
|
||||
let token_id = generated.token.id().value();
|
||||
|
||||
revoke_token::execute(
|
||||
Arc::clone(&tokens),
|
||||
&RevokeWebhookTokenDeps {
|
||||
webhook_token: Arc::clone(&tokens),
|
||||
},
|
||||
RevokeWebhookTokenCommand { user_id, token_id },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let remaining = get_tokens::execute(Arc::clone(&tokens), GetWebhookTokensQuery { user_id })
|
||||
.await
|
||||
.unwrap();
|
||||
let remaining = get_tokens::execute(
|
||||
&GetWebhookTokensDeps {
|
||||
webhook_token: Arc::clone(&tokens),
|
||||
},
|
||||
GetWebhookTokensQuery { user_id },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(remaining.is_empty());
|
||||
}
|
||||
|
||||
@@ -58,13 +58,11 @@ impl PeriodicJob for WrapUpAutoGenerateJob {
|
||||
start_date: start,
|
||||
end_date: end,
|
||||
};
|
||||
if let Err(e) = crate::wrapup::generate::execute(
|
||||
self.wrapup_repo.clone(),
|
||||
self.event_publisher.clone(),
|
||||
cmd,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let deps = crate::wrapup::deps::GenerateWrapUpDeps {
|
||||
wrapup_repo: self.wrapup_repo.clone(),
|
||||
event_publisher: self.event_publisher.clone(),
|
||||
};
|
||||
if let Err(e) = crate::wrapup::generate::execute(&deps, cmd).await {
|
||||
tracing::warn!(
|
||||
"auto-generate wrapup for user {} failed: {e}",
|
||||
user.user_id.value()
|
||||
@@ -81,13 +79,11 @@ impl PeriodicJob for WrapUpAutoGenerateJob {
|
||||
start_date: start,
|
||||
end_date: end,
|
||||
};
|
||||
if let Err(e) = crate::wrapup::generate::execute(
|
||||
self.wrapup_repo.clone(),
|
||||
self.event_publisher.clone(),
|
||||
cmd,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let deps = crate::wrapup::deps::GenerateWrapUpDeps {
|
||||
wrapup_repo: self.wrapup_repo.clone(),
|
||||
event_publisher: self.event_publisher.clone(),
|
||||
};
|
||||
if let Err(e) = crate::wrapup::generate::execute(&deps, cmd).await {
|
||||
tracing::warn!("auto-generate global wrapup failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
pub mod config;
|
||||
pub mod deps;
|
||||
pub mod jobs;
|
||||
pub mod ports;
|
||||
pub mod services;
|
||||
pub mod worker;
|
||||
|
||||
pub mod auth;
|
||||
@@ -19,6 +21,13 @@ pub mod wrapup;
|
||||
#[cfg(test)]
|
||||
pub mod test_helpers;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/services.rs"]
|
||||
mod services_tests;
|
||||
|
||||
pub use deps::Deps;
|
||||
pub use deps::{WorkerDeps, WorkerServices};
|
||||
pub use movies::MovieDiscoveryIndexer;
|
||||
pub use movies::SearchCleanupHandler;
|
||||
pub use movies::SearchReindexHandler;
|
||||
pub use services::Services;
|
||||
|
||||
@@ -30,3 +30,11 @@ pub struct ReindexSearchDeps {
|
||||
pub person_command: Arc<dyn PersonCommand>,
|
||||
pub person_query: Arc<dyn PersonQuery>,
|
||||
}
|
||||
|
||||
pub struct GetMovieProfileDeps {
|
||||
pub movie_profile: Arc<dyn MovieProfileRepository>,
|
||||
}
|
||||
|
||||
pub struct GetMoviesDeps {
|
||||
pub movie: Arc<dyn MovieQuery>,
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{CastMember, CrewMember, ExternalPersonId, MovieProfile, PersonId},
|
||||
ports::MovieProfileRepository,
|
||||
value_objects::MovieId,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::movies::deps::GetMovieProfileDeps;
|
||||
|
||||
pub struct GetMovieProfileQuery {
|
||||
pub movie_id: Uuid,
|
||||
}
|
||||
@@ -61,11 +60,11 @@ fn resolve_crew(member: &CrewMember) -> CrewMemberWithId {
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
movie_profile: Arc<dyn MovieProfileRepository>,
|
||||
deps: &GetMovieProfileDeps,
|
||||
query: GetMovieProfileQuery,
|
||||
) -> Result<Option<MovieProfileResult>, DomainError> {
|
||||
let movie_id = MovieId::from_uuid(query.movie_id);
|
||||
let profile = movie_profile.get_by_movie_id(&movie_id).await?;
|
||||
let profile = deps.movie_profile.get_by_movie_id(&movie_id).await?;
|
||||
|
||||
Ok(profile.map(|p| {
|
||||
let cast = p.cast.iter().map(resolve_cast).collect();
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::collections::{PageParams, Paginated},
|
||||
models::{MovieFilter, MovieSummary},
|
||||
ports::MovieQuery,
|
||||
};
|
||||
|
||||
use crate::movies::deps::GetMoviesDeps;
|
||||
use crate::movies::queries::GetMoviesQuery;
|
||||
|
||||
pub async fn execute(
|
||||
movie: Arc<dyn MovieQuery>,
|
||||
deps: &GetMoviesDeps,
|
||||
query: GetMoviesQuery,
|
||||
) -> Result<Paginated<MovieSummary>, DomainError> {
|
||||
let page = PageParams::new(query.limit, query.offset)?;
|
||||
@@ -19,7 +17,7 @@ pub async fn execute(
|
||||
genre: query.genre,
|
||||
language: query.language,
|
||||
};
|
||||
movie.list_movies(&page, &filter).await
|
||||
deps.movie.list_movies(&page, &filter).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -8,14 +8,16 @@ use domain::{
|
||||
value_objects::MovieId,
|
||||
};
|
||||
|
||||
use crate::movies::deps::GetMovieProfileDeps;
|
||||
use crate::movies::get_movie_profile::{self, GetMovieProfileQuery};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_none_when_no_profile() {
|
||||
let movie_profile = InMemoryMovieProfileRepository::new();
|
||||
let deps = GetMovieProfileDeps { movie_profile };
|
||||
|
||||
let result = get_movie_profile::execute(
|
||||
movie_profile,
|
||||
&deps,
|
||||
GetMovieProfileQuery {
|
||||
movie_id: Uuid::new_v4(),
|
||||
},
|
||||
@@ -64,8 +66,11 @@ async fn returns_profile_with_cast_and_crew() {
|
||||
};
|
||||
profile_repo.upsert(&profile).await.unwrap();
|
||||
|
||||
let deps = GetMovieProfileDeps {
|
||||
movie_profile: profile_repo.clone(),
|
||||
};
|
||||
let result = get_movie_profile::execute(
|
||||
profile_repo.clone(),
|
||||
&deps,
|
||||
GetMovieProfileQuery {
|
||||
movie_id: movie_id.value(),
|
||||
},
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
use domain::testing::InMemoryMovieRepository;
|
||||
|
||||
use crate::movies::{get_movies, queries::GetMoviesQuery};
|
||||
use crate::movies::{deps::GetMoviesDeps, get_movies, queries::GetMoviesQuery};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_when_no_movies() {
|
||||
let movie = InMemoryMovieRepository::new();
|
||||
let deps = GetMoviesDeps { movie };
|
||||
|
||||
let result = get_movies::execute(
|
||||
movie,
|
||||
&deps,
|
||||
GetMoviesQuery {
|
||||
limit: None,
|
||||
offset: None,
|
||||
|
||||
7
crates/application/src/search/deps.rs
Normal file
7
crates/application/src/search/deps.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::SearchPort;
|
||||
|
||||
pub struct SearchDeps {
|
||||
pub search_port: Arc<dyn SearchPort>,
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{SearchQuery, SearchResults},
|
||||
ports::SearchPort,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub async fn execute(
|
||||
search_port: Arc<dyn SearchPort>,
|
||||
query: SearchQuery,
|
||||
) -> Result<SearchResults, DomainError> {
|
||||
search_port.search(&query).await
|
||||
use crate::search::deps::SearchDeps;
|
||||
|
||||
pub async fn execute(deps: &SearchDeps, query: SearchQuery) -> Result<SearchResults, DomainError> {
|
||||
deps.search_port.search(&query).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub mod deps;
|
||||
pub mod execute;
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
use domain::models::SearchQuery;
|
||||
|
||||
use crate::search::deps::SearchDeps;
|
||||
use crate::search::execute;
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_results() {
|
||||
let b = TestContextBuilder::new();
|
||||
let deps = SearchDeps {
|
||||
search_port: b.search_port.clone(),
|
||||
};
|
||||
|
||||
let result = execute::execute(b.search_port.clone(), SearchQuery::default())
|
||||
let result = execute::execute(&deps, SearchQuery::default())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
|
||||
25
crates/application/src/services.rs
Normal file
25
crates/application/src/services.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
AuthService, DiaryExporter, DocumentParser, EventPublisher, MetadataClient, ObjectStorage,
|
||||
PasswordHasher, PersonEnrichmentClient, PosterFetcherClient,
|
||||
};
|
||||
|
||||
use crate::ports::ReviewLogger;
|
||||
|
||||
/// Services the application layer needs, assembled by the composition root.
|
||||
/// Adapter-typed ports do not belong here — the AP port inversion removed the
|
||||
/// last of them; see ADR-0008.
|
||||
#[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>>,
|
||||
}
|
||||
10
crates/application/src/social/count_pending_followers.rs
Normal file
10
crates/application/src/social/count_pending_followers.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use domain::{errors::DomainError, value_objects::UserId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::deps::SocialQueryDeps;
|
||||
|
||||
pub async fn execute(deps: &SocialQueryDeps, user_id: Uuid) -> Result<usize, DomainError> {
|
||||
deps.follow_graph
|
||||
.count_pending_followers(&UserId::from_uuid(user_id))
|
||||
.await
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EventPublisher, SocialCommand, SocialQuery};
|
||||
use domain::ports::{BlockQuery, EventPublisher, FollowGraphQuery, SocialCommand};
|
||||
|
||||
pub struct SocialCommandDeps {
|
||||
pub social_command: Arc<dyn SocialCommand>,
|
||||
pub social_query: Arc<dyn SocialQuery>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
pub struct SocialQueryDeps {
|
||||
pub social_query: Arc<dyn SocialQuery>,
|
||||
pub follow_graph: Arc<dyn FollowGraphQuery>,
|
||||
pub block_query: Arc<dyn BlockQuery>,
|
||||
}
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
value_objects::{SocialActor, UserId},
|
||||
};
|
||||
use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId};
|
||||
|
||||
use super::{
|
||||
commands::SocialCmd,
|
||||
deps::{SocialCommandDeps, SocialQueryDeps},
|
||||
queries::SocialQry,
|
||||
};
|
||||
use super::{commands::SocialCmd, deps::SocialCommandDeps};
|
||||
|
||||
pub async fn execute_command(deps: &SocialCommandDeps, cmd: SocialCmd) -> Result<(), DomainError> {
|
||||
let event = match cmd {
|
||||
@@ -69,24 +61,6 @@ pub async fn execute_command(deps: &SocialCommandDeps, cmd: SocialCmd) -> Result
|
||||
deps.event_publisher.publish(&event).await
|
||||
}
|
||||
|
||||
pub async fn execute_query(
|
||||
deps: &SocialQueryDeps,
|
||||
query: SocialQry,
|
||||
) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let user_id = match &query {
|
||||
SocialQry::GetFollowing { user_id }
|
||||
| SocialQry::GetFollowers { user_id }
|
||||
| SocialQry::GetPending { user_id }
|
||||
| SocialQry::GetBlocked { user_id } => UserId::from_uuid(*user_id),
|
||||
};
|
||||
match query {
|
||||
SocialQry::GetFollowing { .. } => deps.social_query.get_following(&user_id).await,
|
||||
SocialQry::GetFollowers { .. } => deps.social_query.get_followers(&user_id).await,
|
||||
SocialQry::GetPending { .. } => deps.social_query.get_pending_followers(&user_id).await,
|
||||
SocialQry::GetBlocked { .. } => deps.social_query.get_blocked(&user_id).await,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/execute.rs"]
|
||||
mod tests;
|
||||
|
||||
16
crates/application/src/social/get_blocked.rs
Normal file
16
crates/application/src/social/get_blocked.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{SocialActor, UserId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::deps::SocialQueryDeps;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &SocialQueryDeps,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<SocialActor>, DomainError> {
|
||||
deps.block_query
|
||||
.get_blocked(&UserId::from_uuid(user_id))
|
||||
.await
|
||||
}
|
||||
16
crates/application/src/social/get_followers.rs
Normal file
16
crates/application/src/social/get_followers.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{SocialActor, UserId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::deps::SocialQueryDeps;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &SocialQueryDeps,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<SocialActor>, DomainError> {
|
||||
deps.follow_graph
|
||||
.get_followers(&UserId::from_uuid(user_id))
|
||||
.await
|
||||
}
|
||||
16
crates/application/src/social/get_following.rs
Normal file
16
crates/application/src/social/get_following.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{SocialActor, UserId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::deps::SocialQueryDeps;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &SocialQueryDeps,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<SocialActor>, DomainError> {
|
||||
deps.follow_graph
|
||||
.get_following(&UserId::from_uuid(user_id))
|
||||
.await
|
||||
}
|
||||
16
crates/application/src/social/get_pending_followers.rs
Normal file
16
crates/application/src/social/get_pending_followers.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{SocialActor, UserId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::deps::SocialQueryDeps;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &SocialQueryDeps,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<SocialActor>, DomainError> {
|
||||
deps.follow_graph
|
||||
.get_pending_followers(&UserId::from_uuid(user_id))
|
||||
.await
|
||||
}
|
||||
16
crates/application/src/social/get_pending_following.rs
Normal file
16
crates/application/src/social/get_pending_following.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{SocialActor, UserId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::deps::SocialQueryDeps;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &SocialQueryDeps,
|
||||
user_id: Uuid,
|
||||
) -> Result<Vec<SocialActor>, DomainError> {
|
||||
deps.follow_graph
|
||||
.get_pending_following(&UserId::from_uuid(user_id))
|
||||
.await
|
||||
}
|
||||
17
crates/application/src/social/get_relation.rs
Normal file
17
crates/application/src/social/get_relation.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{FollowRelation, SocialIdentity, UserId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::deps::SocialQueryDeps;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &SocialQueryDeps,
|
||||
viewer_id: Uuid,
|
||||
target: SocialIdentity,
|
||||
) -> Result<FollowRelation, DomainError> {
|
||||
deps.follow_graph
|
||||
.get_relation(&UserId::from_uuid(viewer_id), &target)
|
||||
.await
|
||||
}
|
||||
276
crates/application/src/social/local_service.rs
Normal file
276
crates/application/src/social/local_service.rs
Normal file
@@ -0,0 +1,276 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{
|
||||
BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, FollowTargetResolver,
|
||||
ResolvedFollow, SocialCommand, UserRepository,
|
||||
},
|
||||
value_objects::{
|
||||
FollowRelation, FollowStatus, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity,
|
||||
UserId, Username,
|
||||
},
|
||||
};
|
||||
|
||||
/// The subset of `SocialCommand`/`FollowGraphQuery`/`BlockQuery` that a single
|
||||
/// instance can serve with only its own database — no ActivityPub involved.
|
||||
///
|
||||
/// This is the local half of `activitypub::CompositeSocialAdapter`, extracted
|
||||
/// here so `application` needs no dependency on the `activitypub` crate to
|
||||
/// offer it. It implements `domain::ports::LocalSocial` via that trait's
|
||||
/// blanket impl over the five ports below.
|
||||
pub struct LocalSocialService {
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
follow_command: Arc<dyn FollowCommand>,
|
||||
follow_query: Arc<dyn FollowQuery>,
|
||||
instance: InstanceIdentity,
|
||||
}
|
||||
|
||||
impl LocalSocialService {
|
||||
pub fn new(
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
follow_command: Arc<dyn FollowCommand>,
|
||||
follow_query: Arc<dyn FollowQuery>,
|
||||
instance: InstanceIdentity,
|
||||
) -> Self {
|
||||
Self {
|
||||
user_repo,
|
||||
follow_command,
|
||||
follow_query,
|
||||
instance,
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_target_identity(
|
||||
&self,
|
||||
target: &FollowTarget,
|
||||
) -> Result<SocialIdentity, DomainError> {
|
||||
match target {
|
||||
FollowTarget::Identity(id) => Ok(id.clone()),
|
||||
FollowTarget::Handle(handle) => {
|
||||
let host = handle.rsplit_once('@').map(|(_, h)| h).unwrap_or("");
|
||||
let local_host = self.instance.host();
|
||||
if host == local_host {
|
||||
let username_str = handle
|
||||
.trim_start_matches('@')
|
||||
.split('@')
|
||||
.next()
|
||||
.unwrap_or("");
|
||||
if let Ok(username) = Username::new(username_str.to_string())
|
||||
&& let Some(user) = self.user_repo.find_by_username(&username).await?
|
||||
{
|
||||
return Ok(SocialIdentity::Local(user.id().clone()));
|
||||
}
|
||||
}
|
||||
Ok(SocialIdentity::Remote {
|
||||
actor_url: handle.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_unsupported(actor: &str) -> DomainError {
|
||||
DomainError::ValidationError(format!(
|
||||
"cannot reach remote actor {actor}: this instance was built without the federation feature"
|
||||
))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SocialCommand for LocalSocialService {
|
||||
async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError> {
|
||||
let identity = self.resolve_target_identity(target).await?;
|
||||
self.follow_resolved(follower, &identity).await
|
||||
}
|
||||
|
||||
async fn unfollow(
|
||||
&self,
|
||||
follower: &UserId,
|
||||
target: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = self.instance.actor_url_of(target);
|
||||
match target {
|
||||
SocialIdentity::Local(target_id) => {
|
||||
let follower_url = self.instance.actor_url_for(follower);
|
||||
self.follow_command
|
||||
.remove_follow(follower.value(), &actor_url)
|
||||
.await?;
|
||||
self.follow_command
|
||||
.remove_follower_record(target_id.value(), &follower_url)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn accept_follow(
|
||||
&self,
|
||||
owner: &UserId,
|
||||
requester: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = self.instance.actor_url_of(requester);
|
||||
match requester {
|
||||
SocialIdentity::Local(requester_id) => {
|
||||
let owner_url = self.instance.actor_url_for(owner);
|
||||
self.follow_command
|
||||
.update_follower_status(owner.value(), &actor_url, FollowStatus::Accepted)
|
||||
.await?;
|
||||
self.follow_command
|
||||
.update_follow_status(requester_id.value(), &owner_url, FollowStatus::Accepted)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn reject_follow(
|
||||
&self,
|
||||
owner: &UserId,
|
||||
requester: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = self.instance.actor_url_of(requester);
|
||||
match requester {
|
||||
SocialIdentity::Local(requester_id) => {
|
||||
let owner_url = self.instance.actor_url_for(owner);
|
||||
self.follow_command
|
||||
.update_follower_status(owner.value(), &actor_url, FollowStatus::Rejected)
|
||||
.await?;
|
||||
self.follow_command
|
||||
.remove_follow(requester_id.value(), &owner_url)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_follower(
|
||||
&self,
|
||||
owner: &UserId,
|
||||
follower: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
let actor_url = self.instance.actor_url_of(follower);
|
||||
match follower {
|
||||
SocialIdentity::Local(follower_id) => {
|
||||
let owner_url = self.instance.actor_url_for(owner);
|
||||
self.follow_command
|
||||
.remove_follower_record(owner.value(), &actor_url)
|
||||
.await?;
|
||||
self.follow_command
|
||||
.remove_follow(follower_id.value(), &owner_url)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
SocialIdentity::Remote { .. } => Err(remote_unsupported(&actor_url)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn block(&self, _blocker: &UserId, _target: &SocialIdentity) -> Result<(), DomainError> {
|
||||
Err(DomainError::ValidationError(
|
||||
"blocking requires the federation feature".into(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn unblock(
|
||||
&self,
|
||||
_blocker: &UserId,
|
||||
_target: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
Err(DomainError::ValidationError(
|
||||
"blocking requires the federation feature".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FollowGraphQuery for LocalSocialService {
|
||||
async fn get_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
self.follow_query.get_following(user.value()).await
|
||||
}
|
||||
|
||||
async fn get_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
self.follow_query.get_followers(user.value()).await
|
||||
}
|
||||
|
||||
async fn get_pending_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
self.follow_query.get_pending_followers(user.value()).await
|
||||
}
|
||||
|
||||
async fn get_pending_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
self.follow_query.get_pending_following(user.value()).await
|
||||
}
|
||||
|
||||
async fn count_following(&self, user: &UserId) -> Result<usize, DomainError> {
|
||||
self.follow_query.count_following(user.value()).await
|
||||
}
|
||||
|
||||
async fn count_followers(&self, user: &UserId) -> Result<usize, DomainError> {
|
||||
self.follow_query.count_followers(user.value()).await
|
||||
}
|
||||
|
||||
async fn count_pending_followers(&self, user: &UserId) -> Result<usize, DomainError> {
|
||||
self.follow_query
|
||||
.count_pending_followers(user.value())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_relation(
|
||||
&self,
|
||||
viewer: &UserId,
|
||||
target: &SocialIdentity,
|
||||
) -> Result<FollowRelation, DomainError> {
|
||||
let actor_url = self.instance.actor_url_of(target);
|
||||
self.follow_query
|
||||
.get_relation(viewer.value(), &actor_url)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BlockQuery for LocalSocialService {
|
||||
async fn get_blocked(&self, _user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FollowTargetResolver for LocalSocialService {
|
||||
async fn resolve_target(&self, target: &FollowTarget) -> Result<SocialIdentity, DomainError> {
|
||||
self.resolve_target_identity(target).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ResolvedFollow for LocalSocialService {
|
||||
async fn follow_resolved(
|
||||
&self,
|
||||
follower: &UserId,
|
||||
target: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
let SocialIdentity::Local(target_id) = target else {
|
||||
let actor_url = self.instance.actor_url_of(target);
|
||||
return Err(remote_unsupported(&actor_url));
|
||||
};
|
||||
if follower == target_id {
|
||||
return Err(DomainError::ValidationError(
|
||||
"Cannot follow yourself".into(),
|
||||
));
|
||||
}
|
||||
let follower_url = self.instance.actor_url_for(follower);
|
||||
let target_url = self.instance.actor_url_for(target_id);
|
||||
self.follow_command
|
||||
.add_follower(target_id.value(), &follower_url, FollowStatus::Pending)
|
||||
.await?;
|
||||
self.follow_command
|
||||
.add_follow(follower.value(), &target_url, FollowStatus::Pending)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/local_service.rs"]
|
||||
mod tests;
|
||||
@@ -1,4 +1,11 @@
|
||||
pub mod commands;
|
||||
pub mod count_pending_followers;
|
||||
pub mod deps;
|
||||
pub mod execute;
|
||||
pub mod queries;
|
||||
pub mod get_blocked;
|
||||
pub mod get_followers;
|
||||
pub mod get_following;
|
||||
pub mod get_pending_followers;
|
||||
pub mod get_pending_following;
|
||||
pub mod get_relation;
|
||||
pub mod local_service;
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
pub enum SocialQry {
|
||||
GetFollowing { user_id: Uuid },
|
||||
GetFollowers { user_id: Uuid },
|
||||
GetPending { user_id: Uuid },
|
||||
GetBlocked { user_id: Uuid },
|
||||
}
|
||||
@@ -3,15 +3,14 @@ use std::sync::Arc;
|
||||
use domain::{
|
||||
events::DomainEvent,
|
||||
testing::{InMemorySocialRepository, NoopEventPublisher},
|
||||
value_objects::{FollowTarget, SocialIdentity, UserId},
|
||||
value_objects::{FollowStatus, FollowTarget, SocialIdentity, UserId},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::social::{
|
||||
commands::SocialCmd,
|
||||
deps::{SocialCommandDeps, SocialQueryDeps},
|
||||
execute::{execute_command, execute_query},
|
||||
queries::SocialQry,
|
||||
execute::execute_command,
|
||||
};
|
||||
|
||||
fn make_cmd_deps() -> (
|
||||
@@ -23,7 +22,6 @@ fn make_cmd_deps() -> (
|
||||
let events = NoopEventPublisher::new();
|
||||
let deps = SocialCommandDeps {
|
||||
social_command: Arc::clone(&social) as _,
|
||||
social_query: Arc::clone(&social) as _,
|
||||
event_publisher: Arc::clone(&events) as _,
|
||||
};
|
||||
(social, events, deps)
|
||||
@@ -312,62 +310,49 @@ async fn unblock_emits_actor_unblocked_event() {
|
||||
// ── Get following ───────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_accepted_follows() {
|
||||
let social = InMemorySocialRepository::new();
|
||||
let events = NoopEventPublisher::new();
|
||||
let cmd_deps = SocialCommandDeps {
|
||||
social_command: Arc::clone(&social) as _,
|
||||
social_query: Arc::clone(&social) as _,
|
||||
event_publisher: Arc::clone(&events) as _,
|
||||
};
|
||||
async fn get_following_returns_accepted_targets() {
|
||||
let (social, _events, cmd_deps) = make_cmd_deps();
|
||||
let query_deps = SocialQueryDeps {
|
||||
social_query: Arc::clone(&social) as _,
|
||||
follow_graph: Arc::clone(&social) as _,
|
||||
block_query: Arc::clone(&social) as _,
|
||||
};
|
||||
|
||||
let follower_id = Uuid::new_v4();
|
||||
let target_id = Uuid::new_v4();
|
||||
let follower = Uuid::new_v4();
|
||||
let target = UserId::from_uuid(Uuid::new_v4());
|
||||
|
||||
execute_command(
|
||||
&cmd_deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(UserId::from_uuid(target_id))),
|
||||
follower_id: follower,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(target.clone())),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Pending follow should not appear
|
||||
let following = execute_query(
|
||||
&query_deps,
|
||||
SocialQry::GetFollowing {
|
||||
user_id: follower_id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(following.is_empty());
|
||||
let before = crate::social::get_following::execute(&query_deps, follower)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
before.is_empty(),
|
||||
"a pending follow must not appear in get_following"
|
||||
);
|
||||
|
||||
// Accept, then it should appear
|
||||
execute_command(
|
||||
&cmd_deps,
|
||||
SocialCmd::AcceptFollow {
|
||||
owner_id: target_id,
|
||||
requester: SocialIdentity::Local(UserId::from_uuid(follower_id)),
|
||||
owner_id: target.value(),
|
||||
requester: SocialIdentity::Local(UserId::from_uuid(follower)),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let following = execute_query(
|
||||
&query_deps,
|
||||
SocialQry::GetFollowing {
|
||||
user_id: follower_id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(following.len(), 1);
|
||||
let actors = crate::social::get_following::execute(&query_deps, follower)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(actors.len(), 1);
|
||||
assert_eq!(actors[0].identity, SocialIdentity::Local(target));
|
||||
}
|
||||
|
||||
// ── Get followers ───────────────────────────────────────────────────────────
|
||||
@@ -378,11 +363,11 @@ async fn returns_accepted_followers() {
|
||||
let events = NoopEventPublisher::new();
|
||||
let cmd_deps = SocialCommandDeps {
|
||||
social_command: Arc::clone(&social) as _,
|
||||
social_query: Arc::clone(&social) as _,
|
||||
event_publisher: Arc::clone(&events) as _,
|
||||
};
|
||||
let query_deps = SocialQueryDeps {
|
||||
social_query: Arc::clone(&social) as _,
|
||||
follow_graph: Arc::clone(&social) as _,
|
||||
block_query: Arc::clone(&social) as _,
|
||||
};
|
||||
|
||||
let follower_id = Uuid::new_v4();
|
||||
@@ -408,7 +393,7 @@ async fn returns_accepted_followers() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let followers = execute_query(&query_deps, SocialQry::GetFollowers { user_id: owner_id })
|
||||
let followers = crate::social::get_followers::execute(&query_deps, owner_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(followers.len(), 1);
|
||||
@@ -422,11 +407,11 @@ async fn returns_only_pending_followers() {
|
||||
let events = NoopEventPublisher::new();
|
||||
let cmd_deps = SocialCommandDeps {
|
||||
social_command: Arc::clone(&social) as _,
|
||||
social_query: Arc::clone(&social) as _,
|
||||
event_publisher: Arc::clone(&events) as _,
|
||||
};
|
||||
let query_deps = SocialQueryDeps {
|
||||
social_query: Arc::clone(&social) as _,
|
||||
follow_graph: Arc::clone(&social) as _,
|
||||
block_query: Arc::clone(&social) as _,
|
||||
};
|
||||
|
||||
let follower_id = Uuid::new_v4();
|
||||
@@ -442,8 +427,254 @@ async fn returns_only_pending_followers() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let pending = execute_query(&query_deps, SocialQry::GetPending { user_id: owner_id })
|
||||
let pending = crate::social::get_pending_followers::execute(&query_deps, owner_id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
}
|
||||
|
||||
// ── get_relation ────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_relation_reports_none_for_strangers() {
|
||||
use domain::ports::FollowGraphQuery;
|
||||
|
||||
let (social, _events, _deps) = make_cmd_deps();
|
||||
let rel = FollowGraphQuery::get_relation(
|
||||
&*social,
|
||||
&UserId::from_uuid(Uuid::new_v4()),
|
||||
&SocialIdentity::Local(UserId::from_uuid(Uuid::new_v4())),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rel.following, None);
|
||||
assert_eq!(rel.followed_by, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_relation_reports_pending_before_acceptance() {
|
||||
use domain::ports::FollowGraphQuery;
|
||||
|
||||
let (social, _events, deps) = make_cmd_deps();
|
||||
let a = Uuid::new_v4();
|
||||
let b_id = UserId::from_uuid(Uuid::new_v4());
|
||||
|
||||
execute_command(
|
||||
&deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id: a,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(b_id.clone())),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rel = FollowGraphQuery::get_relation(
|
||||
&*social,
|
||||
&UserId::from_uuid(a),
|
||||
&SocialIdentity::Local(b_id),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rel.following, Some(FollowStatus::Pending));
|
||||
assert_eq!(rel.followed_by, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_relation_reports_accepted_in_both_directions_after_mutual_follow() {
|
||||
use domain::ports::FollowGraphQuery;
|
||||
|
||||
let (social, _events, deps) = make_cmd_deps();
|
||||
let a = Uuid::new_v4();
|
||||
let b = Uuid::new_v4();
|
||||
let a_id = UserId::from_uuid(a);
|
||||
let b_id = UserId::from_uuid(b);
|
||||
|
||||
for (from, to) in [(a, b_id.clone()), (b, a_id.clone())] {
|
||||
execute_command(
|
||||
&deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id: from,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(to.clone())),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let owner = if from == a { b } else { a };
|
||||
execute_command(
|
||||
&deps,
|
||||
SocialCmd::AcceptFollow {
|
||||
owner_id: owner,
|
||||
requester: SocialIdentity::Local(UserId::from_uuid(from)),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let rel = FollowGraphQuery::get_relation(&*social, &a_id, &SocialIdentity::Local(b_id))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rel.following, Some(FollowStatus::Accepted));
|
||||
assert_eq!(rel.followed_by, Some(FollowStatus::Accepted));
|
||||
}
|
||||
|
||||
// ── get_pending_following ───────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn unaccepted_follow_appears_in_pending_following_not_in_following() {
|
||||
use domain::ports::FollowGraphQuery;
|
||||
|
||||
let (social, _events, deps) = make_cmd_deps();
|
||||
let viewer = Uuid::new_v4();
|
||||
let target = UserId::from_uuid(Uuid::new_v4());
|
||||
|
||||
execute_command(
|
||||
&deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id: viewer,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(target.clone())),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let pending = FollowGraphQuery::get_pending_following(&*social, &UserId::from_uuid(viewer))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].identity, SocialIdentity::Local(target.clone()));
|
||||
|
||||
let following = FollowGraphQuery::get_following(&*social, &UserId::from_uuid(viewer))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
following.is_empty(),
|
||||
"an unaccepted follow must not appear in get_following"
|
||||
);
|
||||
}
|
||||
|
||||
// ── get_pending_following use case ──────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_pending_following_returns_unaccepted_targets_and_get_following_does_not() {
|
||||
let (social, _events, cmd_deps) = make_cmd_deps();
|
||||
let query_deps = SocialQueryDeps {
|
||||
follow_graph: Arc::clone(&social) as _,
|
||||
block_query: Arc::clone(&social) as _,
|
||||
};
|
||||
let follower = Uuid::new_v4();
|
||||
let target = UserId::from_uuid(Uuid::new_v4());
|
||||
|
||||
execute_command(
|
||||
&cmd_deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id: follower,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(target.clone())),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
// deliberately NOT accepted
|
||||
|
||||
let pending = crate::social::get_pending_following::execute(&query_deps, follower)
|
||||
.await
|
||||
.unwrap();
|
||||
let accepted = crate::social::get_following::execute(&query_deps, follower)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].identity, SocialIdentity::Local(target));
|
||||
assert!(
|
||||
accepted.is_empty(),
|
||||
"a pending follow must not leak into get_following — the privacy invariant"
|
||||
);
|
||||
}
|
||||
|
||||
// ── count_pending_followers use case ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn count_pending_followers_drops_as_requests_are_accepted() {
|
||||
let (social, _events, cmd_deps) = make_cmd_deps();
|
||||
let query_deps = SocialQueryDeps {
|
||||
follow_graph: Arc::clone(&social) as _,
|
||||
block_query: Arc::clone(&social) as _,
|
||||
};
|
||||
let owner = UserId::from_uuid(Uuid::new_v4());
|
||||
let a = Uuid::new_v4();
|
||||
let b = Uuid::new_v4();
|
||||
|
||||
for follower in [a, b] {
|
||||
execute_command(
|
||||
&cmd_deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id: follower,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(owner.clone())),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let before = crate::social::count_pending_followers::execute(&query_deps, owner.value())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(before, 2);
|
||||
|
||||
execute_command(
|
||||
&cmd_deps,
|
||||
SocialCmd::AcceptFollow {
|
||||
owner_id: owner.value(),
|
||||
requester: SocialIdentity::Local(UserId::from_uuid(a)),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let after = crate::social::count_pending_followers::execute(&query_deps, owner.value())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
after, 1,
|
||||
"accepting a request must remove it from the pending count"
|
||||
);
|
||||
}
|
||||
|
||||
// ── get_relation use case ───────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_relation_use_case_reports_direction_asymmetrically() {
|
||||
let (social, _events, cmd_deps) = make_cmd_deps();
|
||||
let query_deps = SocialQueryDeps {
|
||||
follow_graph: Arc::clone(&social) as _,
|
||||
block_query: Arc::clone(&social) as _,
|
||||
};
|
||||
let viewer = Uuid::new_v4();
|
||||
let target = UserId::from_uuid(Uuid::new_v4());
|
||||
|
||||
execute_command(
|
||||
&cmd_deps,
|
||||
SocialCmd::Follow {
|
||||
follower_id: viewer,
|
||||
target: FollowTarget::Identity(SocialIdentity::Local(target.clone())),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let rel =
|
||||
crate::social::get_relation::execute(&query_deps, viewer, SocialIdentity::Local(target))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
rel.following,
|
||||
Some(FollowStatus::Pending),
|
||||
"viewer -> target"
|
||||
);
|
||||
assert_eq!(rel.followed_by, None, "target has not followed back");
|
||||
}
|
||||
|
||||
444
crates/application/src/social/tests/local_service.rs
Normal file
444
crates/application/src/social/tests/local_service.rs
Normal file
@@ -0,0 +1,444 @@
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{User, UserRole},
|
||||
ports::{
|
||||
BlockQuery, FollowCommand, FollowGraphQuery, FollowQuery, SocialCommand, UserRepository,
|
||||
},
|
||||
testing::InMemoryUserRepository,
|
||||
value_objects::{
|
||||
Email, FollowRelation, FollowStatus, FollowTarget, InstanceIdentity, PasswordHash,
|
||||
SocialActor, SocialIdentity, UserId, Username,
|
||||
},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::social::local_service::LocalSocialService;
|
||||
|
||||
// ── Fakes ────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// No fake exists anywhere for FollowCommand/FollowQuery. `InMemorySocialRepository`
|
||||
// (domain::testing) implements SocialCommand/FollowGraphQuery/BlockQuery — the same
|
||||
// level as LocalSocialService itself — so it cannot stand in as its dependency.
|
||||
// A single struct backs both ports, mirroring how one database table serves both
|
||||
// sides of the follow edge in production.
|
||||
|
||||
struct FollowFakeStore {
|
||||
// (follower_id, target_actor_url, status) — written by add_follow/update_follow_status/remove_follow
|
||||
follows: Mutex<Vec<(Uuid, String, FollowStatus)>>,
|
||||
// (local_user_id, follower_actor_url, status) — written by add_follower/update_follower_status/remove_follower_record
|
||||
followers: Mutex<Vec<(Uuid, String, FollowStatus)>>,
|
||||
}
|
||||
|
||||
impl FollowFakeStore {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
follows: Mutex::new(Vec::new()),
|
||||
followers: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_actor(url: &str) -> SocialActor {
|
||||
SocialActor {
|
||||
identity: SocialIdentity::Remote {
|
||||
actor_url: url.to_string(),
|
||||
},
|
||||
handle: url.to_string(),
|
||||
display_name: None,
|
||||
avatar_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FollowCommand for FollowFakeStore {
|
||||
async fn add_follow(
|
||||
&self,
|
||||
follower_id: Uuid,
|
||||
target_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
self.follows
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((follower_id, target_actor_url.to_string(), status));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_follow_status(
|
||||
&self,
|
||||
follower_id: Uuid,
|
||||
target_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let mut store = self.follows.lock().unwrap();
|
||||
if let Some(entry) = store
|
||||
.iter_mut()
|
||||
.find(|(f, t, _)| *f == follower_id && t == target_actor_url)
|
||||
{
|
||||
entry.2 = status;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_follow(
|
||||
&self,
|
||||
follower_id: Uuid,
|
||||
target_actor_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.follows
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|(f, t, _)| !(*f == follower_id && t == target_actor_url));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_follower(
|
||||
&self,
|
||||
local_user_id: Uuid,
|
||||
follower_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
self.followers.lock().unwrap().push((
|
||||
local_user_id,
|
||||
follower_actor_url.to_string(),
|
||||
status,
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_follower_status(
|
||||
&self,
|
||||
local_user_id: Uuid,
|
||||
follower_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let mut store = self.followers.lock().unwrap();
|
||||
if let Some(entry) = store
|
||||
.iter_mut()
|
||||
.find(|(u, f, _)| *u == local_user_id && f == follower_actor_url)
|
||||
{
|
||||
entry.2 = status;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_follower_record(
|
||||
&self,
|
||||
local_user_id: Uuid,
|
||||
follower_actor_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.followers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|(u, f, _)| !(*u == local_user_id && f == follower_actor_url));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FollowQuery for FollowFakeStore {
|
||||
async fn get_following(&self, user_id: Uuid) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(self
|
||||
.follows
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(f, _, _)| *f == user_id)
|
||||
.map(|(_, t, _)| fake_actor(t))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_followers(&self, user_id: Uuid) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(self
|
||||
.followers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(u, _, _)| *u == user_id)
|
||||
.map(|(_, f, _)| fake_actor(f))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_pending_followers(&self, user_id: Uuid) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(self
|
||||
.followers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(u, _, s)| *u == user_id && *s == FollowStatus::Pending)
|
||||
.map(|(_, f, _)| fake_actor(f))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_pending_following(&self, user_id: Uuid) -> Result<Vec<SocialActor>, DomainError> {
|
||||
Ok(self
|
||||
.follows
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(f, _, s)| *f == user_id && *s == FollowStatus::Pending)
|
||||
.map(|(_, t, _)| fake_actor(t))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_following(&self, user_id: Uuid) -> Result<usize, DomainError> {
|
||||
Ok(self
|
||||
.follows
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(f, _, _)| *f == user_id)
|
||||
.count())
|
||||
}
|
||||
|
||||
async fn count_followers(&self, user_id: Uuid) -> Result<usize, DomainError> {
|
||||
Ok(self
|
||||
.followers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(u, _, _)| *u == user_id)
|
||||
.count())
|
||||
}
|
||||
|
||||
async fn count_pending_followers(&self, user_id: Uuid) -> Result<usize, DomainError> {
|
||||
Ok(self
|
||||
.followers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|(u, _, s)| *u == user_id && *s == FollowStatus::Pending)
|
||||
.count())
|
||||
}
|
||||
|
||||
async fn get_relation(
|
||||
&self,
|
||||
viewer_id: Uuid,
|
||||
target_actor_url: &str,
|
||||
) -> Result<FollowRelation, DomainError> {
|
||||
let following = self
|
||||
.follows
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|(f, t, _)| *f == viewer_id && t == target_actor_url)
|
||||
.map(|(_, _, s)| *s);
|
||||
let followed_by = self
|
||||
.followers
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|(u, f, _)| *u == viewer_id && f == target_actor_url)
|
||||
.map(|(_, _, s)| *s);
|
||||
Ok(FollowRelation {
|
||||
following,
|
||||
followed_by,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
fn instance() -> InstanceIdentity {
|
||||
InstanceIdentity::new("http://md.example")
|
||||
}
|
||||
|
||||
async fn register_user(repo: &Arc<InMemoryUserRepository>, username: &str) -> UserId {
|
||||
let user = User::new(
|
||||
Email::new(format!("{username}@example.com")).unwrap(),
|
||||
Username::new(username.to_string()).unwrap(),
|
||||
PasswordHash::new("hashed-password".to_string()).unwrap(),
|
||||
UserRole::Standard,
|
||||
);
|
||||
let id = user.id().clone();
|
||||
repo.save(&user).await.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn service(
|
||||
user_repo: Arc<InMemoryUserRepository>,
|
||||
follow_store: &Arc<FollowFakeStore>,
|
||||
instance: InstanceIdentity,
|
||||
) -> LocalSocialService {
|
||||
LocalSocialService::new(
|
||||
user_repo as Arc<dyn UserRepository>,
|
||||
follow_store.clone() as Arc<dyn FollowCommand>,
|
||||
follow_store.clone() as Arc<dyn FollowQuery>,
|
||||
instance,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_local_user_writes_both_sides() {
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
let bob_id = register_user(&user_repo, "bob").await;
|
||||
let alice_id = register_user(&user_repo, "alice").await;
|
||||
let follow_store = FollowFakeStore::new();
|
||||
let instance = instance();
|
||||
let svc = service(user_repo, &follow_store, instance.clone());
|
||||
|
||||
svc.follow(
|
||||
&alice_id,
|
||||
&FollowTarget::Handle("@bob@md.example".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let bob_url = instance.actor_url_for(&bob_id);
|
||||
let alice_url = instance.actor_url_for(&alice_id);
|
||||
|
||||
let followers = svc.get_followers(&bob_id).await.unwrap();
|
||||
assert!(
|
||||
followers.iter().any(|a| a.handle == alice_url),
|
||||
"add_follower should have recorded alice as bob's follower"
|
||||
);
|
||||
|
||||
let following = svc.get_following(&alice_id).await.unwrap();
|
||||
assert!(
|
||||
following.iter().any(|a| a.handle == bob_url),
|
||||
"add_follow should have recorded bob in alice's following list"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_self_is_rejected() {
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
let alice_id = register_user(&user_repo, "alice").await;
|
||||
let follow_store = FollowFakeStore::new();
|
||||
let svc = service(user_repo, &follow_store, instance());
|
||||
|
||||
let result = svc
|
||||
.follow(
|
||||
&alice_id,
|
||||
&FollowTarget::Handle("@alice@md.example".to_string()),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(DomainError::ValidationError(msg)) => assert_eq!(
|
||||
msg, "Cannot follow yourself",
|
||||
"the self-follow guard must not collapse into the generic remote-target error"
|
||||
),
|
||||
other => panic!("expected ValidationError, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
follow_store.follows.lock().unwrap().len(),
|
||||
0,
|
||||
"self-follow must write nothing to the follows side"
|
||||
);
|
||||
assert_eq!(
|
||||
follow_store.followers.lock().unwrap().len(),
|
||||
0,
|
||||
"self-follow must write nothing to the followers side"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn follow_remote_target_errors_rather_than_silently_succeeding() {
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
let alice_id = register_user(&user_repo, "alice").await;
|
||||
let follow_store = FollowFakeStore::new();
|
||||
let svc = service(user_repo, &follow_store, instance());
|
||||
|
||||
let result = svc
|
||||
.follow(
|
||||
&alice_id,
|
||||
&FollowTarget::Handle("@carol@other.example".to_string()),
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Err(DomainError::ValidationError(msg)) => {
|
||||
assert!(
|
||||
msg.contains("@carol@other.example"),
|
||||
"the remote-target error must name the specific handle that failed, \
|
||||
not a generic message: {msg}"
|
||||
);
|
||||
assert!(
|
||||
msg != "Cannot follow yourself",
|
||||
"the remote-target error must not collapse into the self-follow message"
|
||||
);
|
||||
}
|
||||
other => panic!("expected ValidationError, got {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
follow_store.follows.lock().unwrap().len(),
|
||||
0,
|
||||
"a remote target must write nothing — this replaces NoopSocialCommand's silent Ok(())"
|
||||
);
|
||||
assert_eq!(follow_store.followers.lock().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reject_follow_updates_follower_status_but_removes_the_follow_row() {
|
||||
// Asymmetry with accept_follow, which updates the follow row's status instead
|
||||
// of removing it: reject_follow marks the follower row Rejected but deletes
|
||||
// the follow row outright. A test that only checked the follower row would
|
||||
// pass against a reject_follow "corrected" into accept_follow's symmetry —
|
||||
// the second assertion is the one that carries the weight.
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
let owner_id = register_user(&user_repo, "owner").await;
|
||||
let requester_id = register_user(&user_repo, "requester").await;
|
||||
let follow_store = FollowFakeStore::new();
|
||||
let instance = instance();
|
||||
let svc = service(user_repo, &follow_store, instance.clone());
|
||||
|
||||
svc.follow(
|
||||
&requester_id,
|
||||
&FollowTarget::Handle("@owner@md.example".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
svc.reject_follow(&owner_id, &SocialIdentity::Local(requester_id.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let requester_url = instance.actor_url_for(&requester_id);
|
||||
let owner_url = instance.actor_url_for(&owner_id);
|
||||
|
||||
{
|
||||
let followers = follow_store.followers.lock().unwrap();
|
||||
let follower_row = followers
|
||||
.iter()
|
||||
.find(|(u, f, _)| *u == owner_id.value() && f == &requester_url)
|
||||
.expect("the follower row must still exist after rejection");
|
||||
assert_eq!(
|
||||
follower_row.2,
|
||||
FollowStatus::Rejected,
|
||||
"reject_follow must mark the follower row Rejected"
|
||||
);
|
||||
}
|
||||
|
||||
let follows = follow_store.follows.lock().unwrap();
|
||||
assert!(
|
||||
!follows
|
||||
.iter()
|
||||
.any(|(f, t, _)| *f == requester_id.value() && t == &owner_url),
|
||||
"reject_follow must remove the follow row outright, not merely mark it Rejected — \
|
||||
this is the deliberate asymmetry with accept_follow"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn block_errors_and_get_blocked_is_empty() {
|
||||
let user_repo = InMemoryUserRepository::new();
|
||||
let alice_id = register_user(&user_repo, "alice").await;
|
||||
let follow_store = FollowFakeStore::new();
|
||||
let svc = service(user_repo, &follow_store, instance());
|
||||
|
||||
let target = SocialIdentity::Remote {
|
||||
actor_url: "https://other.example/users/carol".to_string(),
|
||||
};
|
||||
|
||||
let result = svc.block(&alice_id, &target).await;
|
||||
assert!(matches!(result, Err(DomainError::ValidationError(_))));
|
||||
|
||||
let blocked = svc.get_blocked(&alice_id).await.unwrap();
|
||||
assert_eq!(blocked.len(), 0);
|
||||
}
|
||||
@@ -74,7 +74,8 @@ pub struct TestContextBuilder {
|
||||
pub user_settings_repo: Arc<dyn UserSettingsRepository>,
|
||||
pub review_logger: Arc<dyn ReviewLogger>,
|
||||
pub social_command: Arc<dyn domain::ports::SocialCommand>,
|
||||
pub social_query_unified: Arc<dyn domain::ports::SocialQuery>,
|
||||
pub social_query_unified: Arc<dyn domain::ports::FollowGraphQuery>,
|
||||
pub block_query: Arc<dyn domain::ports::BlockQuery>,
|
||||
pub federation_admin: Arc<dyn domain::ports::FederationAdminQuery>,
|
||||
pub refresh_session_repo: Arc<dyn RefreshSessionRepository>,
|
||||
pub config: AppConfig,
|
||||
@@ -127,6 +128,7 @@ impl TestContextBuilder {
|
||||
review_logger: Arc::new(NoopReviewLogger),
|
||||
social_command: Arc::clone(&social) as _,
|
||||
social_query_unified: Arc::clone(&social) as _,
|
||||
block_query: Arc::clone(&social) as _,
|
||||
federation_admin: Arc::new(NoopFederationAdminQuery),
|
||||
refresh_session_repo: InMemoryRefreshSessionRepository::new(),
|
||||
config: AppConfig {
|
||||
|
||||
43
crates/application/src/tests/services.rs
Normal file
43
crates/application/src/tests/services.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use crate::services::Services;
|
||||
use crate::test_helpers::NoopReviewLogger;
|
||||
use domain::testing::{
|
||||
FakeAuthService, FakeDocumentParser, FakeMetadataClient, FakePasswordHasher, FakePosterFetcher,
|
||||
NoopEventPublisher, NoopObjectStorage, PanicDiaryExporter,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Services must be constructible from domain ports alone — no adapter types.
|
||||
/// If this file ever needs an adapter crate import, the boundary has regressed.
|
||||
/// Asserts on Clone specifically: AppState is Clone, so a Services that clones
|
||||
/// its Arcs by value instead of sharing them would silently duplicate state.
|
||||
#[test]
|
||||
fn services_clone_shares_the_same_port_instances() {
|
||||
let logger: Arc<dyn crate::ports::ReviewLogger> = Arc::new(NoopReviewLogger);
|
||||
let s = Services {
|
||||
review_logger: Arc::clone(&logger),
|
||||
person_enrichment: None,
|
||||
auth: Arc::new(FakeAuthService),
|
||||
password_hasher: Arc::new(FakePasswordHasher),
|
||||
metadata: Arc::new(FakeMetadataClient),
|
||||
poster_fetcher: Arc::new(FakePosterFetcher),
|
||||
object_storage: Arc::new(NoopObjectStorage),
|
||||
event_publisher: NoopEventPublisher::new(),
|
||||
diary_exporter: Arc::new(PanicDiaryExporter),
|
||||
document_parser: Arc::new(FakeDocumentParser),
|
||||
};
|
||||
|
||||
let cloned = s.clone();
|
||||
|
||||
assert!(
|
||||
Arc::ptr_eq(&s.review_logger, &cloned.review_logger),
|
||||
"Clone must share port instances, not duplicate them"
|
||||
);
|
||||
assert!(
|
||||
Arc::ptr_eq(&s.review_logger, &logger),
|
||||
"the field must hold the Arc it was given"
|
||||
);
|
||||
assert!(
|
||||
cloned.person_enrichment.is_none(),
|
||||
"optional ports must survive Clone as None"
|
||||
);
|
||||
}
|
||||
28
crates/application/src/users/authorize_admin.rs
Normal file
28
crates/application/src/users/authorize_admin.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::UserRole;
|
||||
use domain::value_objects::UserId;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::users::deps::AuthorizeAdminDeps;
|
||||
|
||||
/// Whether `user_id` is an admin, for the two extractors (`AdminApiUser`,
|
||||
/// `AdminUser`) that gate admin-only routes.
|
||||
///
|
||||
/// Returns `Ok(None)` when the user row does not exist — a distinct success
|
||||
/// value, not folded into `DomainError::NotFound`, specifically so callers can
|
||||
/// tell "row missing" apart from "repository call failed" without depending on
|
||||
/// which `DomainError` variant a lookup failure happens to produce. Each
|
||||
/// extractor rejects those two cases differently (missing row is a 401/404,
|
||||
/// a repository error is a 500) and that distinction must stay exact.
|
||||
pub async fn execute(
|
||||
deps: &AuthorizeAdminDeps,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<bool>, DomainError> {
|
||||
let id = UserId::from_uuid(user_id);
|
||||
let found = deps.user.find_by_id(&id).await?;
|
||||
Ok(found.map(|user| matches!(user.role(), UserRole::Admin)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/authorize_admin.rs"]
|
||||
mod tests;
|
||||
@@ -1,14 +1,48 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
DiaryQuery, EventPublisher, FederationAdminQuery, ObjectStorage, SocialQuery, StatsRepository,
|
||||
UserRepository,
|
||||
DiaryQuery, EventPublisher, FederatedProfileQuery, FederationAdminQuery, FollowGraphQuery,
|
||||
ObjectStorage, StatsRepository, UserProfileFieldsRepository, UserRepository,
|
||||
UserSettingsRepository,
|
||||
};
|
||||
use domain::value_objects::InstanceIdentity;
|
||||
|
||||
pub struct GetProfileDeps {
|
||||
/// The local half of the former `GetProfileDeps` split (ADR-0004). `user` and
|
||||
/// `instance` build the always-populated `ProfileIdentity`; `stats`/`diary`/
|
||||
/// `social_query` are unchanged from before the split.
|
||||
pub struct GetLocalProfileDeps {
|
||||
pub stats: Arc<dyn StatsRepository>,
|
||||
pub diary: Arc<dyn DiaryQuery>,
|
||||
pub social_query: Arc<dyn SocialQuery>,
|
||||
pub social_query: Arc<dyn FollowGraphQuery>,
|
||||
pub user: Arc<dyn UserRepository>,
|
||||
pub instance: InstanceIdentity,
|
||||
}
|
||||
|
||||
/// The federated half of the former `GetProfileDeps` split. No `user`/`instance` —
|
||||
/// the federated handler builds identity from the resolved remote actor and never
|
||||
/// reads a local user row for this path.
|
||||
pub struct GetFederatedProfileStatsDeps {
|
||||
pub stats: Arc<dyn StatsRepository>,
|
||||
pub diary: Arc<dyn DiaryQuery>,
|
||||
pub social_query: Arc<dyn FollowGraphQuery>,
|
||||
}
|
||||
|
||||
/// Backs page-chrome data (email/role badge, pending-follow badge). See the error
|
||||
/// policy note on `get_page_viewer::execute`: this deliberately propagates errors
|
||||
/// rather than degrading internally — the caller decides chrome degrades.
|
||||
pub struct GetPageViewerDeps {
|
||||
pub user: Arc<dyn UserRepository>,
|
||||
pub follow_graph: Arc<dyn FollowGraphQuery>,
|
||||
}
|
||||
|
||||
pub struct ResolveUsernameDeps {
|
||||
pub user: Arc<dyn UserRepository>,
|
||||
}
|
||||
|
||||
pub struct GetProfileSettingsDeps {
|
||||
pub user: Arc<dyn UserRepository>,
|
||||
pub profile_fields: Arc<dyn UserProfileFieldsRepository>,
|
||||
pub instance: InstanceIdentity,
|
||||
}
|
||||
|
||||
pub struct GetUsersListDeps {
|
||||
@@ -26,3 +60,31 @@ pub struct DeleteAccountDeps {
|
||||
pub user: Arc<dyn UserRepository>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
pub struct GetCurrentProfileDeps {
|
||||
pub user: Arc<dyn UserRepository>,
|
||||
}
|
||||
|
||||
pub struct UpdateProfileFieldsDeps {
|
||||
pub profile_fields: Arc<dyn UserProfileFieldsRepository>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
pub struct GetSettingsDeps {
|
||||
pub user_settings: Arc<dyn UserSettingsRepository>,
|
||||
}
|
||||
|
||||
pub struct UpdateSettingsDeps {
|
||||
pub user_settings: Arc<dyn UserSettingsRepository>,
|
||||
}
|
||||
|
||||
pub struct AuthorizeAdminDeps {
|
||||
pub user: Arc<dyn UserRepository>,
|
||||
}
|
||||
|
||||
/// `Option` mirrors `Repositories::federated_profile` — genuine optional
|
||||
/// configuration (federation on/off), not a container-shape workaround. Same
|
||||
/// exemption as `Services::person_enrichment`.
|
||||
pub struct GetFederatedProfileDeps {
|
||||
pub federated_profile: Option<Arc<dyn FederatedProfileQuery>>,
|
||||
}
|
||||
|
||||
80
crates/application/src/users/diary_filter.rs
Normal file
80
crates/application/src/users/diary_filter.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
//! Shared, pure helpers for turning a `GetUserProfileQuery`'s sort/paging/search
|
||||
//! fields into a `DiaryFilter`. Used by both halves of the former `get_profile`
|
||||
//! split (`get_local_profile`, `get_federated_profile_stats`) so the query-shaping
|
||||
//! logic has exactly one definition.
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{DiaryFilter, FeedSortBy, ReviewSortBy, collections::PageParams},
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
pub(super) fn feed_sort_to_direction(sort_by: FeedSortBy) -> ReviewSortBy {
|
||||
match sort_by {
|
||||
FeedSortBy::Date => ReviewSortBy::Descending,
|
||||
FeedSortBy::DateAsc => ReviewSortBy::Ascending,
|
||||
FeedSortBy::Rating => ReviewSortBy::ByRatingDesc,
|
||||
FeedSortBy::RatingAsc => ReviewSortBy::ByRatingAsc,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn paged_user_filter(
|
||||
user_id: UserId,
|
||||
sort_by: ReviewSortBy,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
search: Option<String>,
|
||||
include_remote: bool,
|
||||
) -> Result<DiaryFilter, DomainError> {
|
||||
let page = PageParams::new(limit, offset)?;
|
||||
Ok(DiaryFilter {
|
||||
sort_by,
|
||||
page,
|
||||
movie_id: None,
|
||||
user_id: Some(user_id),
|
||||
search,
|
||||
include_remote,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn feed_sort_to_direction_all_variants() {
|
||||
assert!(matches!(
|
||||
feed_sort_to_direction(FeedSortBy::Date),
|
||||
ReviewSortBy::Descending
|
||||
));
|
||||
assert!(matches!(
|
||||
feed_sort_to_direction(FeedSortBy::DateAsc),
|
||||
ReviewSortBy::Ascending
|
||||
));
|
||||
assert!(matches!(
|
||||
feed_sort_to_direction(FeedSortBy::Rating),
|
||||
ReviewSortBy::ByRatingDesc
|
||||
));
|
||||
assert!(matches!(
|
||||
feed_sort_to_direction(FeedSortBy::RatingAsc),
|
||||
ReviewSortBy::ByRatingAsc
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paged_user_filter_builds_correctly() {
|
||||
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
|
||||
let filter = paged_user_filter(
|
||||
uid.clone(),
|
||||
ReviewSortBy::Descending,
|
||||
Some(20),
|
||||
Some(5),
|
||||
Some("blade".into()),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(filter.user_id.unwrap().value(), uid.value());
|
||||
assert_eq!(filter.search.as_deref(), Some("blade"));
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{errors::DomainError, ports::UserRepository};
|
||||
use domain::errors::DomainError;
|
||||
|
||||
use crate::users::deps::GetCurrentProfileDeps;
|
||||
use crate::users::queries::GetCurrentProfileQuery;
|
||||
|
||||
pub struct ProfileFieldData {
|
||||
@@ -21,11 +20,12 @@ pub struct CurrentProfileData {
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
user: Arc<dyn UserRepository>,
|
||||
deps: &GetCurrentProfileDeps,
|
||||
query: GetCurrentProfileQuery,
|
||||
) -> Result<CurrentProfileData, DomainError> {
|
||||
let user_id = domain::value_objects::UserId::from_uuid(query.user_id);
|
||||
let found = user
|
||||
let found = deps
|
||||
.user
|
||||
.find_by_id(&user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("User not found".into()))?;
|
||||
|
||||
31
crates/application/src/users/get_federated_profile.rs
Normal file
31
crates/application/src/users/get_federated_profile.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::FederatedProfile;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::users::deps::GetFederatedProfileDeps;
|
||||
|
||||
/// Looks up a federated (remote) profile by synthetic user id — the fallback
|
||||
/// path `get_user_profile` (`handlers/users.rs`) takes when the local-profile
|
||||
/// lookup comes back `NotFound`.
|
||||
///
|
||||
/// Federation being disabled (`deps.federated_profile` is `None`) collapses to
|
||||
/// `Ok(None)`, the same value a present-but-empty lookup would return. That's
|
||||
/// deliberate: the handler's `if let Ok(Some(fed)) = ...` treats "port absent",
|
||||
/// "call returned `Ok(None)`", and "call returned `Err`" identically — all three
|
||||
/// fall through to a 404 — and this use case must not make any of those three
|
||||
/// distinguishable to a caller that cannot act on the difference. An `Err` from
|
||||
/// the port is propagated unchanged; the swallow stays at the handler, exactly
|
||||
/// where it already was.
|
||||
pub async fn execute(
|
||||
deps: &GetFederatedProfileDeps,
|
||||
user_id: Uuid,
|
||||
) -> Result<Option<FederatedProfile>, DomainError> {
|
||||
match &deps.federated_profile {
|
||||
Some(fed_query) => fed_query.get_federated_profile(user_id).await,
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_federated_profile.rs"]
|
||||
mod tests;
|
||||
89
crates/application/src/users/get_federated_profile_stats.rs
Normal file
89
crates/application/src/users/get_federated_profile_stats.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
//! The federated half of the former `get_profile` (ADR-0004's closed wart).
|
||||
//! `build_federated_profile_response` (`handlers/users.rs`) calls this with a
|
||||
//! synthetic `user_id` that has no row in the local `users` table — a federated
|
||||
//! actor's identity is built entirely from the resolved `FederatedProfile`, so
|
||||
//! this type carries no `identity` field at all: the old `get_profile` computed
|
||||
//! one for this path anyway, filled with empty-string sentinels for `username`/
|
||||
//! `handle`, and the handler never read it (see the closed wart paragraph in
|
||||
//! `docs/adr/0004-instance-identity.md`). No type here can represent that
|
||||
//! sentinel, because there is nothing to fill in.
|
||||
//!
|
||||
//! A federated actor also has no local pending-follow-request concept, so unlike
|
||||
//! `get_local_profile` this never computes `pending_followers` — it would always
|
||||
//! be empty here regardless of `is_own_profile`.
|
||||
|
||||
use crate::users::{
|
||||
deps::GetFederatedProfileStatsDeps,
|
||||
diary_filter::{feed_sort_to_direction, paged_user_filter},
|
||||
queries::{GetUserProfileQuery, ProfileView},
|
||||
};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{DiaryEntry, UserStats, UserTrends, collections::Paginated},
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
pub struct FederatedProfileStats {
|
||||
pub stats: UserStats,
|
||||
pub entries: Option<Paginated<DiaryEntry>>,
|
||||
pub history: Option<Vec<DiaryEntry>>,
|
||||
pub trends: Option<UserTrends>,
|
||||
pub following_count: usize,
|
||||
pub followers_count: usize,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
deps: &GetFederatedProfileStatsDeps,
|
||||
query: GetUserProfileQuery,
|
||||
) -> Result<FederatedProfileStats, DomainError> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
let stats = deps.stats.get_user_stats(&user_id).await?;
|
||||
|
||||
let following_count = deps
|
||||
.social_query
|
||||
.count_following(&user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let followers_count = deps
|
||||
.social_query
|
||||
.count_followers(&user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
|
||||
let base = |entries, history, trends| FederatedProfileStats {
|
||||
stats,
|
||||
entries,
|
||||
history,
|
||||
trends,
|
||||
following_count,
|
||||
followers_count,
|
||||
};
|
||||
|
||||
match query.view {
|
||||
ProfileView::History => {
|
||||
let all_entries = deps.diary.get_user_history(&user_id).await?;
|
||||
Ok(base(None, Some(all_entries), None))
|
||||
}
|
||||
ProfileView::Trends => {
|
||||
let trends = deps.stats.get_user_trends(&user_id).await?;
|
||||
Ok(base(None, None, Some(trends)))
|
||||
}
|
||||
ProfileView::Ratings | ProfileView::Recent => {
|
||||
let sort_direction = feed_sort_to_direction(query.sort_by);
|
||||
let filter = paged_user_filter(
|
||||
user_id,
|
||||
sort_direction,
|
||||
query.limit,
|
||||
query.offset,
|
||||
query.search.clone(),
|
||||
query.include_remote,
|
||||
)?;
|
||||
let entries = deps.diary.query_diary(&filter).await?;
|
||||
Ok(base(Some(entries), None, None))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_federated_profile_stats.rs"]
|
||||
mod tests;
|
||||
154
crates/application/src/users/get_local_profile.rs
Normal file
154
crates/application/src/users/get_local_profile.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
//! The local half of the former `get_profile` (ADR-0004's closed wart). Unlike
|
||||
//! the old unified function, a user id with no local row is `Err(NotFound)` here
|
||||
//! — there is no federated fallback inside this function, and no empty-string
|
||||
//! sentinel for `identity`, because `LocalProfileData::identity` is always fully
|
||||
//! populated by construction.
|
||||
|
||||
use crate::users::{
|
||||
deps::GetLocalProfileDeps,
|
||||
diary_filter::{feed_sort_to_direction, paged_user_filter},
|
||||
queries::{GetUserProfileQuery, ProfileView},
|
||||
};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{DiaryEntry, UserStats, UserTrends, collections::Paginated},
|
||||
value_objects::{InstanceIdentity, UserId},
|
||||
};
|
||||
|
||||
pub struct PendingFollowerView {
|
||||
pub url: String,
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ProfileIdentity {
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub handle: String,
|
||||
pub actor_url: String,
|
||||
pub avatar_url: Option<String>,
|
||||
pub banner_url: Option<String>,
|
||||
/// Only the HTML profile handler reads this (it derives its "display name"
|
||||
/// from the email's local part, not from `username` — see
|
||||
/// `handlers/users.rs::get_user_profile_html`). Sourced from the same local
|
||||
/// `User` row as every other identity field.
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
pub struct LocalProfileData {
|
||||
pub stats: UserStats,
|
||||
pub entries: Option<Paginated<DiaryEntry>>,
|
||||
pub history: Option<Vec<DiaryEntry>>,
|
||||
pub trends: Option<UserTrends>,
|
||||
pub following_count: usize,
|
||||
pub followers_count: usize,
|
||||
pub pending_followers: Vec<PendingFollowerView>,
|
||||
pub identity: ProfileIdentity,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
deps: &GetLocalProfileDeps,
|
||||
query: GetUserProfileQuery,
|
||||
) -> Result<LocalProfileData, DomainError> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
let user = deps
|
||||
.user
|
||||
.find_by_id(&user_id)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("user {}", query.user_id)))?;
|
||||
|
||||
let stats = deps.stats.get_user_stats(&user_id).await?;
|
||||
|
||||
let (following_count, followers_count, pending_followers) =
|
||||
load_social_counts(deps, &user_id, query.is_own_profile, &deps.instance).await;
|
||||
|
||||
let identity = ProfileIdentity {
|
||||
username: user.username().value().to_string(),
|
||||
display_name: user.display_name().map(str::to_string),
|
||||
bio: user.bio().map(str::to_string),
|
||||
handle: deps.instance.handle_for(user.username().value()),
|
||||
actor_url: deps.instance.actor_url_for(&user_id),
|
||||
avatar_url: user.avatar_path().map(|p| deps.instance.image_url_for(p)),
|
||||
banner_url: user.banner_path().map(|p| deps.instance.image_url_for(p)),
|
||||
email: user.email().value().to_string(),
|
||||
};
|
||||
|
||||
let base = |entries, history, trends| LocalProfileData {
|
||||
stats,
|
||||
entries,
|
||||
history,
|
||||
trends,
|
||||
following_count,
|
||||
followers_count,
|
||||
pending_followers,
|
||||
identity,
|
||||
};
|
||||
|
||||
match query.view {
|
||||
ProfileView::History => {
|
||||
let all_entries = deps.diary.get_user_history(&user_id).await?;
|
||||
Ok(base(None, Some(all_entries), None))
|
||||
}
|
||||
ProfileView::Trends => {
|
||||
let trends = deps.stats.get_user_trends(&user_id).await?;
|
||||
Ok(base(None, None, Some(trends)))
|
||||
}
|
||||
ProfileView::Ratings | ProfileView::Recent => {
|
||||
let sort_direction = feed_sort_to_direction(query.sort_by);
|
||||
let filter = paged_user_filter(
|
||||
user_id,
|
||||
sort_direction,
|
||||
query.limit,
|
||||
query.offset,
|
||||
query.search.clone(),
|
||||
query.include_remote,
|
||||
)?;
|
||||
let entries = deps.diary.query_diary(&filter).await?;
|
||||
Ok(base(Some(entries), None, None))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_social_counts(
|
||||
deps: &GetLocalProfileDeps,
|
||||
user_id: &UserId,
|
||||
is_own_profile: bool,
|
||||
instance: &InstanceIdentity,
|
||||
) -> (usize, usize, Vec<PendingFollowerView>) {
|
||||
let following = deps
|
||||
.social_query
|
||||
.count_following(user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let followers = deps
|
||||
.social_query
|
||||
.count_followers(user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
if !is_own_profile {
|
||||
return (following, followers, vec![]);
|
||||
}
|
||||
let pending = deps
|
||||
.social_query
|
||||
.get_pending_followers(user_id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let url = instance.actor_url_of(&p.identity);
|
||||
PendingFollowerView {
|
||||
url,
|
||||
handle: p.handle,
|
||||
display_name: p.display_name,
|
||||
avatar_url: p.avatar_url,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
(following, followers, pending)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_local_profile.rs"]
|
||||
mod tests;
|
||||
44
crates/application/src/users/get_page_viewer.rs
Normal file
44
crates/application/src/users/get_page_viewer.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
//! Backs page-chrome data: the nav bar's email/admin state and pending-follow
|
||||
//! badge. Absorbs `handlers/helpers.rs::build_page_context`'s inline user lookup
|
||||
//! and pending-follower count.
|
||||
//!
|
||||
//! **Error policy (deliberately no internal tolerance):** this function
|
||||
//! propagates every error from `user` and `follow_graph` with `?`. It does not
|
||||
//! itself decide to degrade — that's a presentation-layer policy
|
||||
//! (`build_page_context` logs a warning and falls back to a zeroed `PageViewer`
|
||||
//! on `Err`). Page chrome degrading is a rendering decision, not a domain one.
|
||||
|
||||
use domain::{errors::DomainError, models::UserRole, value_objects::UserId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::users::deps::GetPageViewerDeps;
|
||||
|
||||
pub struct PageViewer {
|
||||
pub email: Option<String>,
|
||||
pub is_admin: bool,
|
||||
pub pending_follow_count: usize,
|
||||
}
|
||||
|
||||
pub async fn execute(deps: &GetPageViewerDeps, user_id: Uuid) -> Result<PageViewer, DomainError> {
|
||||
let uid = UserId::from_uuid(user_id);
|
||||
let user = deps
|
||||
.user
|
||||
.find_by_id(&uid)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("user {}", user_id)))?;
|
||||
|
||||
// Reuse `FollowGraphQuery::count_pending_followers` directly — the same port
|
||||
// method `social::count_pending_followers::execute` wraps — rather than
|
||||
// re-deriving the count from raw follow rows a second time.
|
||||
let pending_follow_count = deps.follow_graph.count_pending_followers(&uid).await?;
|
||||
|
||||
Ok(PageViewer {
|
||||
email: Some(user.email().value().to_string()),
|
||||
is_admin: matches!(user.role(), UserRole::Admin),
|
||||
pending_follow_count,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_page_viewer.rs"]
|
||||
mod tests;
|
||||
@@ -1,192 +0,0 @@
|
||||
use crate::users::{
|
||||
deps::GetProfileDeps,
|
||||
queries::{GetUserProfileQuery, ProfileView},
|
||||
};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::FeedSortBy,
|
||||
models::{
|
||||
DiaryEntry, DiaryFilter, ReviewSortBy, UserStats, UserTrends,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
pub struct PendingFollowerView {
|
||||
pub url: String,
|
||||
pub handle: String,
|
||||
pub display_name: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
pub struct UserProfileData {
|
||||
pub stats: UserStats,
|
||||
pub entries: Option<Paginated<DiaryEntry>>,
|
||||
pub history: Option<Vec<DiaryEntry>>,
|
||||
pub trends: Option<UserTrends>,
|
||||
pub following_count: usize,
|
||||
pub followers_count: usize,
|
||||
pub pending_followers: Vec<PendingFollowerView>,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
deps: &GetProfileDeps,
|
||||
query: GetUserProfileQuery,
|
||||
) -> Result<UserProfileData, DomainError> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
let stats = deps.stats.get_user_stats(&user_id).await?;
|
||||
|
||||
let (following_count, followers_count, pending_followers) =
|
||||
load_social_counts(deps, &user_id, query.is_own_profile).await;
|
||||
|
||||
let base = |entries, history, trends| UserProfileData {
|
||||
stats,
|
||||
entries,
|
||||
history,
|
||||
trends,
|
||||
following_count,
|
||||
followers_count,
|
||||
pending_followers,
|
||||
};
|
||||
|
||||
match query.view {
|
||||
ProfileView::History => {
|
||||
let all_entries = deps.diary.get_user_history(&user_id).await?;
|
||||
Ok(base(None, Some(all_entries), None))
|
||||
}
|
||||
ProfileView::Trends => {
|
||||
let trends = deps.stats.get_user_trends(&user_id).await?;
|
||||
Ok(base(None, None, Some(trends)))
|
||||
}
|
||||
ProfileView::Ratings | ProfileView::Recent => {
|
||||
let sort_direction = feed_sort_to_direction(query.sort_by);
|
||||
let filter = paged_user_filter(
|
||||
user_id,
|
||||
sort_direction,
|
||||
query.limit,
|
||||
query.offset,
|
||||
query.search.clone(),
|
||||
query.include_remote,
|
||||
)?;
|
||||
let entries = deps.diary.query_diary(&filter).await?;
|
||||
Ok(base(Some(entries), None, None))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_social_counts(
|
||||
deps: &GetProfileDeps,
|
||||
user_id: &UserId,
|
||||
is_own_profile: bool,
|
||||
) -> (usize, usize, Vec<PendingFollowerView>) {
|
||||
let following = deps
|
||||
.social_query
|
||||
.count_following(user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let followers = deps
|
||||
.social_query
|
||||
.count_followers(user_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
if !is_own_profile {
|
||||
return (following, followers, vec![]);
|
||||
}
|
||||
let pending = deps
|
||||
.social_query
|
||||
.get_pending_followers(user_id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let url = match &p.identity {
|
||||
domain::value_objects::SocialIdentity::Remote { actor_url } => actor_url.clone(),
|
||||
domain::value_objects::SocialIdentity::Local(uid) => {
|
||||
format!("local:{}", uid.value())
|
||||
}
|
||||
};
|
||||
PendingFollowerView {
|
||||
url,
|
||||
handle: p.handle,
|
||||
display_name: p.display_name,
|
||||
avatar_url: p.avatar_url,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
(following, followers, pending)
|
||||
}
|
||||
|
||||
fn feed_sort_to_direction(sort_by: FeedSortBy) -> ReviewSortBy {
|
||||
match sort_by {
|
||||
FeedSortBy::Date => ReviewSortBy::Descending,
|
||||
FeedSortBy::DateAsc => ReviewSortBy::Ascending,
|
||||
FeedSortBy::Rating => ReviewSortBy::ByRatingDesc,
|
||||
FeedSortBy::RatingAsc => ReviewSortBy::ByRatingAsc,
|
||||
}
|
||||
}
|
||||
|
||||
fn paged_user_filter(
|
||||
user_id: UserId,
|
||||
sort_by: ReviewSortBy,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
search: Option<String>,
|
||||
include_remote: bool,
|
||||
) -> Result<DiaryFilter, DomainError> {
|
||||
let page = PageParams::new(limit, offset)?;
|
||||
Ok(DiaryFilter {
|
||||
sort_by,
|
||||
page,
|
||||
movie_id: None,
|
||||
user_id: Some(user_id),
|
||||
search,
|
||||
include_remote,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_profile.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod helper_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn feed_sort_to_direction_all_variants() {
|
||||
use domain::models::FeedSortBy;
|
||||
assert!(matches!(
|
||||
feed_sort_to_direction(FeedSortBy::Date),
|
||||
ReviewSortBy::Descending
|
||||
));
|
||||
assert!(matches!(
|
||||
feed_sort_to_direction(FeedSortBy::DateAsc),
|
||||
ReviewSortBy::Ascending
|
||||
));
|
||||
assert!(matches!(
|
||||
feed_sort_to_direction(FeedSortBy::Rating),
|
||||
ReviewSortBy::ByRatingDesc
|
||||
));
|
||||
assert!(matches!(
|
||||
feed_sort_to_direction(FeedSortBy::RatingAsc),
|
||||
ReviewSortBy::ByRatingAsc
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paged_user_filter_builds_correctly() {
|
||||
let uid = UserId::from_uuid(uuid::Uuid::new_v4());
|
||||
let filter = paged_user_filter(
|
||||
uid.clone(),
|
||||
ReviewSortBy::Descending,
|
||||
Some(20),
|
||||
Some(5),
|
||||
Some("blade".into()),
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(filter.user_id.unwrap().value(), uid.value());
|
||||
assert_eq!(filter.search.as_deref(), Some("blade"));
|
||||
}
|
||||
}
|
||||
64
crates/application/src/users/get_profile_settings.rs
Normal file
64
crates/application/src/users/get_profile_settings.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
//! Absorbs BOTH of `handlers/users.rs::get_profile_settings`'s repository calls:
|
||||
//! the user lookup (`repos.user.find_by_id`) and the profile_fields join
|
||||
//! (`repos.profile_fields.get_fields`) — plus the avatar/banner URL derivation via
|
||||
//! `InstanceIdentity`, following the same pattern `get_local_profile` uses.
|
||||
//!
|
||||
//! Error policy: profile fields are primary content for this page (it's the page
|
||||
//! whose entire job is editing them), not page chrome, so a failed join
|
||||
//! propagates as `Err` rather than silently degrading to an empty list.
|
||||
|
||||
use domain::{errors::DomainError, value_objects::UserId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::users::deps::GetProfileSettingsDeps;
|
||||
|
||||
pub struct ProfileSettings {
|
||||
pub username: String,
|
||||
pub display_name: Option<String>,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
pub banner_url: Option<String>,
|
||||
/// Not in the brief's interface sketch, but the deleted handler code read
|
||||
/// `user.also_known_as()` and rendered it in `ProfileSettingsTemplate` — this
|
||||
/// use case absorbs that read too, or the settings page would silently stop
|
||||
/// showing it.
|
||||
pub also_known_as: Option<String>,
|
||||
pub fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
deps: &GetProfileSettingsDeps,
|
||||
user_id: Uuid,
|
||||
) -> Result<ProfileSettings, DomainError> {
|
||||
let uid = UserId::from_uuid(user_id);
|
||||
let user = deps
|
||||
.user
|
||||
.find_by_id(&uid)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("user {}", user_id)))?;
|
||||
|
||||
let avatar_url = user.avatar_path().map(|p| deps.instance.image_url_for(p));
|
||||
let banner_url = user.banner_path().map(|p| deps.instance.image_url_for(p));
|
||||
|
||||
let fields = deps
|
||||
.profile_fields
|
||||
.get_fields(&uid)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|f| (f.name, f.value))
|
||||
.collect();
|
||||
|
||||
Ok(ProfileSettings {
|
||||
username: user.username().value().to_string(),
|
||||
display_name: user.display_name().map(str::to_string),
|
||||
bio: user.bio().map(str::to_string),
|
||||
avatar_url,
|
||||
banner_url,
|
||||
also_known_as: user.also_known_as().map(str::to_string),
|
||||
fields,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_profile_settings.rs"]
|
||||
mod tests;
|
||||
@@ -1,15 +1,13 @@
|
||||
use std::sync::Arc;
|
||||
use domain::{errors::DomainError, models::UserSettings, value_objects::UserId};
|
||||
|
||||
use domain::{
|
||||
errors::DomainError, models::UserSettings, ports::UserSettingsRepository, value_objects::UserId,
|
||||
};
|
||||
use crate::users::deps::GetSettingsDeps;
|
||||
|
||||
pub async fn execute(
|
||||
user_settings: Arc<dyn UserSettingsRepository>,
|
||||
deps: &GetSettingsDeps,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<UserSettings, DomainError> {
|
||||
let uid = UserId::from_uuid(user_id);
|
||||
user_settings.get(&uid).await
|
||||
deps.user_settings.get(&uid).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
pub mod authorize_admin;
|
||||
pub mod commands;
|
||||
pub mod delete_account;
|
||||
pub mod deps;
|
||||
mod diary_filter;
|
||||
pub mod get_current_profile;
|
||||
pub mod get_profile;
|
||||
pub mod get_federated_profile;
|
||||
pub mod get_federated_profile_stats;
|
||||
pub mod get_local_profile;
|
||||
pub mod get_page_viewer;
|
||||
pub mod get_profile_settings;
|
||||
pub mod get_settings;
|
||||
pub mod get_users;
|
||||
pub mod queries;
|
||||
pub mod resolve_username_to_id;
|
||||
pub mod update_profile;
|
||||
pub mod update_profile_fields;
|
||||
pub mod update_settings;
|
||||
|
||||
23
crates/application/src/users/resolve_username_to_id.rs
Normal file
23
crates/application/src/users/resolve_username_to_id.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
//! Absorbs `handlers/users.rs::get_user_by_username`'s
|
||||
//! `repos.user.find_by_username()` call. `None` means "no such username" — the
|
||||
//! handler turns that into a 404, same as it always has; this is not an error
|
||||
//! condition.
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{UserId, Username},
|
||||
};
|
||||
|
||||
use crate::users::deps::ResolveUsernameDeps;
|
||||
|
||||
pub async fn execute(
|
||||
deps: &ResolveUsernameDeps,
|
||||
username: &Username,
|
||||
) -> Result<Option<UserId>, DomainError> {
|
||||
let user = deps.user.find_by_username(username).await?;
|
||||
Ok(user.map(|u| u.id().clone()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/resolve_username_to_id.rs"]
|
||||
mod tests;
|
||||
92
crates/application/src/users/tests/authorize_admin.rs
Normal file
92
crates/application/src/users/tests/authorize_admin.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::models::UserRole;
|
||||
use domain::testing::InMemoryUserRepository;
|
||||
|
||||
use crate::{
|
||||
auth::{commands::RegisterCommand, deps::RegisterDeps, register},
|
||||
test_helpers::TestContextBuilder,
|
||||
users::{authorize_admin, deps::AuthorizeAdminDeps},
|
||||
};
|
||||
|
||||
async fn register_user(
|
||||
b: &TestContextBuilder,
|
||||
email: &str,
|
||||
username: &str,
|
||||
role: UserRole,
|
||||
) -> domain::models::User {
|
||||
let reg_deps = RegisterDeps {
|
||||
user: b.user_repo.clone(),
|
||||
password_hasher: b.password_hasher.clone(),
|
||||
config: b.config.clone(),
|
||||
};
|
||||
register::execute(
|
||||
®_deps,
|
||||
RegisterCommand {
|
||||
email: email.into(),
|
||||
username: username.into(),
|
||||
password: "password123".into(),
|
||||
role,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
b.user_repo
|
||||
.find_by_email(&domain::value_objects::Email::new(email.into()).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorize_admin_is_true_for_an_admin_user() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
let user = register_user(&b, "admin@example.com", "admin_user", UserRole::Admin).await;
|
||||
|
||||
let deps = AuthorizeAdminDeps {
|
||||
user: b.user_repo.clone(),
|
||||
};
|
||||
let result = authorize_admin::execute(&deps, user.id().value())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, Some(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorize_admin_is_false_for_a_regular_user() {
|
||||
let users = InMemoryUserRepository::new();
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
let user = register_user(
|
||||
&b,
|
||||
"standard@example.com",
|
||||
"standard_user",
|
||||
UserRole::Standard,
|
||||
)
|
||||
.await;
|
||||
|
||||
let deps = AuthorizeAdminDeps {
|
||||
user: b.user_repo.clone(),
|
||||
};
|
||||
let result = authorize_admin::execute(&deps, user.id().value())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, Some(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authorize_admin_is_not_found_for_unknown_user() {
|
||||
let b = TestContextBuilder::new();
|
||||
|
||||
let deps = AuthorizeAdminDeps {
|
||||
user: b.user_repo.clone(),
|
||||
};
|
||||
let result = authorize_admin::execute(&deps, uuid::Uuid::new_v4())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use uuid::Uuid;
|
||||
use crate::{
|
||||
auth::{commands::RegisterCommand, deps::RegisterDeps, register},
|
||||
test_helpers::TestContextBuilder,
|
||||
users::{get_current_profile, queries::GetCurrentProfileQuery},
|
||||
users::{deps::GetCurrentProfileDeps, get_current_profile, queries::GetCurrentProfileQuery},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
@@ -41,8 +41,9 @@ async fn returns_profile_for_existing_user() {
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let deps = GetCurrentProfileDeps { user: user_repo };
|
||||
let profile = get_current_profile::execute(
|
||||
user_repo,
|
||||
&deps,
|
||||
GetCurrentProfileQuery {
|
||||
user_id: user.id().value(),
|
||||
},
|
||||
@@ -58,8 +59,9 @@ async fn fails_for_nonexistent_user() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
|
||||
let deps = GetCurrentProfileDeps { user: user_repo };
|
||||
let result = get_current_profile::execute(
|
||||
user_repo,
|
||||
&deps,
|
||||
GetCurrentProfileQuery {
|
||||
user_id: Uuid::new_v4(),
|
||||
},
|
||||
@@ -97,8 +99,9 @@ async fn returns_profile_with_avatar_banner_and_fields() {
|
||||
let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
|
||||
let user_repo = b.user_repo.clone();
|
||||
|
||||
let deps = GetCurrentProfileDeps { user: user_repo };
|
||||
let profile = get_current_profile::execute(
|
||||
user_repo,
|
||||
&deps,
|
||||
GetCurrentProfileQuery {
|
||||
user_id: uid.value(),
|
||||
},
|
||||
|
||||
115
crates/application/src/users/tests/get_federated_profile.rs
Normal file
115
crates/application/src/users/tests/get_federated_profile.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery};
|
||||
|
||||
use crate::users::deps::GetFederatedProfileDeps;
|
||||
use crate::users::get_federated_profile;
|
||||
|
||||
fn a_profile() -> FederatedProfile {
|
||||
FederatedProfile {
|
||||
actor_url: "https://remote.example/users/alice".into(),
|
||||
handle: "alice@remote.example".into(),
|
||||
display_name: Some("Alice".into()),
|
||||
bio: None,
|
||||
avatar_url: None,
|
||||
banner_url: None,
|
||||
}
|
||||
}
|
||||
|
||||
struct FoundFederatedProfileQuery;
|
||||
|
||||
#[async_trait]
|
||||
impl FederatedProfileQuery for FoundFederatedProfileQuery {
|
||||
async fn get_federated_profile(
|
||||
&self,
|
||||
_synthetic_user_id: uuid::Uuid,
|
||||
) -> Result<Option<FederatedProfile>, DomainError> {
|
||||
Ok(Some(a_profile()))
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyFederatedProfileQuery;
|
||||
|
||||
#[async_trait]
|
||||
impl FederatedProfileQuery for EmptyFederatedProfileQuery {
|
||||
async fn get_federated_profile(
|
||||
&self,
|
||||
_synthetic_user_id: uuid::Uuid,
|
||||
) -> Result<Option<FederatedProfile>, DomainError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
struct FailingFederatedProfileQuery;
|
||||
|
||||
#[async_trait]
|
||||
impl FederatedProfileQuery for FailingFederatedProfileQuery {
|
||||
async fn get_federated_profile(
|
||||
&self,
|
||||
_synthetic_user_id: uuid::Uuid,
|
||||
) -> Result<Option<FederatedProfile>, DomainError> {
|
||||
Err(DomainError::InfrastructureError(
|
||||
"remote lookup failed".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_the_profile_when_the_port_finds_one() {
|
||||
let deps = GetFederatedProfileDeps {
|
||||
federated_profile: Some(Arc::new(FoundFederatedProfileQuery)),
|
||||
};
|
||||
|
||||
let result = get_federated_profile::execute(&deps, uuid::Uuid::new_v4())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result.map(|p| p.handle),
|
||||
Some("alice@remote.example".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_ok_none_when_the_port_finds_nothing() {
|
||||
let deps = GetFederatedProfileDeps {
|
||||
federated_profile: Some(Arc::new(EmptyFederatedProfileQuery)),
|
||||
};
|
||||
|
||||
let result = get_federated_profile::execute(&deps, uuid::Uuid::new_v4())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_ok_none_when_federation_is_disabled() {
|
||||
let deps = GetFederatedProfileDeps {
|
||||
federated_profile: None,
|
||||
};
|
||||
|
||||
let result = get_federated_profile::execute(&deps, uuid::Uuid::new_v4())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"an absent port must collapse to Ok(None), the same as an empty lookup — \
|
||||
the handler cannot and must not tell the two apart"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn propagates_an_error_from_the_port_unchanged() {
|
||||
let deps = GetFederatedProfileDeps {
|
||||
federated_profile: Some(Arc::new(FailingFederatedProfileQuery)),
|
||||
};
|
||||
|
||||
let err = get_federated_profile::execute(&deps, uuid::Uuid::new_v4())
|
||||
.await
|
||||
.expect_err("a repository error must not be swallowed inside the use case");
|
||||
|
||||
assert!(matches!(err, DomainError::InfrastructureError(_)));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
use crate::users::deps::GetFederatedProfileStatsDeps;
|
||||
use crate::users::get_federated_profile_stats;
|
||||
use crate::users::queries::{GetUserProfileQuery, ProfileView};
|
||||
|
||||
/// Mirrors exactly how `build_federated_profile_response`
|
||||
/// (`crates/presentation/src/handlers/users.rs`) calls `execute`: a `user_id` that
|
||||
/// has no row in the local `users` table (a federated/remote profile uses a
|
||||
/// synthetic id — see `FederatedProfileQuery`), `is_own_profile: false`,
|
||||
/// `include_remote: true`. Before ADR-0004's split this was the tolerant branch of
|
||||
/// the unified `get_profile`; now it's simply this function's only behavior — it
|
||||
/// never looks at the local `users` table at all, so there is nothing to be
|
||||
/// tolerant about.
|
||||
#[tokio::test]
|
||||
async fn succeeds_for_a_user_id_with_no_local_row() {
|
||||
let b = TestContextBuilder::new();
|
||||
let deps = GetFederatedProfileStatsDeps {
|
||||
stats: b.stats_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
social_query: b.social_query_unified.clone(),
|
||||
};
|
||||
|
||||
let synthetic_user_id = uuid::Uuid::new_v4();
|
||||
|
||||
let result = get_federated_profile_stats::execute(
|
||||
&deps,
|
||||
GetUserProfileQuery {
|
||||
user_id: synthetic_user_id,
|
||||
view: ProfileView::Recent,
|
||||
limit: None,
|
||||
offset: None,
|
||||
sort_by: domain::models::FeedSortBy::Date,
|
||||
search: None,
|
||||
is_own_profile: false,
|
||||
include_remote: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"execute must not hard-fail for a user_id absent from the local `users` table \
|
||||
(the federated-profile call shape) — got Err"
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use domain::errors::DomainError;
|
||||
use domain::models::UserRole;
|
||||
use domain::value_objects::Email;
|
||||
|
||||
@@ -5,9 +6,11 @@ use crate::auth::commands::RegisterCommand;
|
||||
use crate::auth::deps::RegisterDeps;
|
||||
use crate::auth::register;
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
use crate::users::deps::GetProfileDeps;
|
||||
use crate::users::get_profile;
|
||||
use crate::users::commands::UpdateProfileCommand;
|
||||
use crate::users::deps::{GetLocalProfileDeps, UpdateProfileDeps};
|
||||
use crate::users::get_local_profile;
|
||||
use crate::users::queries::{GetUserProfileQuery, ProfileView};
|
||||
use crate::users::update_profile;
|
||||
|
||||
async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) {
|
||||
let deps = RegisterDeps {
|
||||
@@ -28,15 +31,51 @@ async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn deps(b: &TestContextBuilder) -> GetLocalProfileDeps {
|
||||
GetLocalProfileDeps {
|
||||
stats: b.stats_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
social_query: b.social_query_unified.clone(),
|
||||
user: b.user_repo.clone(),
|
||||
instance: domain::value_objects::InstanceIdentity::new(b.config.base_url.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn unknown_query(user_id: uuid::Uuid) -> GetUserProfileQuery {
|
||||
GetUserProfileQuery {
|
||||
user_id,
|
||||
view: ProfileView::Recent,
|
||||
limit: None,
|
||||
offset: None,
|
||||
sort_by: domain::models::FeedSortBy::Date,
|
||||
search: None,
|
||||
is_own_profile: false,
|
||||
include_remote: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `get_local_profile` is the local-only half of the former `get_profile` split
|
||||
/// (ADR-0004's closed wart). Unlike the old tolerant behavior, a user id with no
|
||||
/// local row must be `NotFound` here — the empty-string identity sentinel this
|
||||
/// replaces cannot be represented in `LocalProfileData` at all.
|
||||
#[tokio::test]
|
||||
async fn get_local_profile_is_not_found_for_unknown_user() {
|
||||
let b = TestContextBuilder::new();
|
||||
let d = deps(&b);
|
||||
|
||||
let unknown_id = uuid::Uuid::new_v4();
|
||||
let err = match get_local_profile::execute(&d, unknown_query(unknown_id)).await {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("expected Err(NotFound) for an unknown user id, got Ok"),
|
||||
};
|
||||
assert!(matches!(err, DomainError::NotFound(_)), "got {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_profile_with_empty_stats() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
let deps = GetProfileDeps {
|
||||
stats: b.stats_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
social_query: b.social_query_unified.clone(),
|
||||
};
|
||||
let d = deps(&b);
|
||||
|
||||
setup_user(&b, "profile@test.com", "profuser").await;
|
||||
|
||||
@@ -44,8 +83,8 @@ async fn returns_profile_with_empty_stats() {
|
||||
let user = user_repo.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let result = get_profile::execute(
|
||||
&deps,
|
||||
let result = get_local_profile::execute(
|
||||
&d,
|
||||
GetUserProfileQuery {
|
||||
user_id: uid,
|
||||
view: ProfileView::Recent,
|
||||
@@ -67,11 +106,7 @@ async fn returns_profile_with_empty_stats() {
|
||||
async fn returns_history_view() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
let deps = GetProfileDeps {
|
||||
stats: b.stats_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
social_query: b.social_query_unified.clone(),
|
||||
};
|
||||
let d = deps(&b);
|
||||
|
||||
setup_user(&b, "hist@test.com", "histuser").await;
|
||||
|
||||
@@ -79,8 +114,8 @@ async fn returns_history_view() {
|
||||
let user = user_repo.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let result = get_profile::execute(
|
||||
&deps,
|
||||
let result = get_local_profile::execute(
|
||||
&d,
|
||||
GetUserProfileQuery {
|
||||
user_id: uid,
|
||||
view: ProfileView::History,
|
||||
@@ -104,11 +139,7 @@ async fn returns_history_view() {
|
||||
async fn returns_trends_view() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
let deps = GetProfileDeps {
|
||||
stats: b.stats_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
social_query: b.social_query_unified.clone(),
|
||||
};
|
||||
let d = deps(&b);
|
||||
|
||||
setup_user(&b, "trends@test.com", "trendsuser").await;
|
||||
|
||||
@@ -116,8 +147,8 @@ async fn returns_trends_view() {
|
||||
let user = user_repo.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let result = get_profile::execute(
|
||||
&deps,
|
||||
let result = get_local_profile::execute(
|
||||
&d,
|
||||
GetUserProfileQuery {
|
||||
user_id: uid,
|
||||
view: ProfileView::Trends,
|
||||
@@ -141,11 +172,7 @@ async fn returns_trends_view() {
|
||||
async fn returns_ratings_view() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
let deps = GetProfileDeps {
|
||||
stats: b.stats_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
social_query: b.social_query_unified.clone(),
|
||||
};
|
||||
let d = deps(&b);
|
||||
|
||||
setup_user(&b, "ratings@test.com", "ratingsuser").await;
|
||||
|
||||
@@ -153,8 +180,8 @@ async fn returns_ratings_view() {
|
||||
let user = user_repo.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let result = get_profile::execute(
|
||||
&deps,
|
||||
let result = get_local_profile::execute(
|
||||
&d,
|
||||
GetUserProfileQuery {
|
||||
user_id: uid,
|
||||
view: ProfileView::Ratings,
|
||||
@@ -176,11 +203,7 @@ async fn returns_ratings_view() {
|
||||
async fn returns_recent_with_search() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
let deps = GetProfileDeps {
|
||||
stats: b.stats_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
social_query: b.social_query_unified.clone(),
|
||||
};
|
||||
let d = deps(&b);
|
||||
|
||||
setup_user(&b, "search@test.com", "searchuser").await;
|
||||
|
||||
@@ -188,8 +211,8 @@ async fn returns_recent_with_search() {
|
||||
let user = user_repo.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let result = get_profile::execute(
|
||||
&deps,
|
||||
let result = get_local_profile::execute(
|
||||
&d,
|
||||
GetUserProfileQuery {
|
||||
user_id: uid,
|
||||
view: ProfileView::Recent,
|
||||
@@ -211,11 +234,7 @@ async fn returns_recent_with_search() {
|
||||
async fn non_own_profile_skips_pending_followers() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
let deps = GetProfileDeps {
|
||||
stats: b.stats_repo.clone(),
|
||||
diary: b.diary_repo.clone(),
|
||||
social_query: b.social_query_unified.clone(),
|
||||
};
|
||||
let d = deps(&b);
|
||||
|
||||
setup_user(&b, "other@test.com", "otheruser").await;
|
||||
|
||||
@@ -223,8 +242,8 @@ async fn non_own_profile_skips_pending_followers() {
|
||||
let user = user_repo.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let result = get_profile::execute(
|
||||
&deps,
|
||||
let result = get_local_profile::execute(
|
||||
&d,
|
||||
GetUserProfileQuery {
|
||||
user_id: uid,
|
||||
view: ProfileView::Recent,
|
||||
@@ -241,3 +260,96 @@ async fn non_own_profile_skips_pending_followers() {
|
||||
|
||||
assert!(result.pending_followers.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn populates_handle_and_actor_url_for_a_local_user() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
let d = deps(&b);
|
||||
|
||||
setup_user(&b, "handle@test.com", "gabriel").await;
|
||||
let email = Email::new("handle@test.com".into()).unwrap();
|
||||
let user = user_repo.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
// `InMemoryUserRepository::update_profile` now actually persists (previously a
|
||||
// no-op stub) — use it to give this test a real display_name/bio to assert,
|
||||
// rather than asserting `None` for a mapping that could just as easily be
|
||||
// wrong, inverted, or hardcoded.
|
||||
let update_deps = UpdateProfileDeps {
|
||||
user: user_repo.clone(),
|
||||
object_storage: b.object_storage.clone(),
|
||||
event_publisher: b.event_publisher.clone(),
|
||||
};
|
||||
update_profile::execute(
|
||||
&update_deps,
|
||||
UpdateProfileCommand {
|
||||
user_id: uid,
|
||||
display_name: Some("Gabriel K".into()),
|
||||
bio: Some("Movies and diaries.".into()),
|
||||
avatar_bytes: None,
|
||||
avatar_content_type: None,
|
||||
banner_bytes: None,
|
||||
banner_content_type: None,
|
||||
also_known_as: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result = get_local_profile::execute(
|
||||
&d,
|
||||
GetUserProfileQuery {
|
||||
user_id: uid,
|
||||
view: ProfileView::Recent,
|
||||
limit: None,
|
||||
offset: None,
|
||||
sort_by: domain::models::FeedSortBy::Date,
|
||||
search: None,
|
||||
is_own_profile: true,
|
||||
include_remote: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.identity.username, "gabriel");
|
||||
assert_eq!(result.identity.handle, "@gabriel@localhost:3000");
|
||||
assert_eq!(
|
||||
result.identity.actor_url,
|
||||
format!("http://localhost:3000/users/{}", uid)
|
||||
);
|
||||
assert_eq!(result.identity.display_name, Some("Gabriel K".to_string()));
|
||||
assert_eq!(result.identity.bio, Some("Movies and diaries.".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn identity_display_name_and_bio_are_none_when_unset() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_repo = b.user_repo.clone();
|
||||
let d = deps(&b);
|
||||
|
||||
setup_user(&b, "unset@test.com", "unsetuser").await;
|
||||
let email = Email::new("unset@test.com".into()).unwrap();
|
||||
let user = user_repo.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let result = get_local_profile::execute(
|
||||
&d,
|
||||
GetUserProfileQuery {
|
||||
user_id: uid,
|
||||
view: ProfileView::Recent,
|
||||
limit: None,
|
||||
offset: None,
|
||||
sort_by: domain::models::FeedSortBy::Date,
|
||||
search: None,
|
||||
is_own_profile: true,
|
||||
include_remote: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.identity.display_name, None);
|
||||
assert_eq!(result.identity.bio, None);
|
||||
}
|
||||
66
crates/application/src/users/tests/get_page_viewer.rs
Normal file
66
crates/application/src/users/tests/get_page_viewer.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use domain::models::UserRole;
|
||||
use domain::value_objects::{Email, FollowTarget, SocialIdentity, UserId};
|
||||
|
||||
use crate::auth::commands::RegisterCommand;
|
||||
use crate::auth::deps::RegisterDeps;
|
||||
use crate::auth::register;
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
use crate::users::deps::GetPageViewerDeps;
|
||||
use crate::users::get_page_viewer;
|
||||
|
||||
async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) -> uuid::Uuid {
|
||||
let deps = RegisterDeps {
|
||||
user: b.user_repo.clone(),
|
||||
password_hasher: b.password_hasher.clone(),
|
||||
config: b.config.clone(),
|
||||
};
|
||||
register::execute(
|
||||
&deps,
|
||||
RegisterCommand {
|
||||
email: email.into(),
|
||||
username: username.into(),
|
||||
password: "password123".into(),
|
||||
role: UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let user = b
|
||||
.user_repo
|
||||
.find_by_email(&Email::new(email.into()).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
user.id().value()
|
||||
}
|
||||
|
||||
/// `get_page_viewer` absorbs `helpers.rs::build_page_context`'s inline pending-count
|
||||
/// computation. It must reuse `FollowGraphQuery::count_pending_followers` rather than
|
||||
/// re-deriving the count from raw follow rows.
|
||||
#[tokio::test]
|
||||
async fn get_page_viewer_reports_pending_follower_count() {
|
||||
let b = TestContextBuilder::new();
|
||||
let owner_uuid = setup_user(&b, "owner@test.com", "pageowner").await;
|
||||
let follower1 = setup_user(&b, "follower1@test.com", "pagefollower1").await;
|
||||
let follower2 = setup_user(&b, "follower2@test.com", "pagefollower2").await;
|
||||
|
||||
let owner_id = UserId::from_uuid(owner_uuid);
|
||||
let target = FollowTarget::Identity(SocialIdentity::Local(owner_id.clone()));
|
||||
b.social_command
|
||||
.follow(&UserId::from_uuid(follower1), &target)
|
||||
.await
|
||||
.unwrap();
|
||||
b.social_command
|
||||
.follow(&UserId::from_uuid(follower2), &target)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let deps = GetPageViewerDeps {
|
||||
user: b.user_repo.clone(),
|
||||
follow_graph: b.social_query_unified.clone(),
|
||||
};
|
||||
|
||||
let v = get_page_viewer::execute(&deps, owner_uuid).await.unwrap();
|
||||
assert_eq!(v.pending_follow_count, 2);
|
||||
assert!(!v.is_admin);
|
||||
}
|
||||
64
crates/application/src/users/tests/get_profile_settings.rs
Normal file
64
crates/application/src/users/tests/get_profile_settings.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use domain::models::{ProfileField, UserRole};
|
||||
use domain::value_objects::{Email, UserId};
|
||||
|
||||
use crate::auth::commands::RegisterCommand;
|
||||
use crate::auth::deps::RegisterDeps;
|
||||
use crate::auth::register;
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
use crate::users::deps::GetProfileSettingsDeps;
|
||||
use crate::users::get_profile_settings;
|
||||
|
||||
/// `get_profile_settings` absorbs BOTH of the handler's calls: the user lookup
|
||||
/// (`repos.user.find_by_id`) and the profile_fields join
|
||||
/// (`repos.profile_fields.get_fields`). This proves the join actually moved in,
|
||||
/// not just the user lookup.
|
||||
#[tokio::test]
|
||||
async fn get_profile_settings_includes_profile_fields() {
|
||||
let b = TestContextBuilder::new();
|
||||
let register_deps = RegisterDeps {
|
||||
user: b.user_repo.clone(),
|
||||
password_hasher: b.password_hasher.clone(),
|
||||
config: b.config.clone(),
|
||||
};
|
||||
register::execute(
|
||||
®ister_deps,
|
||||
RegisterCommand {
|
||||
email: "settings@test.com".into(),
|
||||
username: "settingsuser".into(),
|
||||
password: "password123".into(),
|
||||
role: UserRole::Standard,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let user = b
|
||||
.user_repo
|
||||
.find_by_email(&Email::new("settings@test.com".into()).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
b.profile_fields_repo
|
||||
.set_fields(
|
||||
&UserId::from_uuid(uid),
|
||||
vec![ProfileField {
|
||||
name: "pronouns".into(),
|
||||
value: "they/them".into(),
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let deps = GetProfileSettingsDeps {
|
||||
user: b.user_repo.clone(),
|
||||
profile_fields: b.profile_fields_repo.clone(),
|
||||
instance: domain::value_objects::InstanceIdentity::new(b.config.base_url.clone()),
|
||||
};
|
||||
|
||||
let s = get_profile_settings::execute(&deps, uid).await.unwrap();
|
||||
assert_eq!(
|
||||
s.fields,
|
||||
vec![("pronouns".to_string(), "they/them".to_string())]
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{test_helpers::TestContextBuilder, users::get_settings};
|
||||
use crate::{
|
||||
test_helpers::TestContextBuilder,
|
||||
users::{deps::GetSettingsDeps, get_settings},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_default_settings() {
|
||||
let b = TestContextBuilder::new();
|
||||
let user_settings = b.user_settings_repo.clone();
|
||||
let deps = GetSettingsDeps {
|
||||
user_settings: b.user_settings_repo.clone(),
|
||||
};
|
||||
|
||||
let settings = get_settings::execute(user_settings, Uuid::nil())
|
||||
.await
|
||||
.unwrap();
|
||||
let settings = get_settings::execute(&deps, Uuid::nil()).await.unwrap();
|
||||
|
||||
assert!(settings.federate_goals());
|
||||
assert!(settings.federate_reviews());
|
||||
|
||||
23
crates/application/src/users/tests/resolve_username_to_id.rs
Normal file
23
crates/application/src/users/tests/resolve_username_to_id.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use domain::value_objects::Username;
|
||||
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
use crate::users::deps::ResolveUsernameDeps;
|
||||
use crate::users::resolve_username_to_id;
|
||||
|
||||
/// `resolve_username_to_id` absorbs `handlers/users.rs::get_user_by_username`'s
|
||||
/// `repos.user.find_by_username()` call. Unknown usernames must resolve to `None`,
|
||||
/// not an error — the handler turns `None` into a 404, same as today.
|
||||
#[tokio::test]
|
||||
async fn resolve_username_to_id_returns_none_for_unknown() {
|
||||
let b = TestContextBuilder::new();
|
||||
let deps = ResolveUsernameDeps {
|
||||
user: b.user_repo.clone(),
|
||||
};
|
||||
let unknown = Username::new("nosuchuser".into()).unwrap();
|
||||
assert!(
|
||||
resolve_username_to_id::execute(&deps, &unknown)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,9 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
test_helpers::TestContextBuilder,
|
||||
users::{commands::UpdateProfileFieldsCommand, update_profile_fields},
|
||||
users::{
|
||||
commands::UpdateProfileFieldsCommand, deps::UpdateProfileFieldsDeps, update_profile_fields,
|
||||
},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
@@ -17,12 +19,13 @@ async fn saves_profile_fields() {
|
||||
let b = TestContextBuilder::new()
|
||||
.with_profile_fields(Arc::clone(&fields_repo) as _)
|
||||
.with_event_publisher(Arc::clone(&events) as _);
|
||||
let profile_fields = b.profile_fields_repo.clone();
|
||||
let event_publisher = b.event_publisher.clone();
|
||||
let deps = UpdateProfileFieldsDeps {
|
||||
profile_fields: b.profile_fields_repo.clone(),
|
||||
event_publisher: b.event_publisher.clone(),
|
||||
};
|
||||
|
||||
update_profile_fields::execute(
|
||||
profile_fields,
|
||||
event_publisher,
|
||||
&deps,
|
||||
UpdateProfileFieldsCommand {
|
||||
user_id: Uuid::nil(),
|
||||
fields: vec![
|
||||
@@ -51,8 +54,10 @@ async fn saves_profile_fields() {
|
||||
#[tokio::test]
|
||||
async fn rejects_more_than_four_fields() {
|
||||
let b = TestContextBuilder::new();
|
||||
let profile_fields = b.profile_fields_repo.clone();
|
||||
let event_publisher = b.event_publisher.clone();
|
||||
let deps = UpdateProfileFieldsDeps {
|
||||
profile_fields: b.profile_fields_repo.clone(),
|
||||
event_publisher: b.event_publisher.clone(),
|
||||
};
|
||||
|
||||
let fields: Vec<ProfileField> = (0..5)
|
||||
.map(|i| ProfileField {
|
||||
@@ -62,8 +67,7 @@ async fn rejects_more_than_four_fields() {
|
||||
.collect();
|
||||
|
||||
let result = update_profile_fields::execute(
|
||||
profile_fields,
|
||||
event_publisher,
|
||||
&deps,
|
||||
UpdateProfileFieldsCommand {
|
||||
user_id: Uuid::nil(),
|
||||
fields,
|
||||
|
||||
@@ -5,7 +5,11 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
test_helpers::TestContextBuilder,
|
||||
users::{get_settings, update_settings::UpdateUserSettingsCommand},
|
||||
users::{
|
||||
deps::{GetSettingsDeps, UpdateSettingsDeps},
|
||||
get_settings,
|
||||
update_settings::UpdateUserSettingsCommand,
|
||||
},
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
@@ -16,7 +20,9 @@ async fn updates_federate_goals() {
|
||||
let uid = Uuid::nil();
|
||||
|
||||
crate::users::update_settings::execute(
|
||||
user_settings.clone(),
|
||||
&UpdateSettingsDeps {
|
||||
user_settings: user_settings.clone(),
|
||||
},
|
||||
UpdateUserSettingsCommand {
|
||||
user_id: uid,
|
||||
federate_goals: false,
|
||||
@@ -27,7 +33,9 @@ async fn updates_federate_goals() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let settings = get_settings::execute(user_settings, uid).await.unwrap();
|
||||
let settings = get_settings::execute(&GetSettingsDeps { user_settings }, uid)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!settings.federate_goals());
|
||||
assert!(settings.federate_reviews());
|
||||
assert!(settings.federate_watchlist());
|
||||
@@ -41,7 +49,9 @@ async fn updates_federate_reviews() {
|
||||
let uid = Uuid::nil();
|
||||
|
||||
crate::users::update_settings::execute(
|
||||
user_settings.clone(),
|
||||
&UpdateSettingsDeps {
|
||||
user_settings: user_settings.clone(),
|
||||
},
|
||||
UpdateUserSettingsCommand {
|
||||
user_id: uid,
|
||||
federate_goals: true,
|
||||
@@ -52,7 +62,9 @@ async fn updates_federate_reviews() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let settings = get_settings::execute(user_settings, uid).await.unwrap();
|
||||
let settings = get_settings::execute(&GetSettingsDeps { user_settings }, uid)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(settings.federate_goals());
|
||||
assert!(!settings.federate_reviews());
|
||||
assert!(settings.federate_watchlist());
|
||||
@@ -66,7 +78,9 @@ async fn updates_federate_watchlist() {
|
||||
let uid = Uuid::nil();
|
||||
|
||||
crate::users::update_settings::execute(
|
||||
user_settings.clone(),
|
||||
&UpdateSettingsDeps {
|
||||
user_settings: user_settings.clone(),
|
||||
},
|
||||
UpdateUserSettingsCommand {
|
||||
user_id: uid,
|
||||
federate_goals: true,
|
||||
@@ -77,7 +91,9 @@ async fn updates_federate_watchlist() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let settings = get_settings::execute(user_settings, uid).await.unwrap();
|
||||
let settings = get_settings::execute(&GetSettingsDeps { user_settings }, uid)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(settings.federate_goals());
|
||||
assert!(settings.federate_reviews());
|
||||
assert!(!settings.federate_watchlist());
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::UserProfile,
|
||||
ports::{EventPublisher, UserProfileFieldsRepository},
|
||||
value_objects::UserId,
|
||||
errors::DomainError, events::DomainEvent, models::UserProfile, value_objects::UserId,
|
||||
};
|
||||
|
||||
use crate::users::commands::UpdateProfileFieldsCommand;
|
||||
use crate::users::deps::UpdateProfileFieldsDeps;
|
||||
|
||||
pub async fn execute(
|
||||
profile_fields: Arc<dyn UserProfileFieldsRepository>,
|
||||
event_publisher: Arc<dyn EventPublisher>,
|
||||
deps: &UpdateProfileFieldsDeps,
|
||||
cmd: UpdateProfileFieldsCommand,
|
||||
) -> Result<(), DomainError> {
|
||||
UserProfile::validate_custom_fields(&cmd.fields)?;
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
profile_fields.set_fields(&user_id, cmd.fields).await?;
|
||||
event_publisher
|
||||
deps.profile_fields.set_fields(&user_id, cmd.fields).await?;
|
||||
deps.event_publisher
|
||||
.publish(&DomainEvent::UserUpdated { user_id })
|
||||
.await?;
|
||||
Ok(())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::sync::Arc;
|
||||
use domain::{errors::DomainError, value_objects::UserId};
|
||||
|
||||
use domain::{errors::DomainError, ports::UserSettingsRepository, value_objects::UserId};
|
||||
use crate::users::deps::UpdateSettingsDeps;
|
||||
|
||||
pub struct UpdateUserSettingsCommand {
|
||||
pub user_id: uuid::Uuid,
|
||||
@@ -10,15 +10,15 @@ pub struct UpdateUserSettingsCommand {
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
user_settings: Arc<dyn UserSettingsRepository>,
|
||||
deps: &UpdateSettingsDeps,
|
||||
cmd: UpdateUserSettingsCommand,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = UserId::from_uuid(cmd.user_id);
|
||||
let mut settings = user_settings.get(&uid).await?;
|
||||
let mut settings = deps.user_settings.get(&uid).await?;
|
||||
settings.set_federate_goals(cmd.federate_goals);
|
||||
settings.set_federate_reviews(cmd.federate_reviews);
|
||||
settings.set_federate_watchlist(cmd.federate_watchlist);
|
||||
user_settings.save(&settings).await
|
||||
deps.user_settings.save(&settings).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
EventPublisher, MetadataClient, MovieCommand, MovieQuery, WatchlistRepository,
|
||||
EventPublisher, MetadataClient, MovieCommand, MovieQuery, RemoteWatchlistRepository,
|
||||
UserRepository, WatchlistRepository,
|
||||
};
|
||||
|
||||
pub struct WatchlistAddDeps {
|
||||
@@ -11,3 +12,22 @@ pub struct WatchlistAddDeps {
|
||||
pub watchlist: Arc<dyn WatchlistRepository>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
pub struct GetWatchlistForOwnerDeps {
|
||||
pub user: Arc<dyn UserRepository>,
|
||||
pub watchlist: Arc<dyn WatchlistRepository>,
|
||||
pub remote_watchlist: Arc<dyn RemoteWatchlistRepository>,
|
||||
}
|
||||
|
||||
pub struct GetWatchlistDeps {
|
||||
pub watchlist: Arc<dyn WatchlistRepository>,
|
||||
}
|
||||
|
||||
pub struct IsOnWatchlistDeps {
|
||||
pub watchlist: Arc<dyn WatchlistRepository>,
|
||||
}
|
||||
|
||||
pub struct RemoveFromWatchlistDeps {
|
||||
pub watchlist: Arc<dyn WatchlistRepository>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
WatchlistWithMovie,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::WatchlistRepository,
|
||||
value_objects::UserId,
|
||||
};
|
||||
|
||||
use crate::watchlist::deps::GetWatchlistDeps;
|
||||
use crate::watchlist::queries::GetWatchlistQuery;
|
||||
|
||||
pub async fn execute(
|
||||
watchlist: Arc<dyn WatchlistRepository>,
|
||||
deps: &GetWatchlistDeps,
|
||||
query: GetWatchlistQuery,
|
||||
) -> Result<Paginated<WatchlistWithMovie>, DomainError> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
let page = PageParams::new(query.limit, query.offset)?;
|
||||
watchlist.get_for_user(&user_id, &page).await
|
||||
deps.watchlist.get_for_user(&user_id, &page).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
74
crates/application/src/watchlist/get_watchlist_for_owner.rs
Normal file
74
crates/application/src/watchlist/get_watchlist_for_owner.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{RemoteWatchlistEntry, WatchlistWithMovie, collections::Paginated},
|
||||
value_objects::UserId,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::watchlist::deps::{GetWatchlistDeps, GetWatchlistForOwnerDeps};
|
||||
use crate::watchlist::get;
|
||||
use crate::watchlist::queries::GetWatchlistQuery;
|
||||
|
||||
/// Which data source answered a watchlist page request. The former handler
|
||||
/// decided this by probing for a local user row — that decision now lives here,
|
||||
/// not in `handlers/watchlist.rs`.
|
||||
pub enum WatchlistView {
|
||||
Local(Paginated<WatchlistWithMovie>),
|
||||
Remote(Vec<RemoteWatchlistEntry>),
|
||||
}
|
||||
|
||||
/// `limit`/`offset` are taken raw, not as a pre-built `PageParams`, on purpose:
|
||||
/// they're only validated on the local branch, by delegating to
|
||||
/// `watchlist::get::execute` (whose `PageParams::new` call is where the validation
|
||||
/// actually happens). The remote branch ignores them entirely, unconditionally
|
||||
/// fetching the full federated watchlist. The delegation call below must stay
|
||||
/// inside the `if is_local` branch — calling it before the local/remote decision
|
||||
/// would turn an out-of-range `limit` into a 400 on a remote owner's page, which
|
||||
/// never happened before and must not start happening now.
|
||||
pub async fn execute(
|
||||
deps: &GetWatchlistForOwnerDeps,
|
||||
owner_id: Uuid,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
) -> Result<WatchlistView, DomainError> {
|
||||
let user_id = UserId::from_uuid(owner_id);
|
||||
|
||||
// Matches the deleted handler's `.map(|u| u.is_some()).unwrap_or(false)`
|
||||
// exactly: a lookup error is treated the same as "no local row", falling
|
||||
// through to the remote arm rather than propagating.
|
||||
let is_local = deps
|
||||
.user
|
||||
.find_by_id(&user_id)
|
||||
.await
|
||||
.map(|u| u.is_some())
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_local {
|
||||
let get_deps = GetWatchlistDeps {
|
||||
watchlist: deps.watchlist.clone(),
|
||||
};
|
||||
let paginated = get::execute(
|
||||
&get_deps,
|
||||
GetWatchlistQuery {
|
||||
user_id: owner_id,
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(WatchlistView::Local(paginated))
|
||||
} else {
|
||||
// Matches the deleted handler's `.unwrap_or_default()`: a federation
|
||||
// lookup error yields an empty list, not a propagated error.
|
||||
let entries = deps
|
||||
.remote_watchlist
|
||||
.get_by_derived_uuid(owner_id)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
Ok(WatchlistView::Remote(entries))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_watchlist_for_owner.rs"]
|
||||
mod tests;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user