application: library bounded context
This commit is contained in:
5
crates/application/src/library/commands.rs
Normal file
5
crates/application/src/library/commands.rs
Normal file
@@ -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<String>,
|
||||
}
|
||||
17
crates/application/src/library/deps.rs
Normal file
17
crates/application/src/library/deps.rs
Normal file
@@ -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<dyn LibraryCommand>,
|
||||
pub library_query: Arc<dyn LibraryQuery>,
|
||||
pub library_sync: Arc<dyn LibrarySyncAdapter>,
|
||||
pub provider_registry: Arc<dyn IProviderRegistry>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
/// Dependencies for library read use cases (search, list, get).
|
||||
pub struct LibraryQueryDeps {
|
||||
pub library_query: Arc<dyn LibraryQuery>,
|
||||
}
|
||||
17
crates/application/src/library/get_item.rs
Normal file
17
crates/application/src/library/get_item.rs
Normal file
@@ -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<Option<LibraryItem>> {
|
||||
deps.library_query.get_by_id(&query.item_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_item.rs"]
|
||||
mod tests;
|
||||
17
crates/application/src/library/get_sync_status.rs
Normal file
17
crates/application/src/library/get_sync_status.rs
Normal file
@@ -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<Vec<LibrarySyncLogEntry>> {
|
||||
deps.library_query.latest_sync_status().await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/get_sync_status.rs"]
|
||||
mod tests;
|
||||
19
crates/application/src/library/list_collections.rs
Normal file
19
crates/application/src/library/list_collections.rs
Normal file
@@ -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<Vec<LibraryCollection>> {
|
||||
deps.library_query
|
||||
.list_collections(query.provider_id.as_deref())
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/list_collections.rs"]
|
||||
mod tests;
|
||||
33
crates/application/src/library/list_genres.rs
Normal file
33
crates/application/src/library/list_genres.rs
Normal file
@@ -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<Vec<String>> {
|
||||
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<ContentType> {
|
||||
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;
|
||||
19
crates/application/src/library/list_seasons.rs
Normal file
19
crates/application/src/library/list_seasons.rs
Normal file
@@ -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<Vec<SeasonSummary>> {
|
||||
deps.library_query
|
||||
.list_seasons(&query.series_name, query.provider_id.as_deref())
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/list_seasons.rs"]
|
||||
mod tests;
|
||||
23
crates/application/src/library/list_shows.rs
Normal file
23
crates/application/src/library/list_shows.rs
Normal file
@@ -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<Vec<ShowSummary>> {
|
||||
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;
|
||||
18
crates/application/src/library/mod.rs
Normal file
18
crates/application/src/library/mod.rs
Normal file
@@ -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,
|
||||
};
|
||||
45
crates/application/src/library/queries.rs
Normal file
45
crates/application/src/library/queries.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
/// Search library items with filters.
|
||||
pub struct SearchItemsQuery {
|
||||
pub provider_id: Option<String>,
|
||||
pub content_type: Option<String>,
|
||||
pub genres: Vec<String>,
|
||||
pub search_term: Option<String>,
|
||||
pub collection_id: Option<String>,
|
||||
pub series_names: Vec<String>,
|
||||
pub season_number: Option<u32>,
|
||||
pub decade: Option<u16>,
|
||||
pub offset: u32,
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
/// List library collections.
|
||||
pub struct ListCollectionsQuery {
|
||||
pub provider_id: Option<String>,
|
||||
}
|
||||
|
||||
/// List TV show summaries.
|
||||
pub struct ListShowsQuery {
|
||||
pub provider_id: Option<String>,
|
||||
pub search_term: Option<String>,
|
||||
pub genres: Vec<String>,
|
||||
}
|
||||
|
||||
/// List seasons for a specific series.
|
||||
pub struct ListSeasonsQuery {
|
||||
pub series_name: String,
|
||||
pub provider_id: Option<String>,
|
||||
}
|
||||
|
||||
/// List genres available in the library.
|
||||
pub struct ListGenresQuery {
|
||||
pub content_type: Option<String>,
|
||||
pub provider_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 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;
|
||||
64
crates/application/src/library/search.rs
Normal file
64
crates/application/src/library/search.rs
Normal file
@@ -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<LibraryItem>, 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<ContentType> {
|
||||
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;
|
||||
37
crates/application/src/library/sync.rs
Normal file
37
crates/application/src/library/sync.rs
Normal file
@@ -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<Vec<String>> {
|
||||
let provider_ids: Vec<String> = 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;
|
||||
50
crates/application/src/library/tests/get_item.rs
Normal file
50
crates/application/src/library/tests/get_item.rs
Normal file
@@ -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<domain::testing::InMemoryLibraryRepository>) {
|
||||
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());
|
||||
}
|
||||
41
crates/application/src/library/tests/get_sync_status.rs
Normal file
41
crates/application/src/library/tests/get_sync_status.rs
Normal file
@@ -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");
|
||||
}
|
||||
106
crates/application/src/library/tests/helpers.rs
Normal file
106
crates/application/src/library/tests/helpers.rs
Normal file
@@ -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<Vec<MediaItem>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn fetch_by_id(&self, _item_id: &MediaItemId) -> DomainResult<Option<MediaItem>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn get_stream_url(
|
||||
&self,
|
||||
_item_id: &MediaItemId,
|
||||
_quality: &StreamQuality,
|
||||
) -> DomainResult<String> {
|
||||
Err(domain::DomainError::InfrastructureError(
|
||||
"TestProviderRegistry does not support streaming".into(),
|
||||
))
|
||||
}
|
||||
|
||||
fn provider_ids(&self) -> Vec<String> {
|
||||
vec!["test".into()]
|
||||
}
|
||||
|
||||
fn primary_id(&self) -> &str {
|
||||
"test"
|
||||
}
|
||||
|
||||
fn capabilities(&self, _provider_id: &str) -> Option<ProviderCapabilities> {
|
||||
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<Vec<Collection>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn list_series(
|
||||
&self,
|
||||
_provider_id: &str,
|
||||
_collection_id: Option<&str>,
|
||||
) -> DomainResult<Vec<SeriesSummary>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
async fn list_genres(
|
||||
&self,
|
||||
_provider_id: &str,
|
||||
_content_type: Option<&ContentType>,
|
||||
) -> DomainResult<Vec<String>> {
|
||||
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<InMemoryLibraryRepository>,
|
||||
) {
|
||||
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)
|
||||
}
|
||||
83
crates/application/src/library/tests/list_collections.rs
Normal file
83
crates/application/src/library/tests/list_collections.rs
Normal file
@@ -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<domain::testing::InMemoryLibraryRepository>) {
|
||||
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());
|
||||
}
|
||||
108
crates/application/src/library/tests/list_genres.rs
Normal file
108
crates/application/src/library/tests/list_genres.rs
Normal file
@@ -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<domain::testing::InMemoryLibraryRepository>) {
|
||||
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());
|
||||
}
|
||||
77
crates/application/src/library/tests/list_seasons.rs
Normal file
77
crates/application/src/library/tests/list_seasons.rs
Normal file
@@ -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<domain::testing::InMemoryLibraryRepository>) {
|
||||
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());
|
||||
}
|
||||
101
crates/application/src/library/tests/list_shows.rs
Normal file
101
crates/application/src/library/tests/list_shows.rs
Normal file
@@ -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<domain::testing::InMemoryLibraryRepository>) {
|
||||
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());
|
||||
}
|
||||
247
crates/application/src/library/tests/search.rs
Normal file
247
crates/application/src/library/tests/search.rs
Normal file
@@ -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<domain::testing::InMemoryLibraryRepository>) {
|
||||
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<domain::testing::InMemoryLibraryRepository>) {
|
||||
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);
|
||||
}
|
||||
56
crates/application/src/library/tests/sync.rs
Normal file
56
crates/application/src/library/tests/sync.rs
Normal file
@@ -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"));
|
||||
}
|
||||
Reference in New Issue
Block a user