collapse 20 pass-through use cases; handlers call ports directly

delete get/list/list_by_owner channels, get_settings/activity_log admin,
get_item/get_sync_status/list_collections/list_seasons/list_shows/list_genres library,
get/list/delete providers, get/list/patch_label config_snapshots,
get_active/list_history/delete_after schedule — all single-delegation.

remove ChannelQueryDeps, LibraryQueryDeps, deleted query/command structs.
add direct port fields to AppState. update MCP crate accordingly.
This commit is contained in:
2026-07-12 07:18:26 +02:00
parent a6558e15b2
commit e2393be635
68 changed files with 191 additions and 1589 deletions

View File

@@ -1,12 +0,0 @@
use domain::models::ActivityEvent;
use domain::DomainResult;
use super::deps::AdminDeps;
use super::queries::GetActivityLogQuery;
pub async fn execute(
deps: &AdminDeps,
query: GetActivityLogQuery,
) -> DomainResult<Vec<ActivityEvent>> {
deps.activity_query.recent(query.limit).await
}

View File

@@ -1,15 +0,0 @@
use domain::DomainResult;
use super::deps::AdminDeps;
use super::queries::GetSettingsQuery;
pub async fn execute(
deps: &AdminDeps,
_query: GetSettingsQuery,
) -> DomainResult<Vec<(String, String)>> {
deps.settings_repo.get_all().await
}
#[cfg(test)]
#[path = "tests/get_settings.rs"]
mod tests;

View File

@@ -1,10 +1,6 @@
pub mod activity_log;
pub mod commands;
pub mod deps;
pub mod get_settings;
pub mod queries;
pub mod update_settings;
pub use commands::UpdateSettingsCommand;
pub use deps::AdminDeps;
pub use queries::{GetActivityLogQuery, GetSettingsQuery};

View File

@@ -1,5 +0,0 @@
pub struct GetSettingsQuery;
pub struct GetActivityLogQuery {
pub limit: u32,
}

View File

@@ -1,46 +0,0 @@
use std::sync::Arc;
use domain::testing::{InMemoryActivityLog, InMemoryAppSettings};
use crate::admin::commands::UpdateSettingsCommand;
use crate::admin::deps::AdminDeps;
use crate::admin::queries::GetSettingsQuery;
use crate::admin::{get_settings, update_settings};
fn make_deps() -> AdminDeps {
AdminDeps {
settings_repo: Arc::new(InMemoryAppSettings::new()),
activity_query: Arc::new(InMemoryActivityLog::new()),
}
}
#[tokio::test]
async fn get_empty_settings() {
let deps = make_deps();
let settings = get_settings::execute(&deps, GetSettingsQuery).await.unwrap();
assert!(settings.is_empty());
}
#[tokio::test]
async fn get_returns_stored_settings() {
let deps = make_deps();
update_settings::execute(
&deps,
UpdateSettingsCommand {
settings: vec![
("a".into(), "1".into()),
("b".into(), "2".into()),
],
},
)
.await
.unwrap();
let settings = get_settings::execute(&deps, GetSettingsQuery).await.unwrap();
assert_eq!(settings.len(), 2);
let keys: Vec<&str> = settings.iter().map(|(k, _)| k.as_str()).collect();
assert!(keys.contains(&"a"));
assert!(keys.contains(&"b"));
}

View File

@@ -7,7 +7,3 @@ pub struct ChannelCommandDeps {
pub channel_query: Arc<dyn ChannelQuery>,
pub event_publisher: Arc<dyn EventPublisher>,
}
pub struct ChannelQueryDeps {
pub channel_query: Arc<dyn ChannelQuery>,
}

View File

@@ -1,13 +0,0 @@
use domain::models::Channel;
use domain::DomainResult;
use super::deps::ChannelQueryDeps;
use super::queries::GetChannelQuery;
pub async fn execute(deps: &ChannelQueryDeps, query: GetChannelQuery) -> DomainResult<Option<Channel>> {
deps.channel_query.find_by_id(query.channel_id).await
}
#[cfg(test)]
#[path = "tests/get.rs"]
mod tests;

View File

@@ -1,13 +0,0 @@
use domain::models::Channel;
use domain::DomainResult;
use super::deps::ChannelQueryDeps;
use super::queries::ListChannelsQuery;
pub async fn execute(deps: &ChannelQueryDeps, _query: ListChannelsQuery) -> DomainResult<Vec<Channel>> {
deps.channel_query.find_all().await
}
#[cfg(test)]
#[path = "tests/list.rs"]
mod tests;

View File

@@ -1,13 +0,0 @@
use domain::models::Channel;
use domain::DomainResult;
use super::deps::ChannelQueryDeps;
use super::queries::ListByOwnerQuery;
pub async fn execute(deps: &ChannelQueryDeps, query: ListByOwnerQuery) -> DomainResult<Vec<Channel>> {
deps.channel_query.find_by_owner(query.owner_id).await
}
#[cfg(test)]
#[path = "tests/list_by_owner.rs"]
mod tests;

