structural refactor and codebase improvements
This commit is contained in:
@@ -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;
|
||||
@@ -1,20 +1,18 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::WatchlistRepository,
|
||||
value_objects::{MovieId, UserId},
|
||||
};
|
||||
|
||||
use crate::watchlist::deps::IsOnWatchlistDeps;
|
||||
use crate::watchlist::queries::IsOnWatchlistQuery;
|
||||
|
||||
pub async fn execute(
|
||||
watchlist: Arc<dyn WatchlistRepository>,
|
||||
deps: &IsOnWatchlistDeps,
|
||||
query: IsOnWatchlistQuery,
|
||||
) -> Result<bool, DomainError> {
|
||||
let user_id = UserId::from_uuid(query.user_id);
|
||||
let movie_id = MovieId::from_uuid(query.movie_id);
|
||||
watchlist.contains(&user_id, &movie_id).await
|
||||
deps.watchlist.contains(&user_id, &movie_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod add;
|
||||
pub mod commands;
|
||||
pub mod deps;
|
||||
pub mod get;
|
||||
pub mod get_watchlist_for_owner;
|
||||
pub mod is_on;
|
||||
pub mod queries;
|
||||
pub mod remove;
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
ports::{EventPublisher, WatchlistRepository},
|
||||
value_objects::{MovieId, UserId},
|
||||
};
|
||||
|
||||
use crate::watchlist::commands::RemoveFromWatchlistCommand;
|
||||
use crate::watchlist::deps::RemoveFromWatchlistDeps;
|
||||
|
||||
pub async fn execute(
|
||||
watchlist: Arc<dyn WatchlistRepository>,
|
||||
event_publisher: Arc<dyn EventPublisher>,
|
||||
deps: &RemoveFromWatchlistDeps,
|
||||
cmd: RemoveFromWatchlistCommand,
|
||||
) -> Result<(), DomainError> {
|
||||
let user_id = UserId::from_uuid(cmd.user_id);
|
||||
let movie_id = MovieId::from_uuid(cmd.movie_id);
|
||||
watchlist.remove(&user_id, &movie_id).await?;
|
||||
deps.watchlist.remove(&user_id, &movie_id).await?;
|
||||
|
||||
let _ = event_publisher
|
||||
let _ = deps
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::WatchlistEntryRemoved { user_id, movie_id })
|
||||
.await;
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
use crate::watchlist::{get, queries::GetWatchlistQuery};
|
||||
use crate::watchlist::{deps::GetWatchlistDeps, get, queries::GetWatchlistQuery};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_empty_page_for_new_user() {
|
||||
let b = TestContextBuilder::new();
|
||||
let deps = GetWatchlistDeps {
|
||||
watchlist: b.watchlist_repo.clone(),
|
||||
};
|
||||
let result = get::execute(
|
||||
b.watchlist_repo.clone(),
|
||||
&deps,
|
||||
GetWatchlistQuery {
|
||||
user_id: Uuid::new_v4(),
|
||||
limit: None,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use domain::models::UserRole;
|
||||
use domain::testing::{
|
||||
InMemoryRemoteWatchlistRepository, InMemoryUserRepository, InMemoryWatchlistRepository,
|
||||
PanicRemoteWatchlistRepository, PanicWatchlistRepository,
|
||||
};
|
||||
use domain::value_objects::Email;
|
||||
|
||||
use crate::auth::commands::RegisterCommand;
|
||||
use crate::auth::deps::RegisterDeps;
|
||||
use crate::auth::register;
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
use crate::watchlist::deps::GetWatchlistForOwnerDeps;
|
||||
use crate::watchlist::get_watchlist_for_owner::{self, WatchlistView};
|
||||
|
||||
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 watchlist_for_owner_returns_local_when_user_row_exists() {
|
||||
let b = TestContextBuilder::new();
|
||||
setup_user(&b, "owner@test.com", "owneruser").await;
|
||||
|
||||
let email = Email::new("owner@test.com".into()).unwrap();
|
||||
let user = b.user_repo.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
// If the local-row decision were wrong and this reached the remote arm, the
|
||||
// panic port below would fail the test instead of silently passing.
|
||||
let deps = GetWatchlistForOwnerDeps {
|
||||
user: b.user_repo.clone(),
|
||||
watchlist: InMemoryWatchlistRepository::new(),
|
||||
remote_watchlist: Arc::new(PanicRemoteWatchlistRepository),
|
||||
};
|
||||
|
||||
let view = get_watchlist_for_owner::execute(&deps, uid, Some(20), Some(0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(view, WatchlistView::Local(_)), "expected Local");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watchlist_for_owner_returns_remote_when_no_local_row() {
|
||||
let owner_id = Uuid::new_v4();
|
||||
|
||||
let remote = InMemoryRemoteWatchlistRepository::with_entries(vec![
|
||||
domain::models::RemoteWatchlistEntry {
|
||||
ap_id: "https://remote.example/ap/1".into(),
|
||||
actor_url: "https://remote.example/users/owner".into(),
|
||||
movie_title: "Remote Movie".into(),
|
||||
release_year: 2021,
|
||||
external_metadata_id: None,
|
||||
poster_url: None,
|
||||
added_at: chrono::Utc::now(),
|
||||
},
|
||||
]);
|
||||
|
||||
// No user row exists for `owner_id` in this fresh, empty user repo — the
|
||||
// local-vs-remote decision must fall through to the remote arm. The panic
|
||||
// port below proves the local arm is never touched.
|
||||
let deps = GetWatchlistForOwnerDeps {
|
||||
user: InMemoryUserRepository::new(),
|
||||
watchlist: Arc::new(PanicWatchlistRepository),
|
||||
remote_watchlist: remote,
|
||||
};
|
||||
|
||||
let view = get_watchlist_for_owner::execute(&deps, owner_id, Some(20), Some(0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
match view {
|
||||
WatchlistView::Remote(entries) => {
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].movie_title, "Remote Movie");
|
||||
}
|
||||
WatchlistView::Local(_) => panic!("expected Remote"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pins a pre-existing quirk: the old handler validated `limit`/`offset` only on
|
||||
/// the local branch (inside `watchlist::get::execute`'s `PageParams::new` call) —
|
||||
/// the remote branch never looked at them at all. An out-of-range limit must
|
||||
/// still surface as a validation error on the local arm.
|
||||
#[tokio::test]
|
||||
async fn watchlist_for_owner_local_arm_validates_limit() {
|
||||
let b = TestContextBuilder::new();
|
||||
setup_user(&b, "badlimit@test.com", "badlimituser").await;
|
||||
|
||||
let email = Email::new("badlimit@test.com".into()).unwrap();
|
||||
let user = b.user_repo.find_by_email(&email).await.unwrap().unwrap();
|
||||
let uid = user.id().value();
|
||||
|
||||
let deps = GetWatchlistForOwnerDeps {
|
||||
user: b.user_repo.clone(),
|
||||
watchlist: InMemoryWatchlistRepository::new(),
|
||||
remote_watchlist: Arc::new(PanicRemoteWatchlistRepository),
|
||||
};
|
||||
|
||||
let err = match get_watchlist_for_owner::execute(&deps, uid, Some(0), Some(0)).await {
|
||||
Err(e) => e,
|
||||
Ok(_) => panic!("expected Err(ValidationError) for limit=0 on the local arm, got Ok"),
|
||||
};
|
||||
assert!(matches!(
|
||||
err,
|
||||
domain::errors::DomainError::ValidationError(_)
|
||||
));
|
||||
}
|
||||
|
||||
/// Same quirk, other direction: an out-of-range limit is silently ignored on the
|
||||
/// remote arm, exactly as before — it must not turn into a validation error just
|
||||
/// because the use case now owns the branch decision.
|
||||
#[tokio::test]
|
||||
async fn watchlist_for_owner_remote_arm_ignores_invalid_limit() {
|
||||
let owner_id = Uuid::new_v4();
|
||||
let remote = InMemoryRemoteWatchlistRepository::new();
|
||||
|
||||
let deps = GetWatchlistForOwnerDeps {
|
||||
user: InMemoryUserRepository::new(),
|
||||
watchlist: Arc::new(PanicWatchlistRepository),
|
||||
remote_watchlist: remote,
|
||||
};
|
||||
|
||||
let view = get_watchlist_for_owner::execute(&deps, owner_id, Some(0), Some(0))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(view, WatchlistView::Remote(_)), "expected Remote");
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use domain::value_objects::{MovieId, UserId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
use crate::watchlist::{is_on, queries::IsOnWatchlistQuery};
|
||||
use crate::watchlist::{deps::IsOnWatchlistDeps, is_on, queries::IsOnWatchlistQuery};
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_true_when_present() {
|
||||
@@ -22,8 +22,11 @@ async fn returns_true_when_present() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let deps = IsOnWatchlistDeps {
|
||||
watchlist: Arc::clone(&watchlist) as _,
|
||||
};
|
||||
let result = is_on::execute(
|
||||
Arc::clone(&watchlist) as _,
|
||||
&deps,
|
||||
IsOnWatchlistQuery {
|
||||
user_id: uid,
|
||||
movie_id: mid,
|
||||
@@ -38,8 +41,11 @@ async fn returns_true_when_present() {
|
||||
#[tokio::test]
|
||||
async fn returns_false_when_absent() {
|
||||
let b = TestContextBuilder::new();
|
||||
let deps = IsOnWatchlistDeps {
|
||||
watchlist: b.watchlist_repo.clone(),
|
||||
};
|
||||
let result = is_on::execute(
|
||||
b.watchlist_repo.clone(),
|
||||
&deps,
|
||||
IsOnWatchlistQuery {
|
||||
user_id: Uuid::new_v4(),
|
||||
movie_id: Uuid::new_v4(),
|
||||
|
||||
@@ -8,7 +8,9 @@ use domain::value_objects::{MovieId, UserId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::test_helpers::TestContextBuilder;
|
||||
use crate::watchlist::{commands::RemoveFromWatchlistCommand, remove};
|
||||
use crate::watchlist::{
|
||||
commands::RemoveFromWatchlistCommand, deps::RemoveFromWatchlistDeps, remove,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn removes_entry_and_emits_event() {
|
||||
@@ -24,9 +26,12 @@ async fn removes_entry_and_emits_event() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let deps = RemoveFromWatchlistDeps {
|
||||
watchlist: Arc::clone(&watchlist) as _,
|
||||
event_publisher: Arc::clone(&events) as _,
|
||||
};
|
||||
remove::execute(
|
||||
Arc::clone(&watchlist) as _,
|
||||
Arc::clone(&events) as _,
|
||||
&deps,
|
||||
RemoveFromWatchlistCommand {
|
||||
user_id: uid,
|
||||
movie_id: mid,
|
||||
@@ -47,9 +52,12 @@ async fn removes_entry_and_emits_event() {
|
||||
#[tokio::test]
|
||||
async fn fails_when_not_on_watchlist() {
|
||||
let b = TestContextBuilder::new();
|
||||
let deps = RemoveFromWatchlistDeps {
|
||||
watchlist: b.watchlist_repo.clone(),
|
||||
event_publisher: b.event_publisher.clone(),
|
||||
};
|
||||
let result = remove::execute(
|
||||
b.watchlist_repo.clone(),
|
||||
b.event_publisher.clone(),
|
||||
&deps,
|
||||
RemoveFromWatchlistCommand {
|
||||
user_id: Uuid::new_v4(),
|
||||
movie_id: Uuid::new_v4(),
|
||||
|
||||
Reference in New Issue
Block a user