From ebf0614fdf92de160c0f7e3001c28b049d5b9765 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 12 Jul 2026 02:08:04 +0200 Subject: [PATCH] application: library bounded context --- crates/application/src/lib.rs | 1 + crates/application/src/library/commands.rs | 5 + crates/application/src/library/deps.rs | 17 ++ crates/application/src/library/get_item.rs | 17 ++ .../src/library/get_sync_status.rs | 17 ++ .../src/library/list_collections.rs | 19 ++ crates/application/src/library/list_genres.rs | 33 +++ .../application/src/library/list_seasons.rs | 19 ++ crates/application/src/library/list_shows.rs | 23 ++ crates/application/src/library/mod.rs | 18 ++ crates/application/src/library/queries.rs | 45 ++++ crates/application/src/library/search.rs | 64 +++++ crates/application/src/library/sync.rs | 37 +++ .../application/src/library/tests/get_item.rs | 50 ++++ .../src/library/tests/get_sync_status.rs | 41 +++ .../application/src/library/tests/helpers.rs | 106 ++++++++ .../src/library/tests/list_collections.rs | 83 ++++++ .../src/library/tests/list_genres.rs | 108 ++++++++ .../src/library/tests/list_seasons.rs | 77 ++++++ .../src/library/tests/list_shows.rs | 101 +++++++ .../application/src/library/tests/search.rs | 247 ++++++++++++++++++ crates/application/src/library/tests/sync.rs | 56 ++++ 22 files changed, 1184 insertions(+) create mode 100644 crates/application/src/library/commands.rs create mode 100644 crates/application/src/library/deps.rs create mode 100644 crates/application/src/library/get_item.rs create mode 100644 crates/application/src/library/get_sync_status.rs create mode 100644 crates/application/src/library/list_collections.rs create mode 100644 crates/application/src/library/list_genres.rs create mode 100644 crates/application/src/library/list_seasons.rs create mode 100644 crates/application/src/library/list_shows.rs create mode 100644 crates/application/src/library/mod.rs create mode 100644 crates/application/src/library/queries.rs create mode 100644 crates/application/src/library/search.rs create mode 100644 crates/application/src/library/sync.rs create mode 100644 crates/application/src/library/tests/get_item.rs create mode 100644 crates/application/src/library/tests/get_sync_status.rs create mode 100644 crates/application/src/library/tests/helpers.rs create mode 100644 crates/application/src/library/tests/list_collections.rs create mode 100644 crates/application/src/library/tests/list_genres.rs create mode 100644 crates/application/src/library/tests/list_seasons.rs create mode 100644 crates/application/src/library/tests/list_shows.rs create mode 100644 crates/application/src/library/tests/search.rs create mode 100644 crates/application/src/library/tests/sync.rs diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 357745a..ee526d2 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -1,3 +1,4 @@ pub mod auth; pub mod channels; +pub mod library; pub mod schedule; diff --git a/crates/application/src/library/commands.rs b/crates/application/src/library/commands.rs new file mode 100644 index 0000000..8eb7f91 --- /dev/null +++ b/crates/application/src/library/commands.rs @@ -0,0 +1,5 @@ +/// Trigger a library sync for one or all providers. +pub struct TriggerSyncCommand { + /// Provider to sync. `None` means sync all registered providers. + pub provider_id: Option, +} diff --git a/crates/application/src/library/deps.rs b/crates/application/src/library/deps.rs new file mode 100644 index 0000000..1cb7eb3 --- /dev/null +++ b/crates/application/src/library/deps.rs @@ -0,0 +1,17 @@ +use std::sync::Arc; + +use domain::ports::{EventPublisher, IProviderRegistry, LibraryCommand, LibraryQuery, LibrarySyncAdapter}; + +/// Dependencies for library write use cases (trigger sync). +pub struct LibraryCommandDeps { + pub library_command: Arc, + pub library_query: Arc, + pub library_sync: Arc, + pub provider_registry: Arc, + pub event_publisher: Arc, +} + +/// Dependencies for library read use cases (search, list, get). +pub struct LibraryQueryDeps { + pub library_query: Arc, +} diff --git a/crates/application/src/library/get_item.rs b/crates/application/src/library/get_item.rs new file mode 100644 index 0000000..8f47834 --- /dev/null +++ b/crates/application/src/library/get_item.rs @@ -0,0 +1,17 @@ +use domain::models::LibraryItem; +use domain::DomainResult; + +use super::deps::LibraryQueryDeps; +use super::queries::GetItemQuery; + +/// Get a single library item by its composite ID. +pub async fn execute( + deps: &LibraryQueryDeps, + query: GetItemQuery, +) -> DomainResult> { + deps.library_query.get_by_id(&query.item_id).await +} + +#[cfg(test)] +#[path = "tests/get_item.rs"] +mod tests; diff --git a/crates/application/src/library/get_sync_status.rs b/crates/application/src/library/get_sync_status.rs new file mode 100644 index 0000000..98fcbd1 --- /dev/null +++ b/crates/application/src/library/get_sync_status.rs @@ -0,0 +1,17 @@ +use domain::models::LibrarySyncLogEntry; +use domain::DomainResult; + +use super::deps::LibraryQueryDeps; +use super::queries::GetSyncStatusQuery; + +/// Get the latest sync status per provider. +pub async fn execute( + deps: &LibraryQueryDeps, + _query: GetSyncStatusQuery, +) -> DomainResult> { + deps.library_query.latest_sync_status().await +} + +#[cfg(test)] +#[path = "tests/get_sync_status.rs"] +mod tests; diff --git a/crates/application/src/library/list_collections.rs b/crates/application/src/library/list_collections.rs new file mode 100644 index 0000000..8121c0d --- /dev/null +++ b/crates/application/src/library/list_collections.rs @@ -0,0 +1,19 @@ +use domain::models::LibraryCollection; +use domain::DomainResult; + +use super::deps::LibraryQueryDeps; +use super::queries::ListCollectionsQuery; + +/// List library collections, optionally filtered by provider. +pub async fn execute( + deps: &LibraryQueryDeps, + query: ListCollectionsQuery, +) -> DomainResult> { + deps.library_query + .list_collections(query.provider_id.as_deref()) + .await +} + +#[cfg(test)] +#[path = "tests/list_collections.rs"] +mod tests; diff --git a/crates/application/src/library/list_genres.rs b/crates/application/src/library/list_genres.rs new file mode 100644 index 0000000..f4c8cfc --- /dev/null +++ b/crates/application/src/library/list_genres.rs @@ -0,0 +1,33 @@ +use domain::errors::{DomainError, DomainResult}; +use domain::value_objects::ContentType; + +use super::deps::LibraryQueryDeps; +use super::queries::ListGenresQuery; + +/// List genres available in the library, optionally filtered. +pub async fn execute(deps: &LibraryQueryDeps, query: ListGenresQuery) -> DomainResult> { + let content_type = query + .content_type + .as_deref() + .map(parse_content_type) + .transpose()?; + + deps.library_query + .list_genres(content_type.as_ref(), query.provider_id.as_deref()) + .await +} + +fn parse_content_type(s: &str) -> DomainResult { + match s { + "movie" => Ok(ContentType::Movie), + "episode" => Ok(ContentType::Episode), + "short" => Ok(ContentType::Short), + other => Err(DomainError::ValidationError(format!( + "Unknown content type '{other}'. Use movie, episode, or short." + ))), + } +} + +#[cfg(test)] +#[path = "tests/list_genres.rs"] +mod tests; diff --git a/crates/application/src/library/list_seasons.rs b/crates/application/src/library/list_seasons.rs new file mode 100644 index 0000000..ddaafc2 --- /dev/null +++ b/crates/application/src/library/list_seasons.rs @@ -0,0 +1,19 @@ +use domain::models::SeasonSummary; +use domain::DomainResult; + +use super::deps::LibraryQueryDeps; +use super::queries::ListSeasonsQuery; + +/// List season summaries for a specific series. +pub async fn execute( + deps: &LibraryQueryDeps, + query: ListSeasonsQuery, +) -> DomainResult> { + deps.library_query + .list_seasons(&query.series_name, query.provider_id.as_deref()) + .await +} + +#[cfg(test)] +#[path = "tests/list_seasons.rs"] +mod tests; diff --git a/crates/application/src/library/list_shows.rs b/crates/application/src/library/list_shows.rs new file mode 100644 index 0000000..c4dc46d --- /dev/null +++ b/crates/application/src/library/list_shows.rs @@ -0,0 +1,23 @@ +use domain::models::ShowSummary; +use domain::DomainResult; + +use super::deps::LibraryQueryDeps; +use super::queries::ListShowsQuery; + +/// List TV show summaries, optionally filtered. +pub async fn execute( + deps: &LibraryQueryDeps, + query: ListShowsQuery, +) -> DomainResult> { + deps.library_query + .list_shows( + query.provider_id.as_deref(), + query.search_term.as_deref(), + &query.genres, + ) + .await +} + +#[cfg(test)] +#[path = "tests/list_shows.rs"] +mod tests; diff --git a/crates/application/src/library/mod.rs b/crates/application/src/library/mod.rs new file mode 100644 index 0000000..a56f295 --- /dev/null +++ b/crates/application/src/library/mod.rs @@ -0,0 +1,18 @@ +pub mod commands; +pub mod deps; +pub mod get_item; +pub mod get_sync_status; +pub mod list_collections; +pub mod list_genres; +pub mod list_seasons; +pub mod list_shows; +pub mod queries; +pub mod search; +pub mod sync; + +pub use commands::TriggerSyncCommand; +pub use deps::{LibraryCommandDeps, LibraryQueryDeps}; +pub use queries::{ + GetItemQuery, GetSyncStatusQuery, ListCollectionsQuery, ListGenresQuery, ListSeasonsQuery, + ListShowsQuery, SearchItemsQuery, +}; diff --git a/crates/application/src/library/queries.rs b/crates/application/src/library/queries.rs new file mode 100644 index 0000000..23dec23 --- /dev/null +++ b/crates/application/src/library/queries.rs @@ -0,0 +1,45 @@ +/// Search library items with filters. +pub struct SearchItemsQuery { + pub provider_id: Option, + pub content_type: Option, + pub genres: Vec, + pub search_term: Option, + pub collection_id: Option, + pub series_names: Vec, + pub season_number: Option, + pub decade: Option, + pub offset: u32, + pub limit: u32, +} + +/// List library collections. +pub struct ListCollectionsQuery { + pub provider_id: Option, +} + +/// List TV show summaries. +pub struct ListShowsQuery { + pub provider_id: Option, + pub search_term: Option, + pub genres: Vec, +} + +/// List seasons for a specific series. +pub struct ListSeasonsQuery { + pub series_name: String, + pub provider_id: Option, +} + +/// List genres available in the library. +pub struct ListGenresQuery { + pub content_type: Option, + pub provider_id: Option, +} + +/// Get a single library item by its composite ID. +pub struct GetItemQuery { + pub item_id: String, +} + +/// Get the latest sync status per provider. +pub struct GetSyncStatusQuery; diff --git a/crates/application/src/library/search.rs b/crates/application/src/library/search.rs new file mode 100644 index 0000000..2cb5837 --- /dev/null +++ b/crates/application/src/library/search.rs @@ -0,0 +1,64 @@ +use domain::errors::{DomainError, DomainResult}; +use domain::models::LibraryItem; +use domain::value_objects::{ContentType, LibrarySearchFilter}; + +use super::deps::LibraryQueryDeps; +use super::queries::SearchItemsQuery; + +/// Search library items with filters. Returns `(items, total_count)`. +pub async fn execute( + deps: &LibraryQueryDeps, + query: SearchItemsQuery, +) -> DomainResult<(Vec, u32)> { + let content_type = query + .content_type + .as_deref() + .map(parse_content_type) + .transpose()?; + + let mut filter = LibrarySearchFilter::new() + .with_offset(query.offset) + .with_limit(query.limit); + + if let Some(pid) = query.provider_id { + filter = filter.with_provider_id(pid); + } + if let Some(ct) = content_type { + filter = filter.with_content_type(ct); + } + if !query.genres.is_empty() { + filter = filter.with_genres(query.genres); + } + if let Some(term) = query.search_term { + filter = filter.with_search_term(term); + } + if let Some(cid) = query.collection_id { + filter = filter.with_collection_id(cid); + } + if !query.series_names.is_empty() { + filter = filter.with_series_names(query.series_names); + } + if let Some(sn) = query.season_number { + filter = filter.with_season_number(sn); + } + if let Some(decade) = query.decade { + filter = filter.with_decade(decade); + } + + deps.library_query.search(&filter).await +} + +fn parse_content_type(s: &str) -> DomainResult { + match s { + "movie" => Ok(ContentType::Movie), + "episode" => Ok(ContentType::Episode), + "short" => Ok(ContentType::Short), + other => Err(DomainError::ValidationError(format!( + "Unknown content type '{other}'. Use movie, episode, or short." + ))), + } +} + +#[cfg(test)] +#[path = "tests/search.rs"] +mod tests; diff --git a/crates/application/src/library/sync.rs b/crates/application/src/library/sync.rs new file mode 100644 index 0000000..e9e5c01 --- /dev/null +++ b/crates/application/src/library/sync.rs @@ -0,0 +1,37 @@ +use domain::errors::{DomainError, DomainResult}; + +use super::commands::TriggerSyncCommand; +use super::deps::LibraryCommandDeps; + +/// Validate and return provider IDs eligible for sync. +/// +/// Checks that no sync is already running for the targeted provider(s). +/// Returns the list of provider IDs to sync. The caller (API layer) is +/// responsible for spawning the actual sync tasks, since `LibrarySyncAdapter` +/// requires `&dyn IMediaProvider` references that only the infra layer holds. +/// +/// Returns `Err(ValidationError)` if any targeted provider is already syncing +/// (maps to 409 Conflict at the API layer). +pub async fn execute( + deps: &LibraryCommandDeps, + cmd: TriggerSyncCommand, +) -> DomainResult> { + let provider_ids: Vec = match cmd.provider_id { + Some(pid) => vec![pid], + None => deps.provider_registry.provider_ids(), + }; + + for pid in &provider_ids { + if deps.library_query.is_sync_running(pid).await? { + return Err(DomainError::ValidationError(format!( + "Sync already running for provider '{pid}'" + ))); + } + } + + Ok(provider_ids) +} + +#[cfg(test)] +#[path = "tests/sync.rs"] +mod tests; diff --git a/crates/application/src/library/tests/get_item.rs b/crates/application/src/library/tests/get_item.rs new file mode 100644 index 0000000..9ee7144 --- /dev/null +++ b/crates/application/src/library/tests/get_item.rs @@ -0,0 +1,50 @@ +use domain::models::LibraryItem; +use domain::value_objects::ContentType; + +use crate::library::get_item; +use crate::library::queries::GetItemQuery; + +#[path = "helpers.rs"] +mod helpers; + +fn seed_item(repo: &std::sync::Arc) { + let item = LibraryItem::new("test", "m1", "Die Hard", ContentType::Movie, 7800, "2026-01-01"); + repo.items + .lock() + .unwrap() + .insert(item.id().to_string(), item); +} + +#[tokio::test] +async fn get_existing_item() { + let (_cmd, query, repo) = helpers::make_deps(); + seed_item(&repo); + + let item = get_item::execute( + &query, + GetItemQuery { + item_id: "test::m1".into(), + }, + ) + .await + .unwrap(); + + assert!(item.is_some()); + assert_eq!(item.unwrap().title(), "Die Hard"); +} + +#[tokio::test] +async fn get_nonexistent_item_returns_none() { + let (_cmd, query, _repo) = helpers::make_deps(); + + let item = get_item::execute( + &query, + GetItemQuery { + item_id: "test::missing".into(), + }, + ) + .await + .unwrap(); + + assert!(item.is_none()); +} diff --git a/crates/application/src/library/tests/get_sync_status.rs b/crates/application/src/library/tests/get_sync_status.rs new file mode 100644 index 0000000..c81b32f --- /dev/null +++ b/crates/application/src/library/tests/get_sync_status.rs @@ -0,0 +1,41 @@ +use crate::library::get_sync_status; +use crate::library::queries::GetSyncStatusQuery; + +#[path = "helpers.rs"] +mod helpers; + +#[tokio::test] +async fn get_sync_status_empty() { + let (_cmd, query, _repo) = helpers::make_deps(); + + let entries = get_sync_status::execute(&query, GetSyncStatusQuery) + .await + .unwrap(); + + assert!(entries.is_empty()); +} + +#[tokio::test] +async fn get_sync_status_after_sync() { + let (cmd, query, _repo) = helpers::make_deps(); + + // Start a sync log entry + let log_id = domain::ports::LibraryCommand::log_sync_start(&*cmd.library_command, "test") + .await + .unwrap(); + + // Finish it + let result = domain::models::LibrarySyncResult::new("test", 42, 500); + domain::ports::LibraryCommand::log_sync_finish(&*cmd.library_command, log_id, &result) + .await + .unwrap(); + + let entries = get_sync_status::execute(&query, GetSyncStatusQuery) + .await + .unwrap(); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].provider_id(), "test"); + assert_eq!(entries[0].items_found(), 42); + assert_eq!(entries[0].status(), "success"); +} diff --git a/crates/application/src/library/tests/helpers.rs b/crates/application/src/library/tests/helpers.rs new file mode 100644 index 0000000..e6017b4 --- /dev/null +++ b/crates/application/src/library/tests/helpers.rs @@ -0,0 +1,106 @@ +use std::sync::Arc; + +use async_trait::async_trait; + +use domain::errors::DomainResult; +use domain::models::MediaItem; +use domain::ports::{ + Collection, IProviderRegistry, ProviderCapabilities, SeriesSummary, StreamQuality, + StreamingProtocol, +}; +use domain::testing::{InMemoryLibraryRepository, NoopEventPublisher, NoopLibrarySync}; +use domain::value_objects::{ContentType, MediaFilter, MediaItemId}; + +use crate::library::deps::{LibraryCommandDeps, LibraryQueryDeps}; + +/// Minimal IProviderRegistry for library tests. +pub(crate) struct TestProviderRegistry; + +#[async_trait] +impl IProviderRegistry for TestProviderRegistry { + async fn fetch_items( + &self, + _provider_id: &str, + _filter: &MediaFilter, + ) -> DomainResult> { + Ok(vec![]) + } + + async fn fetch_by_id(&self, _item_id: &MediaItemId) -> DomainResult> { + Ok(None) + } + + async fn get_stream_url( + &self, + _item_id: &MediaItemId, + _quality: &StreamQuality, + ) -> DomainResult { + Err(domain::DomainError::InfrastructureError( + "TestProviderRegistry does not support streaming".into(), + )) + } + + fn provider_ids(&self) -> Vec { + vec!["test".into()] + } + + fn primary_id(&self) -> &str { + "test" + } + + fn capabilities(&self, _provider_id: &str) -> Option { + Some(ProviderCapabilities { + collections: true, + series: true, + genres: true, + tags: false, + decade: false, + search: true, + streaming_protocol: StreamingProtocol::Hls, + rescan: false, + transcode: false, + }) + } + + async fn list_collections(&self, _provider_id: &str) -> DomainResult> { + Ok(vec![]) + } + + async fn list_series( + &self, + _provider_id: &str, + _collection_id: Option<&str>, + ) -> DomainResult> { + Ok(vec![]) + } + + async fn list_genres( + &self, + _provider_id: &str, + _content_type: Option<&ContentType>, + ) -> DomainResult> { + Ok(vec![]) + } +} + +/// Build LibraryCommandDeps and LibraryQueryDeps backed by InMemory repos. +/// +/// Returns deps plus the underlying repo for seeding test data. +pub(crate) fn make_deps() -> ( + LibraryCommandDeps, + LibraryQueryDeps, + Arc, +) { + let repo = Arc::new(InMemoryLibraryRepository::new()); + let cmd_deps = LibraryCommandDeps { + library_command: repo.clone(), + library_query: repo.clone(), + library_sync: Arc::new(NoopLibrarySync::new()), + provider_registry: Arc::new(TestProviderRegistry), + event_publisher: Arc::new(NoopEventPublisher::new()), + }; + let query_deps = LibraryQueryDeps { + library_query: repo.clone(), + }; + (cmd_deps, query_deps, repo) +} diff --git a/crates/application/src/library/tests/list_collections.rs b/crates/application/src/library/tests/list_collections.rs new file mode 100644 index 0000000..fae8b3f --- /dev/null +++ b/crates/application/src/library/tests/list_collections.rs @@ -0,0 +1,83 @@ +use domain::models::LibraryItem; +use domain::value_objects::ContentType; + +use crate::library::list_collections; +use crate::library::queries::ListCollectionsQuery; + +#[path = "helpers.rs"] +mod helpers; + +fn seed_with_collections(repo: &std::sync::Arc) { + let mut store = repo.items.lock().unwrap(); + + let item = LibraryItem::from_persistence( + "test::m1".into(), + "test".into(), + "m1".into(), + "Die Hard".into(), + ContentType::Movie, + 7800, + None, + None, + None, + None, + vec![], + vec![], + Some("col-1".into()), + Some("Movies".into()), + Some("movies".into()), + None, + "2026-01-01".into(), + ); + store.insert(item.id().to_string(), item); + + let item2 = LibraryItem::from_persistence( + "test::e1".into(), + "test".into(), + "e1".into(), + "BB S01E01".into(), + ContentType::Episode, + 2700, + Some("Breaking Bad".into()), + Some(1), + Some(1), + None, + vec![], + vec![], + Some("col-2".into()), + Some("TV Shows".into()), + Some("tvshows".into()), + None, + "2026-01-01".into(), + ); + store.insert(item2.id().to_string(), item2); +} + +#[tokio::test] +async fn list_collections_returns_distinct() { + let (_cmd, query, repo) = helpers::make_deps(); + seed_with_collections(&repo); + + let cols = list_collections::execute( + &query, + ListCollectionsQuery { provider_id: None }, + ) + .await + .unwrap(); + + assert_eq!(cols.len(), 2); +} + +#[tokio::test] +async fn list_collections_empty_library() { + let (_cmd, query, _repo) = helpers::make_deps(); + + let cols = list_collections::execute( + &query, + ListCollectionsQuery { provider_id: None }, + ) + .await + .unwrap(); + + assert!(cols.is_empty()); +} diff --git a/crates/application/src/library/tests/list_genres.rs b/crates/application/src/library/tests/list_genres.rs new file mode 100644 index 0000000..12f3f0a --- /dev/null +++ b/crates/application/src/library/tests/list_genres.rs @@ -0,0 +1,108 @@ +use domain::models::LibraryItem; +use domain::value_objects::ContentType; + +use crate::library::list_genres; +use crate::library::queries::ListGenresQuery; + +#[path = "helpers.rs"] +mod helpers; + +fn seed_with_genres(repo: &std::sync::Arc) { + let mut store = repo.items.lock().unwrap(); + + let item1 = LibraryItem::from_persistence( + "test::m1".into(), + "test".into(), + "m1".into(), + "Die Hard".into(), + ContentType::Movie, + 7800, + None, + None, + None, + None, + vec!["Action".into(), "Thriller".into()], + vec![], + None, + None, + None, + None, + "2026-01-01".into(), + ); + let item2 = LibraryItem::from_persistence( + "test::m2".into(), + "test".into(), + "m2".into(), + "Alien".into(), + ContentType::Movie, + 7020, + None, + None, + None, + None, + vec!["Sci-Fi".into(), "Action".into()], + vec![], + None, + None, + None, + None, + "2026-01-01".into(), + ); + + store.insert(item1.id().to_string(), item1); + store.insert(item2.id().to_string(), item2); +} + +#[tokio::test] +async fn list_genres_returns_unique() { + let (_cmd, query, repo) = helpers::make_deps(); + seed_with_genres(&repo); + + let genres = list_genres::execute( + &query, + ListGenresQuery { + content_type: None, + provider_id: None, + }, + ) + .await + .unwrap(); + + assert_eq!(genres.len(), 3); // Action, Sci-Fi, Thriller (deduped) + assert!(genres.contains(&"Action".to_string())); + assert!(genres.contains(&"Sci-Fi".to_string())); + assert!(genres.contains(&"Thriller".to_string())); +} + +#[tokio::test] +async fn list_genres_empty_library() { + let (_cmd, query, _repo) = helpers::make_deps(); + + let genres = list_genres::execute( + &query, + ListGenresQuery { + content_type: None, + provider_id: None, + }, + ) + .await + .unwrap(); + + assert!(genres.is_empty()); +} + +#[tokio::test] +async fn list_genres_invalid_content_type_errors() { + let (_cmd, query, _repo) = helpers::make_deps(); + + let result = list_genres::execute( + &query, + ListGenresQuery { + content_type: Some("invalid".into()), + provider_id: None, + }, + ) + .await; + + assert!(result.is_err()); +} diff --git a/crates/application/src/library/tests/list_seasons.rs b/crates/application/src/library/tests/list_seasons.rs new file mode 100644 index 0000000..70687d6 --- /dev/null +++ b/crates/application/src/library/tests/list_seasons.rs @@ -0,0 +1,77 @@ +use domain::models::LibraryItem; +use domain::value_objects::ContentType; + +use crate::library::list_seasons; +use crate::library::queries::ListSeasonsQuery; + +#[path = "helpers.rs"] +mod helpers; + +fn seed_episodes(repo: &std::sync::Arc) { + let mut store = repo.items.lock().unwrap(); + + for (i, season) in [1u32, 1, 2, 2, 2, 3].iter().enumerate() { + let item = LibraryItem::from_persistence( + format!("test::e{i}"), + "test".into(), + format!("e{i}"), + format!("BB S{season:02}E{:02}", i + 1), + ContentType::Episode, + 2700, + Some("Breaking Bad".into()), + Some(*season), + Some(i as u32 + 1), + None, + vec![], + vec![], + None, + None, + None, + None, + "2026-01-01".into(), + ); + store.insert(item.id().to_string(), item); + } +} + +#[tokio::test] +async fn list_seasons_for_series() { + let (_cmd, query, repo) = helpers::make_deps(); + seed_episodes(&repo); + + let seasons = list_seasons::execute( + &query, + ListSeasonsQuery { + series_name: "Breaking Bad".into(), + provider_id: None, + }, + ) + .await + .unwrap(); + + assert_eq!(seasons.len(), 3); + // Sorted by season_number + assert_eq!(seasons[0].season_number(), 1); + assert_eq!(seasons[0].episode_count(), 2); + assert_eq!(seasons[1].season_number(), 2); + assert_eq!(seasons[1].episode_count(), 3); + assert_eq!(seasons[2].season_number(), 3); + assert_eq!(seasons[2].episode_count(), 1); +} + +#[tokio::test] +async fn list_seasons_unknown_series() { + let (_cmd, query, _repo) = helpers::make_deps(); + + let seasons = list_seasons::execute( + &query, + ListSeasonsQuery { + series_name: "Nonexistent".into(), + provider_id: None, + }, + ) + .await + .unwrap(); + + assert!(seasons.is_empty()); +} diff --git a/crates/application/src/library/tests/list_shows.rs b/crates/application/src/library/tests/list_shows.rs new file mode 100644 index 0000000..9c2388d --- /dev/null +++ b/crates/application/src/library/tests/list_shows.rs @@ -0,0 +1,101 @@ +use domain::models::LibraryItem; +use domain::value_objects::ContentType; + +use crate::library::list_shows; +use crate::library::queries::ListShowsQuery; + +#[path = "helpers.rs"] +mod helpers; + +fn seed_episodes(repo: &std::sync::Arc) { + let mut store = repo.items.lock().unwrap(); + + for (i, (series, season)) in [ + ("Breaking Bad", 1u32), + ("Breaking Bad", 1), + ("Breaking Bad", 2), + ("The Wire", 1), + ("The Wire", 1), + ] + .iter() + .enumerate() + { + let item = LibraryItem::from_persistence( + format!("test::e{i}"), + "test".into(), + format!("e{i}"), + format!("{series} S{season:02}E{i:02}"), + ContentType::Episode, + 2700, + Some(series.to_string()), + Some(*season), + Some(i as u32 + 1), + None, + vec![], + vec![], + None, + None, + None, + None, + "2026-01-01".into(), + ); + store.insert(item.id().to_string(), item); + } +} + +#[tokio::test] +async fn list_shows_returns_summaries() { + let (_cmd, query, repo) = helpers::make_deps(); + seed_episodes(&repo); + + let shows = list_shows::execute( + &query, + ListShowsQuery { + provider_id: None, + search_term: None, + genres: vec![], + }, + ) + .await + .unwrap(); + + assert_eq!(shows.len(), 2); +} + +#[tokio::test] +async fn list_shows_with_search_term() { + let (_cmd, query, repo) = helpers::make_deps(); + seed_episodes(&repo); + + let shows = list_shows::execute( + &query, + ListShowsQuery { + provider_id: None, + search_term: Some("breaking".into()), + genres: vec![], + }, + ) + .await + .unwrap(); + + assert_eq!(shows.len(), 1); + assert_eq!(shows[0].series_name(), "Breaking Bad"); +} + +#[tokio::test] +async fn list_shows_empty() { + let (_cmd, query, _repo) = helpers::make_deps(); + + let shows = list_shows::execute( + &query, + ListShowsQuery { + provider_id: None, + search_term: None, + genres: vec![], + }, + ) + .await + .unwrap(); + + assert!(shows.is_empty()); +} diff --git a/crates/application/src/library/tests/search.rs b/crates/application/src/library/tests/search.rs new file mode 100644 index 0000000..31bc05c --- /dev/null +++ b/crates/application/src/library/tests/search.rs @@ -0,0 +1,247 @@ +use domain::models::LibraryItem; +use domain::value_objects::ContentType; + +use crate::library::queries::SearchItemsQuery; +use crate::library::search; + +#[path = "helpers.rs"] +mod helpers; + +fn seed_items(repo: &std::sync::Arc) { + let mut store = repo.items.lock().unwrap(); + let items = vec![ + LibraryItem::new("test", "m1", "Die Hard", ContentType::Movie, 7800, "2026-01-01"), + LibraryItem::new("test", "m2", "Alien", ContentType::Movie, 7020, "2026-01-01"), + LibraryItem::new("test", "e1", "BB S01E01", ContentType::Episode, 2700, "2026-01-01"), + ]; + for item in items { + store.insert(item.id().to_string(), item); + } +} + +fn seed_items_with_genres(repo: &std::sync::Arc) { + let mut store = repo.items.lock().unwrap(); + + let action = LibraryItem::from_persistence( + "test::m1".into(), + "test".into(), + "m1".into(), + "Die Hard".into(), + ContentType::Movie, + 7800, + None, + None, + None, + Some(1988), + vec!["Action".into(), "Thriller".into()], + vec![], + None, + None, + None, + None, + "2026-01-01".into(), + ); + let scifi = LibraryItem::from_persistence( + "test::m2".into(), + "test".into(), + "m2".into(), + "Alien".into(), + ContentType::Movie, + 7020, + None, + None, + None, + Some(1979), + vec!["Sci-Fi".into(), "Horror".into()], + vec![], + None, + None, + None, + None, + "2026-01-01".into(), + ); + let comedy = LibraryItem::from_persistence( + "test::m3".into(), + "test".into(), + "m3".into(), + "Airplane!".into(), + ContentType::Movie, + 5280, + None, + None, + None, + Some(1980), + vec!["Comedy".into()], + vec![], + None, + None, + None, + None, + "2026-01-01".into(), + ); + + store.insert(action.id().to_string(), action); + store.insert(scifi.id().to_string(), scifi); + store.insert(comedy.id().to_string(), comedy); +} + +#[tokio::test] +async fn search_empty_filter_returns_all() { + let (_cmd, query, repo) = helpers::make_deps(); + seed_items(&repo); + + let (items, total) = search::execute( + &query, + SearchItemsQuery { + provider_id: None, + content_type: None, + genres: vec![], + search_term: None, + collection_id: None, + series_names: vec![], + season_number: None, + decade: None, + offset: 0, + limit: 50, + }, + ) + .await + .unwrap(); + + assert_eq!(total, 3); + assert_eq!(items.len(), 3); +} + +#[tokio::test] +async fn search_by_content_type() { + let (_cmd, query, repo) = helpers::make_deps(); + seed_items(&repo); + + let (items, total) = search::execute( + &query, + SearchItemsQuery { + provider_id: None, + content_type: Some("movie".into()), + genres: vec![], + search_term: None, + collection_id: None, + series_names: vec![], + season_number: None, + decade: None, + offset: 0, + limit: 50, + }, + ) + .await + .unwrap(); + + assert_eq!(total, 2); + assert_eq!(items.len(), 2); +} + +#[tokio::test] +async fn search_by_genre() { + let (_cmd, query, repo) = helpers::make_deps(); + seed_items_with_genres(&repo); + + let (items, total) = search::execute( + &query, + SearchItemsQuery { + provider_id: None, + content_type: None, + genres: vec!["Action".into()], + search_term: None, + collection_id: None, + series_names: vec![], + season_number: None, + decade: None, + offset: 0, + limit: 50, + }, + ) + .await + .unwrap(); + + assert_eq!(total, 1); + assert_eq!(items.len(), 1); + assert_eq!(items[0].title(), "Die Hard"); +} + +#[tokio::test] +async fn search_by_search_term() { + let (_cmd, query, repo) = helpers::make_deps(); + seed_items(&repo); + + let (items, total) = search::execute( + &query, + SearchItemsQuery { + provider_id: None, + content_type: None, + genres: vec![], + search_term: Some("die".into()), + collection_id: None, + series_names: vec![], + season_number: None, + decade: None, + offset: 0, + limit: 50, + }, + ) + .await + .unwrap(); + + assert_eq!(total, 1); + assert_eq!(items.len(), 1); + assert_eq!(items[0].title(), "Die Hard"); +} + +#[tokio::test] +async fn search_invalid_content_type_errors() { + let (_cmd, query, _repo) = helpers::make_deps(); + + let result = search::execute( + &query, + SearchItemsQuery { + provider_id: None, + content_type: Some("bogus".into()), + genres: vec![], + search_term: None, + collection_id: None, + series_names: vec![], + season_number: None, + decade: None, + offset: 0, + limit: 50, + }, + ) + .await; + + assert!(result.is_err()); +} + +#[tokio::test] +async fn search_pagination() { + let (_cmd, query, repo) = helpers::make_deps(); + seed_items(&repo); + + let (items, total) = search::execute( + &query, + SearchItemsQuery { + provider_id: None, + content_type: None, + genres: vec![], + search_term: None, + collection_id: None, + series_names: vec![], + season_number: None, + decade: None, + offset: 0, + limit: 2, + }, + ) + .await + .unwrap(); + + assert_eq!(total, 3); + assert_eq!(items.len(), 2); +} diff --git a/crates/application/src/library/tests/sync.rs b/crates/application/src/library/tests/sync.rs new file mode 100644 index 0000000..d1ad9cf --- /dev/null +++ b/crates/application/src/library/tests/sync.rs @@ -0,0 +1,56 @@ +use crate::library::commands::TriggerSyncCommand; +use crate::library::sync; + +#[path = "helpers.rs"] +mod helpers; + +#[tokio::test] +async fn trigger_sync_returns_provider_ids() { + let (cmd, _query, _repo) = helpers::make_deps(); + + let ids = sync::execute( + &cmd, + TriggerSyncCommand { provider_id: None }, + ) + .await + .unwrap(); + + assert_eq!(ids, vec!["test".to_string()]); +} + +#[tokio::test] +async fn trigger_sync_specific_provider() { + let (cmd, _query, _repo) = helpers::make_deps(); + + let ids = sync::execute( + &cmd, + TriggerSyncCommand { + provider_id: Some("test".into()), + }, + ) + .await + .unwrap(); + + assert_eq!(ids, vec!["test".to_string()]); +} + +#[tokio::test] +async fn trigger_sync_while_running_errors() { + let (cmd, _query, repo) = helpers::make_deps(); + + // Simulate a running sync by inserting a log entry with "running" status + repo.items.lock().unwrap(); // just verify repo is accessible + let _log_id = domain::ports::LibraryCommand::log_sync_start(&*cmd.library_command, "test") + .await + .unwrap(); + + let result = sync::execute( + &cmd, + TriggerSyncCommand { provider_id: None }, + ) + .await; + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.to_string().contains("Sync already running")); +}