View File

@@ -2,15 +2,10 @@ pub mod commands;
pub mod create;
pub mod delete;
pub mod deps;
pub mod get;
pub mod list;
pub mod list_by_owner;
pub mod queries;
pub mod update;
pub use commands::{CreateChannelCommand, DeleteChannelCommand, UpdateChannelCommand};
pub use deps::{ChannelCommandDeps, ChannelQueryDeps};
pub use queries::{GetChannelQuery, ListByOwnerQuery, ListChannelsQuery};
pub use deps::ChannelCommandDeps;
use domain::models::Channel;
use domain::value_objects::{ChannelId, UserId};

View File

@@ -1,11 +0,0 @@
use domain::value_objects::{ChannelId, UserId};
pub struct GetChannelQuery {
pub channel_id: ChannelId,
}
pub struct ListChannelsQuery;
pub struct ListByOwnerQuery {
pub owner_id: UserId,
}

View File

@@ -1,66 +0,0 @@
use std::sync::Arc;
use domain::testing::{InMemoryChannelRepository, NoopEventPublisher};
use domain::value_objects::{ChannelId, UserId};
use crate::channels::commands::CreateChannelCommand;
use crate::channels::deps::{ChannelCommandDeps, ChannelQueryDeps};
use crate::channels::queries::GetChannelQuery;
use crate::channels::{create, get};
fn make_deps() -> (ChannelCommandDeps, ChannelQueryDeps) {
let repo = Arc::new(InMemoryChannelRepository::new());
let cmd_deps = ChannelCommandDeps {
channel_command: repo.clone(),
channel_query: repo.clone(),
event_publisher: Arc::new(NoopEventPublisher::new()),
};
let query_deps = ChannelQueryDeps {
channel_query: repo,
};
(cmd_deps, query_deps)
}
#[tokio::test]
async fn get_existing_channel() {
let (cmd_deps, query_deps) = make_deps();
let channel = create::execute(
&cmd_deps,
CreateChannelCommand {
owner_id: UserId::generate(),
name: "Findable".into(),
timezone: "UTC".into(),
},
)
.await
.unwrap();
let found = get::execute(
&query_deps,
GetChannelQuery {
channel_id: channel.id(),
},
)
.await
.unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().name(), "Findable");
}
#[tokio::test]
async fn get_nonexistent_returns_none() {
let (_, query_deps) = make_deps();
let found = get::execute(
&query_deps,
GetChannelQuery {
channel_id: ChannelId::generate(),
},
)
.await
.unwrap();
assert!(found.is_none());
}

View File

@@ -1,51 +0,0 @@
use std::sync::Arc;
use domain::testing::{InMemoryChannelRepository, NoopEventPublisher};
use domain::value_objects::UserId;
use crate::channels::commands::CreateChannelCommand;
use crate::channels::deps::{ChannelCommandDeps, ChannelQueryDeps};
use crate::channels::queries::ListChannelsQuery;
use crate::channels::{create, list};
fn make_deps() -> (ChannelCommandDeps, ChannelQueryDeps) {
let repo = Arc::new(InMemoryChannelRepository::new());
let cmd_deps = ChannelCommandDeps {
channel_command: repo.clone(),
channel_query: repo.clone(),
event_publisher: Arc::new(NoopEventPublisher::new()),
};
let query_deps = ChannelQueryDeps {
channel_query: repo,
};
(cmd_deps, query_deps)
}
#[tokio::test]
async fn list_empty_returns_empty() {
let (_, query_deps) = make_deps();
let channels = list::execute(&query_deps, ListChannelsQuery).await.unwrap();
assert!(channels.is_empty());
}
#[tokio::test]
async fn list_returns_all_channels() {
let (cmd_deps, query_deps) = make_deps();
for name in ["A", "B", "C"] {
create::execute(
&cmd_deps,
CreateChannelCommand {
owner_id: UserId::generate(),
name: name.into(),
timezone: "UTC".into(),
},
)
.await
.unwrap();
}
let channels = list::execute(&query_deps, ListChannelsQuery).await.unwrap();
assert_eq!(channels.len(), 3);
}

View File

