application: library bounded context

This commit is contained in:
2026-07-12 02:08:04 +02:00
parent ef86a967cd
commit ebf0614fdf
22 changed files with 1184 additions and 0 deletions

View File

@@ -1,3 +1,4 @@
pub mod auth; pub mod auth;
pub mod channels; pub mod channels;
pub mod library;
pub mod schedule; pub mod schedule;

View 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>,
}

View 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>,
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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,
};

View 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;

View 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;

View 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;

View 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());
}

View 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");
}

View 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)
}

View 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());
}

View 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());
}

View 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());
}

View 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());
}

View 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);
}

View 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"));
}