@@ -1,83 +0,0 @@
use std::sync::Arc;
use domain::testing::{InMemoryChannelRepository, NoopEventPublisher};
use domain::value_objects::UserId;
use crate::channels::commands::CreateChannelCommand;
use crate::channels::deps::{ChannelCommandDeps, ChannelQueryDeps};
use crate::channels::queries::ListByOwnerQuery;
use crate::channels::{create, list_by_owner};
fn make_deps() -> (ChannelCommandDeps, ChannelQueryDeps) {
let repo = Arc::new(InMemoryChannelRepository::new());
let cmd_deps = ChannelCommandDeps {
channel_command: repo.clone(),
channel_query: repo.clone(),
event_publisher: Arc::new(NoopEventPublisher::new()),
};
let query_deps = ChannelQueryDeps {
channel_query: repo,
};
(cmd_deps, query_deps)
}
#[tokio::test]
async fn filters_by_owner() {
let (cmd_deps, query_deps) = make_deps();
let alice = UserId::generate();
let bob = UserId::generate();
// Alice: 2 channels
for name in ["Alice-1", "Alice-2"] {
create::execute(
&cmd_deps,
CreateChannelCommand {
owner_id: alice,
name: name.into(),
timezone: "UTC".into(),
},
)
.await
.unwrap();
}
// Bob: 1 channel
create::execute(
&cmd_deps,
CreateChannelCommand {
owner_id: bob,
name: "Bob-1".into(),
timezone: "UTC".into(),
},
)
.await
.unwrap();
let alice_channels = list_by_owner::execute(
&query_deps,
ListByOwnerQuery {
owner_id: alice,
},
)
.await
.unwrap();
assert_eq!(alice_channels.len(), 2);
assert!(alice_channels.iter().all(|c| c.owner_id() == alice));
}
#[tokio::test]
async fn no_channels_returns_empty() {
let (_, query_deps) = make_deps();
let channels = list_by_owner::execute(
&query_deps,
ListByOwnerQuery {
owner_id: UserId::generate(),
},
)
.await
.unwrap();
assert!(channels.is_empty());
}

View File

@@ -5,12 +5,6 @@ pub struct SaveSnapshotCommand {
pub label: Option<String>,
}
pub struct PatchLabelCommand {
pub channel_id: ChannelId,
pub snapshot_id: SnapshotId,
pub label: Option<String>,
}
pub struct RestoreSnapshotCommand {
pub channel_id: ChannelId,
pub snapshot_id: SnapshotId,

View File

@@ -1,14 +0,0 @@
use domain::models::ChannelConfigSnapshot;
use domain::DomainResult;
use super::deps::ConfigSnapshotDeps;
use super::queries::GetSnapshotQuery;
pub async fn execute(
deps: &ConfigSnapshotDeps,
query: GetSnapshotQuery,
) -> DomainResult<Option<ChannelConfigSnapshot>> {
deps.channel_query
.get_config_snapshot(query.channel_id, query.snapshot_id)
.await
}

View File

@@ -1,16 +0,0 @@
use domain::models::ChannelConfigSnapshot;
use domain::DomainResult;
use super::deps::ConfigSnapshotDeps;
use super::queries::ListSnapshotsQuery;
pub async fn execute(
deps: &ConfigSnapshotDeps,
query: ListSnapshotsQuery,
) -> DomainResult<Vec<ChannelConfigSnapshot>> {
deps.channel_query.list_config_snapshots(query.channel_id).await
}
#[cfg(test)]
#[path = "tests/list.rs"]
mod tests;

View File

@@ -1,12 +1,7 @@
pub mod commands;
pub mod deps;
pub mod get;
pub mod list;
pub mod patch_label;
pub mod queries;
pub mod restore;
pub mod save;
pub use commands::{PatchLabelCommand, RestoreSnapshotCommand, SaveSnapshotCommand};
pub use commands::{RestoreSnapshotCommand, SaveSnapshotCommand};
pub use deps::ConfigSnapshotDeps;
pub use queries::{GetSnapshotQuery, ListSnapshotsQuery};

View File

@@ -1,14 +0,0 @@
use domain::models::ChannelConfigSnapshot;
use domain::DomainResult;
use super::commands::PatchLabelCommand;
use super::deps::ConfigSnapshotDeps;
pub async fn execute(
deps: &ConfigSnapshotDeps,
cmd: PatchLabelCommand,
) -> DomainResult<Option<ChannelConfigSnapshot>> {
deps.channel_command
.patch_config_snapshot_label(cmd.channel_id, cmd.snapshot_id, cmd.label)
.await
}

View File

@@ -1,10 +0,0 @@
use domain::value_objects::{ChannelId, SnapshotId};
pub struct ListSnapshotsQuery {
pub channel_id: ChannelId,
}
pub struct GetSnapshotQuery {
pub channel_id: ChannelId,
pub snapshot_id: SnapshotId,
}

View File

@@ -1,77 +0,0 @@
use std::sync::Arc;
use domain::models::Channel;
use domain::testing::InMemoryChannelRepository;
use domain::value_objects::UserId;
use crate::config_snapshots::commands::SaveSnapshotCommand;
use crate::config_snapshots::deps::ConfigSnapshotDeps;
use crate::config_snapshots::queries::ListSnapshotsQuery;
use crate::config_snapshots::{list, save};
fn make_deps() -> (ConfigSnapshotDeps, Arc<InMemoryChannelRepository>) {
let repo = Arc::new(InMemoryChannelRepository::new());
let deps = ConfigSnapshotDeps {
channel_command: repo.clone(),
channel_query: repo.clone(),
};
(deps, repo)
}
async fn seed_channel(repo: &InMemoryChannelRepository) -> Channel {
let channel = Channel::new(UserId::generate(), "Test Channel", "UTC");
repo.channels
.lock()
.unwrap()
.insert(channel.id(), channel.clone());
channel
}
#[tokio::test]
async fn list_empty() {
let (deps, repo) = make_deps();
let channel = seed_channel(&repo).await;
let snaps = list::execute(
&deps,
ListSnapshotsQuery {
channel_id: channel.id(),
},
)
.await
.unwrap();
assert!(snaps.is_empty());
}
#[tokio::test]
async fn list_returns_saved_snapshots() {
let (deps, repo) = make_deps();
let channel = seed_channel(&repo).await;
for label in ["first", "second"] {
save::execute(
&deps,
SaveSnapshotCommand {
channel_id: channel.id(),
label: Some(label.into()),
},
)
.await
.unwrap();
}
let snaps = list::execute(
&deps,
ListSnapshotsQuery {
channel_id: channel.id(),
},
)
.await
.unwrap();
assert_eq!(snaps.len(), 2);
// Newest first
assert_eq!(snaps[0].version_num(), 2);
assert_eq!(snaps[1].version_num(), 1);
}

View File

@@ -9,7 +9,3 @@ pub struct LibraryCommandDeps {
pub provider_registry: Arc<dyn IProviderRegistry>,
pub event_publisher: Arc<dyn EventPublisher>,
}
pub struct LibraryQueryDeps {
pub library_query: Arc<dyn LibraryQuery>,
}

View File

@@ -1,16 +0,0 @@
use domain::models::MediaItem;
use domain::DomainResult;
use super::deps::LibraryQueryDeps;
use super::queries::GetItemQuery;
pub async fn execute(
deps: &LibraryQueryDeps,
query: GetItemQuery,
) -> DomainResult<Option<MediaItem>> {
deps.library_query.get_by_id(&query.item_id).await
}
#[cfg(test)]
#[path = "tests/get_item.rs"]
mod tests;

View File

@@ -1,16 +0,0 @@
use domain::models::LibrarySyncLogEntry;
use domain::DomainResult;
use super::deps::LibraryQueryDeps;
use super::queries::GetSyncStatusQuery;
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

@@ -1,18 +0,0 @@
use domain::models::LibraryCollection;
use domain::DomainResult;
use super::deps::LibraryQueryDeps;
use super::queries::ListCollectionsQuery;
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

@@ -1,21 +0,0 @@
use domain::DomainResult;
use super::deps::LibraryQueryDeps;
use super::parse_content_type;
use super::queries::ListGenresQuery;
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
}
#[cfg(test)]
#[path = "tests/list_genres.rs"]
mod tests;

View File

@@ -1,18 +0,0 @@
use domain::models::SeasonSummary;
use domain::DomainResult;
use super::deps::LibraryQueryDeps;
use super::queries::ListSeasonsQuery;
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

@@ -1,22 +0,0 @@
use domain::models::ShowSummary;
use domain::DomainResult;
use super::deps::LibraryQueryDeps;
use super::queries::ListShowsQuery;
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

@@ -1,26 +1,17 @@
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,
};
pub use deps::LibraryCommandDeps;
pub use queries::SearchItemsQuery;
use domain::errors::{DomainError, DomainResult};
use domain::value_objects::ContentType;
pub(crate) fn parse_content_type(s: &str) -> DomainResult<ContentType> {
pub fn parse_content_type(s: &str) -> DomainResult<ContentType> {
match s {
"movie" => Ok(ContentType::Movie),
"episode" => Ok(ContentType::Episode),

View File

@@ -10,29 +10,3 @@ pub struct SearchItemsQuery {
pub offset: u32,
pub limit: u32,
}
pub struct ListCollectionsQuery {
pub provider_id: Option<String>,
}
pub struct ListShowsQuery {
pub provider_id: Option<String>,
pub search_term: Option<String>,
pub genres: Vec<String>,
}
pub struct ListSeasonsQuery {
pub series_name: String,
pub provider_id: Option<String>,
}
pub struct ListGenresQuery {
pub content_type: Option<String>,
pub provider_id: Option<String>,
}
pub struct GetItemQuery {
pub item_id: String,
}
pub struct GetSyncStatusQuery;

View File

@@ -2,12 +2,12 @@ use domain::DomainResult;
use domain::models::MediaItem;
use domain::value_objects::LibrarySearchFilter;
use super::deps::LibraryQueryDeps;
use super::deps::LibraryCommandDeps;
use super::parse_content_type;
use super::queries::SearchItemsQuery;
pub async fn execute(
deps: &LibraryQueryDeps,
deps: &LibraryCommandDeps,
query: SearchItemsQuery,
) -> DomainResult<(Vec<MediaItem>, u32)> {
let content_type = query

View File

@@ -1,50 +0,0 @@
use domain::models::MediaItem;
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 = MediaItem::new_library("test", "m1", "Die Hard", ContentType::Movie, 7800, "2026-01-01");
repo.items
.lock()
.unwrap()
.insert(item.id().value().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

@@ -1,41 +0,0 @@
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

@@ -11,9 +11,8 @@ use domain::ports::{
use domain::testing::{InMemoryLibraryRepository, NoopEventPublisher, NoopLibrarySync};
use domain::value_objects::{ContentType, MediaFilter, MediaItemId};
use crate::library::deps::{LibraryCommandDeps, LibraryQueryDeps};
use crate::library::deps::LibraryCommandDeps;
/// Minimal IProviderRegistry for library tests.
pub(crate) struct TestProviderRegistry;
#[async_trait]
@@ -83,24 +82,14 @@ impl IProviderRegistry for TestProviderRegistry {
}
}
/// 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>,
) {
pub(crate) fn make_deps() -> (LibraryCommandDeps, Arc<InMemoryLibraryRepository>) {
let repo = Arc::new(InMemoryLibraryRepository::new());
let cmd_deps = LibraryCommandDeps {
let 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)
(deps, repo)
}

View File

@@ -1,87 +0,0 @@
use domain::models::{MediaItem, MediaItemRow};
use domain::value_objects::{ContentType, MediaItemId, MediaRole};
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 = MediaItem::from_persistence(MediaItemRow {
id: MediaItemId::new("test::m1"),
provider_id: "test".into(),
external_id: "m1".into(),
title: "Die Hard".into(),
content_type: ContentType::Movie,
duration_secs: 7800,
description: None,
series_name: None,
season_number: None,
episode_number: None,
year: None,
genres: vec![],
tags: vec![],
collection_id: Some("col-1".into()),
collection_name: Some("Movies".into()),
collection_type: Some("movies".into()),
thumbnail_url: None,
synced_at: Some("2026-01-01".into()),
role: MediaRole::default(),
});
store.insert(item.id().value().to_string(), item);
let item2 = MediaItem::from_persistence(MediaItemRow {
id: MediaItemId::new("test::e1"),
provider_id: "test".into(),
external_id: "e1".into(),
title: "BB S01E01".into(),
content_type: ContentType::Episode,
duration_secs: 2700,
description: None,
series_name: Some("Breaking Bad".into()),
season_number: Some(1),
episode_number: Some(1),
year: None,
genres: vec![],
tags: vec![],
collection_id: Some("col-2".into()),
collection_name: Some("TV Shows".into()),
collection_type: Some("tvshows".into()),
thumbnail_url: None,
synced_at: Some("2026-01-01".into()),
role: MediaRole::default(),
});
store.insert(item2.id().value().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

@@ -1,112 +0,0 @@
use domain::models::{MediaItem, MediaItemRow};
use domain::value_objects::{ContentType, MediaItemId, MediaRole};
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 = MediaItem::from_persistence(MediaItemRow {
id: MediaItemId::new("test::m1"),
provider_id: "test".into(),
external_id: "m1".into(),
title: "Die Hard".into(),
content_type: ContentType::Movie,
duration_secs: 7800,
description: None,
series_name: None,
season_number: None,
episode_number: None,
year: None,
genres: vec!["Action".into(), "Thriller".into()],
tags: vec![],
collection_id: None,
collection_name: None,
collection_type: None,
thumbnail_url: None,
synced_at: Some("2026-01-01".into()),
role: MediaRole::default(),
});
let item2 = MediaItem::from_persistence(MediaItemRow {
id: MediaItemId::new("test::m2"),
provider_id: "test".into(),
external_id: "m2".into(),
title: "Alien".into(),
content_type: ContentType::Movie,
duration_secs: 7020,
description: None,
series_name: None,
season_number: None,
episode_number: None,
year: None,
genres: vec!["Sci-Fi".into(), "Action".into()],
tags: vec![],
collection_id: None,
collection_name: None,
collection_type: None,
thumbnail_url: None,
synced_at: Some("2026-01-01".into()),
role: MediaRole::default(),
});
store.insert(item1.id().value().to_string(), item1);
store.insert(item2.id().value().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

@@ -1,79 +0,0 @@
use domain::models::{MediaItem, MediaItemRow};
use domain::value_objects::{ContentType, MediaItemId, MediaRole};
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 = MediaItem::from_persistence(MediaItemRow {
id: MediaItemId::new(format!("test::e{i}")),
provider_id: "test".into(),
external_id: format!("e{i}"),
title: format!("BB S{season:02}E{:02}", i + 1),
content_type: ContentType::Episode,
duration_secs: 2700,
description: None,
series_name: Some("Breaking Bad".into()),
season_number: Some(*season),
episode_number: Some(i as u32 + 1),
year: None,
genres: vec![],
tags: vec![],
collection_id: None,
collection_name: None,
collection_type: None,
thumbnail_url: None,
synced_at: Some("2026-01-01".into()),
role: MediaRole::default(),
});
store.insert(item.id().value().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

@@ -1,103 +0,0 @@
use domain::models::{MediaItem, MediaItemRow};
use domain::value_objects::{ContentType, MediaItemId, MediaRole};
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 = MediaItem::from_persistence(MediaItemRow {
id: MediaItemId::new(format!("test::e{i}")),
provider_id: "test".into(),
external_id: format!("e{i}"),
title: format!("{series} S{season:02}E{i:02}"),
content_type: ContentType::Episode,
duration_secs: 2700,
description: None,
series_name: Some(series.to_string()),
season_number: Some(*season),
episode_number: Some(i as u32 + 1),
year: None,
genres: vec![],
tags: vec![],
collection_id: None,
collection_name: None,
collection_type: None,
thumbnail_url: None,
synced_at: Some("2026-01-01".into()),
role: MediaRole::default(),
});
store.insert(item.id().value().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

@@ -93,11 +93,11 @@ fn seed_items_with_genres(repo: &std::sync::Arc<domain::testing::InMemoryLibrary
#[tokio::test]
async fn search_empty_filter_returns_all() {
let (_cmd, query, repo) = helpers::make_deps();
let (deps, repo) = helpers::make_deps();
seed_items(&repo);
let (items, total) = search::execute(
&query,
&deps,
SearchItemsQuery {
provider_id: None,
content_type: None,
@@ -120,11 +120,11 @@ async fn search_empty_filter_returns_all() {
#[tokio::test]
async fn search_by_content_type() {
let (_cmd, query, repo) = helpers::make_deps();
let (deps, repo) = helpers::make_deps();
seed_items(&repo);
let (items, total) = search::execute(
&query,
&deps,
SearchItemsQuery {
provider_id: None,
content_type: Some("movie".into()),
@@ -147,11 +147,11 @@ async fn search_by_content_type() {
#[tokio::test]
async fn search_by_genre() {
let (_cmd, query, repo) = helpers::make_deps();
let (deps, repo) = helpers::make_deps();
seed_items_with_genres(&repo);
let (items, total) = search::execute(
&query,
&deps,
SearchItemsQuery {
provider_id: None,
content_type: None,
@@ -175,11 +175,11 @@ async fn search_by_genre() {
#[tokio::test]
async fn search_by_search_term() {
let (_cmd, query, repo) = helpers::make_deps();
let (deps, repo) = helpers::make_deps();
seed_items(&repo);
let (items, total) = search::execute(
&query,
&deps,
SearchItemsQuery {
provider_id: None,
content_type: None,
@@ -203,10 +203,10 @@ async fn search_by_search_term() {
#[tokio::test]
async fn search_invalid_content_type_errors() {
let (_cmd, query, _repo) = helpers::make_deps();
let (deps, _repo) = helpers::make_deps();
let result = search::execute(
&query,
&deps,
SearchItemsQuery {
provider_id: None,
content_type: Some("bogus".into()),
@@ -227,11 +227,11 @@ async fn search_invalid_content_type_errors() {
#[tokio::test]
async fn search_pagination() {
let (_cmd, query, repo) = helpers::make_deps();
let (deps, repo) = helpers::make_deps();
seed_items(&repo);
let (items, total) = search::execute(
&query,
&deps,
SearchItemsQuery {
provider_id: None,
content_type: None,

View File

@@ -6,10 +6,10 @@ mod helpers;
#[tokio::test]
async fn trigger_sync_returns_provider_ids() {
let (cmd, _query, _repo) = helpers::make_deps();
let (deps, _repo) = helpers::make_deps();
let ids = sync::execute(
&cmd,
&deps,
TriggerSyncCommand { provider_id: None },
)
.await
@@ -20,10 +20,10 @@ async fn trigger_sync_returns_provider_ids() {
#[tokio::test]
async fn trigger_sync_specific_provider() {
let (cmd, _query, _repo) = helpers::make_deps();
let (deps, _repo) = helpers::make_deps();
let ids = sync::execute(
&cmd,
&deps,
TriggerSyncCommand {
provider_id: Some("test".into()),
},
@@ -36,16 +36,15 @@ async fn trigger_sync_specific_provider() {
#[tokio::test]
async fn trigger_sync_while_running_errors() {
let (cmd, _query, repo) = helpers::make_deps();
let (deps, 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")
repo.items.lock().unwrap();
let _log_id = domain::ports::LibraryCommand::log_sync_start(&*deps.library_command, "test")
.await
.unwrap();
let result = sync::execute(
&cmd,
&deps,
TriggerSyncCommand { provider_id: None },
)
.await;

View File

@@ -4,7 +4,3 @@ pub struct UpsertProviderCommand {
pub config: serde_json::Value,
pub enabled: bool,
}
pub struct DeleteProviderCommand {
pub id: String,
}

View File

@@ -1,8 +0,0 @@
use domain::DomainResult;
use super::commands::DeleteProviderCommand;
use super::deps::ProviderDeps;
pub async fn execute(deps: &ProviderDeps, cmd: DeleteProviderCommand) -> DomainResult<()> {
deps.provider_config_command.delete(&cmd.id).await
}

View File

@@ -1,12 +0,0 @@
use domain::models::ProviderConfigRow;
use domain::DomainResult;
use super::deps::ProviderDeps;
use super::queries::GetProviderQuery;
pub async fn execute(
deps: &ProviderDeps,
query: GetProviderQuery,
) -> DomainResult<Option<ProviderConfigRow>> {
deps.provider_config_query.get_by_id(&query.id).await
}

View File

@@ -1,16 +0,0 @@
use domain::models::ProviderConfigRow;
use domain::DomainResult;
use super::deps::ProviderDeps;
use super::queries::ListProvidersQuery;
pub async fn execute(
deps: &ProviderDeps,
_query: ListProvidersQuery,
) -> DomainResult<Vec<ProviderConfigRow>> {
deps.provider_config_query.get_all().await
}
#[cfg(test)]
#[path = "tests/list.rs"]
mod tests;

View File

@@ -1,11 +1,6 @@
pub mod commands;
pub mod delete;
pub mod deps;
pub mod get;
pub mod list;
pub mod queries;
pub mod upsert;
pub use commands::{DeleteProviderCommand, UpsertProviderCommand};
pub use commands::UpsertProviderCommand;
pub use deps::ProviderDeps;
pub use queries::{GetProviderQuery, ListProvidersQuery};

View File

@@ -1,5 +0,0 @@
pub struct ListProvidersQuery;
pub struct GetProviderQuery {
pub id: String,
}

View File

@@ -1,45 +0,0 @@
use std::sync::Arc;
use domain::testing::InMemoryProviderConfig;
use crate::providers::commands::UpsertProviderCommand;
use crate::providers::deps::ProviderDeps;
use crate::providers::queries::ListProvidersQuery;
use crate::providers::{list, upsert};
fn make_deps() -> ProviderDeps {
let repo = Arc::new(InMemoryProviderConfig::new());
ProviderDeps {
provider_config_command: repo.clone(),
provider_config_query: repo,
}
}
#[tokio::test]
async fn list_empty() {
let deps = make_deps();
let providers = list::execute(&deps, ListProvidersQuery).await.unwrap();
assert!(providers.is_empty());
}
#[tokio::test]
async fn list_returns_all_providers() {
let deps = make_deps();
for id in ["jf-1", "local-1"] {
upsert::execute(
&deps,
UpsertProviderCommand {
id: id.into(),
provider_type: "jellyfin".into(),
config: serde_json::json!({}),
enabled: true,
},
)
.await
.unwrap();
}
let providers = list::execute(&deps, ListProvidersQuery).await.unwrap();
assert_eq!(providers.len(), 2);
}

View File

@@ -3,8 +3,3 @@ use uuid::Uuid;
pub struct GenerateScheduleCommand {
pub channel_id: Uuid,
}
pub struct DeleteSchedulesAfterCommand {
pub channel_id: Uuid,
pub target_generation: u32,
}

View File

@@ -1,19 +0,0 @@
use domain::value_objects::ChannelId;
use domain::DomainResult;
use super::commands::DeleteSchedulesAfterCommand;
use super::deps::ScheduleDeps;
pub async fn execute(
deps: &ScheduleDeps,
cmd: DeleteSchedulesAfterCommand,
) -> DomainResult<()> {
let channel_id = ChannelId::from(cmd.channel_id);
deps.schedule_command
.delete_schedules_after(channel_id, cmd.target_generation)
.await
}
#[cfg(test)]
#[path = "tests/delete_after.rs"]
mod tests;

View File

@@ -1,20 +0,0 @@
use chrono::Utc;
use domain::models::GeneratedSchedule;
use domain::value_objects::ChannelId;
use domain::DomainResult;
use super::deps::ScheduleDeps;
use super::queries::GetActiveScheduleQuery;
pub async fn execute(
deps: &ScheduleDeps,
query: GetActiveScheduleQuery,
) -> DomainResult<Option<GeneratedSchedule>> {
let channel_id = ChannelId::from(query.channel_id);
deps.schedule_query.find_active(channel_id, Utc::now()).await
}
#[cfg(test)]
#[path = "tests/get_active.rs"]
mod tests;

View File

@@ -1,20 +0,0 @@
use domain::models::GeneratedSchedule;
use domain::value_objects::ChannelId;
use domain::DomainResult;
use super::deps::ScheduleDeps;
use super::queries::ListHistoryQuery;
pub async fn execute(
deps: &ScheduleDeps,
query: ListHistoryQuery,
) -> DomainResult<Vec<GeneratedSchedule>> {
let channel_id = ChannelId::from(query.channel_id);
deps.schedule_query
.list_schedule_history(channel_id)
.await
}
#[cfg(test)]
#[path = "tests/list_history.rs"]
mod tests;

View File

@@ -1,17 +1,11 @@
pub mod commands;
pub mod delete_after;
pub mod deps;
pub mod generate;
pub mod get_active;
pub mod get_current_broadcast;
pub mod get_epg;
pub mod get_stream_url;
pub mod list_history;
pub mod queries;
pub use commands::{DeleteSchedulesAfterCommand, GenerateScheduleCommand};
pub use commands::GenerateScheduleCommand;
pub use deps::ScheduleDeps;
pub use queries::{
GetActiveScheduleQuery, GetCurrentBroadcastQuery, GetEpgQuery, GetStreamUrlQuery,
ListHistoryQuery,
};
pub use queries::{GetCurrentBroadcastQuery, GetEpgQuery, GetStreamUrlQuery};

View File

@@ -1,9 +1,5 @@
use uuid::Uuid;
pub struct GetActiveScheduleQuery {
pub channel_id: Uuid,
}
pub struct GetCurrentBroadcastQuery {
pub channel_id: Uuid,
}
@@ -15,7 +11,3 @@ pub struct GetEpgQuery {
pub struct GetStreamUrlQuery {
pub channel_id: Uuid,
}
pub struct ListHistoryQuery {
pub channel_id: Uuid,
}

View File

@@ -1,64 +0,0 @@
use domain::models::{Channel, GeneratedSchedule};
use domain::value_objects::UserId;
use crate::schedule::commands::DeleteSchedulesAfterCommand;
use crate::schedule::delete_after;
use crate::schedule::queries::ListHistoryQuery;
use crate::schedule::list_history;
#[path = "helpers.rs"]
mod helpers;
use helpers::make_schedule_deps;
#[tokio::test]
async fn delete_after_removes_later_generations() {
let (deps, channel_repo, schedule_repo) = make_schedule_deps();
let channel = Channel::new(UserId::generate(), "Cleanup", "UTC");
let channel_id = channel.id();
channel_repo
.channels
.lock()
.unwrap()
.insert(channel_id, channel);
// Manually insert schedules with different generations.
let now = chrono::Utc::now();
for generation in 1..=3 {
let sched = GeneratedSchedule::new(
channel_id,
now,
now + chrono::Duration::hours(24),
generation,
vec![],
);
schedule_repo
.schedules
.lock()
.unwrap()
.insert(sched.id(), sched);
}
// Delete generations > 1.
delete_after::execute(
&deps,
DeleteSchedulesAfterCommand {
channel_id: channel_id.value(),
target_generation: 1,
},
)
.await
.unwrap();
let remaining = list_history::execute(
&deps,
ListHistoryQuery {
channel_id: channel_id.value(),
},
)
.await
.unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].generation(), 1);
}

View File

@@ -1,22 +0,0 @@
use crate::schedule::get_active;
use crate::schedule::queries::GetActiveScheduleQuery;
#[path = "helpers.rs"]
mod helpers;
use helpers::make_schedule_deps;
#[tokio::test]
async fn returns_none_when_no_schedule_exists() {
let (deps, _, _) = make_schedule_deps();
let result = get_active::execute(
&deps,
GetActiveScheduleQuery {
channel_id: uuid::Uuid::new_v4(),
},
)
.await
.unwrap();
assert!(result.is_none());
}

View File

@@ -1,22 +0,0 @@
use crate::schedule::list_history;
use crate::schedule::queries::ListHistoryQuery;
#[path = "helpers.rs"]
mod helpers;
use helpers::make_schedule_deps;
#[tokio::test]
async fn returns_empty_for_new_channel() {
let (deps, _, _) = make_schedule_deps();
let result = list_history::execute(
&deps,
ListHistoryQuery {
channel_id: uuid::Uuid::new_v4(),
},
)
.await
.unwrap();
assert!(result.is_empty());
}