From 848d4752e2c036654328dc9514ffe96e41389b53 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 12 Jul 2026 01:41:23 +0200 Subject: [PATCH] domain testing: InMemory repos + Noops behind test-helpers --- crates/domain/src/lib.rs | 2 + crates/domain/src/testing/in_memory.rs | 785 +++++++++++++++++++++++++ crates/domain/src/testing/mod.rs | 11 + crates/domain/src/testing/noops.rs | 137 +++++ 4 files changed, 935 insertions(+) create mode 100644 crates/domain/src/testing/in_memory.rs create mode 100644 crates/domain/src/testing/mod.rs create mode 100644 crates/domain/src/testing/noops.rs diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 7fff863..ee0cb2f 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -3,6 +3,8 @@ pub mod events; pub mod models; pub mod ports; pub mod services; +#[cfg(feature = "test-helpers")] +pub mod testing; pub mod value_objects; pub use errors::{DomainError, DomainResult}; diff --git a/crates/domain/src/testing/in_memory.rs b/crates/domain/src/testing/in_memory.rs new file mode 100644 index 0000000..1897c8c --- /dev/null +++ b/crates/domain/src/testing/in_memory.rs @@ -0,0 +1,785 @@ +//! InMemory implementations of all domain port traits. +//! +//! Each struct uses `Mutex>` internally. One struct +//! implements both the Command and Query traits for a given aggregate. + +use std::collections::HashMap; +use std::sync::Mutex; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use crate::errors::DomainResult; +use crate::models::{ + ActivityEvent, Channel, ChannelConfigSnapshot, GeneratedSchedule, LibraryCollection, + LibraryItem, LibrarySyncLogEntry, LibrarySyncResult, PlaybackRecord, ProviderConfigRow, + ScheduleConfig, SeasonSummary, ShowSummary, +}; +use crate::ports::{ + ActivityLogCommand, ActivityLogQuery, AppSettingsRepository, ChannelCommand, ChannelQuery, + LibraryCommand, LibraryQuery, ProviderConfigCommand, ProviderConfigQuery, ScheduleCommand, + ScheduleQuery, TranscodeSettingsRepository, UserCommand, UserQuery, +}; +use crate::value_objects::{ + BlockId, ChannelId, ContentType, LibrarySearchFilter, MediaItemId, ScheduleId, UserId, +}; + +// ============================================================================ +// InMemoryUserRepository +// ============================================================================ + +pub struct InMemoryUserRepository { + pub store: Mutex>, +} + +impl InMemoryUserRepository { + pub fn new() -> Self { + Self { + store: Mutex::new(HashMap::new()), + } + } +} + +#[async_trait] +impl UserCommand for InMemoryUserRepository { + async fn save(&self, user: &crate::models::User) -> DomainResult<()> { + self.store + .lock() + .unwrap() + .insert(user.id().value(), user.clone()); + Ok(()) + } + + async fn delete(&self, id: UserId) -> DomainResult<()> { + self.store.lock().unwrap().remove(&id.value()); + Ok(()) + } +} + +#[async_trait] +impl UserQuery for InMemoryUserRepository { + async fn find_by_id(&self, id: UserId) -> DomainResult> { + Ok(self.store.lock().unwrap().get(&id.value()).cloned()) + } + + async fn find_by_subject(&self, subject: &str) -> DomainResult> { + let store = self.store.lock().unwrap(); + Ok(store.values().find(|u| u.subject() == subject).cloned()) + } + + async fn find_by_email(&self, email: &str) -> DomainResult> { + let store = self.store.lock().unwrap(); + Ok(store + .values() + .find(|u| u.email().as_ref() == email) + .cloned()) + } + + async fn count_users(&self) -> DomainResult { + Ok(self.store.lock().unwrap().len() as u64) + } +} + +// ============================================================================ +// InMemoryChannelRepository +// ============================================================================ + +pub struct InMemoryChannelRepository { + pub channels: Mutex>, + pub snapshots: Mutex>, +} + +impl InMemoryChannelRepository { + pub fn new() -> Self { + Self { + channels: Mutex::new(HashMap::new()), + snapshots: Mutex::new(Vec::new()), + } + } +} + +#[async_trait] +impl ChannelCommand for InMemoryChannelRepository { + async fn save(&self, channel: &Channel) -> DomainResult<()> { + self.channels + .lock() + .unwrap() + .insert(channel.id().value(), channel.clone()); + Ok(()) + } + + async fn delete(&self, id: ChannelId) -> DomainResult<()> { + self.channels.lock().unwrap().remove(&id.value()); + Ok(()) + } + + async fn save_config_snapshot( + &self, + channel_id: ChannelId, + config: &ScheduleConfig, + label: Option, + ) -> DomainResult { + let snaps = self.snapshots.lock().unwrap(); + let version = snaps + .iter() + .filter(|s| s.channel_id() == channel_id) + .map(|s| s.version_num()) + .max() + .unwrap_or(0) + + 1; + drop(snaps); + + let mut snap = ChannelConfigSnapshot::new(channel_id, config.clone(), version); + if let Some(lbl) = label { + snap = ChannelConfigSnapshot::from_persistence( + snap.id(), + snap.channel_id(), + snap.config().clone(), + snap.version_num(), + Some(lbl), + snap.created_at(), + ); + } + self.snapshots.lock().unwrap().push(snap.clone()); + Ok(snap) + } + + async fn patch_config_snapshot_label( + &self, + channel_id: ChannelId, + snapshot_id: Uuid, + label: Option, + ) -> DomainResult> { + let mut snaps = self.snapshots.lock().unwrap(); + if let Some(pos) = snaps + .iter() + .position(|s| s.channel_id() == channel_id && s.id() == snapshot_id) + { + let old = &snaps[pos]; + let updated = ChannelConfigSnapshot::from_persistence( + old.id(), + old.channel_id(), + old.config().clone(), + old.version_num(), + label, + old.created_at(), + ); + snaps[pos] = updated.clone(); + Ok(Some(updated)) + } else { + Ok(None) + } + } +} + +#[async_trait] +impl ChannelQuery for InMemoryChannelRepository { + async fn find_by_id(&self, id: ChannelId) -> DomainResult> { + Ok(self.channels.lock().unwrap().get(&id.value()).cloned()) + } + + async fn find_by_owner(&self, owner_id: UserId) -> DomainResult> { + let store = self.channels.lock().unwrap(); + Ok(store + .values() + .filter(|c| c.owner_id() == owner_id) + .cloned() + .collect()) + } + + async fn find_all(&self) -> DomainResult> { + Ok(self.channels.lock().unwrap().values().cloned().collect()) + } + + async fn find_auto_schedule_enabled(&self) -> DomainResult> { + let store = self.channels.lock().unwrap(); + Ok(store + .values() + .filter(|c| c.auto_schedule()) + .cloned() + .collect()) + } + + async fn list_config_snapshots( + &self, + channel_id: ChannelId, + ) -> DomainResult> { + let snaps = self.snapshots.lock().unwrap(); + let mut result: Vec<_> = snaps + .iter() + .filter(|s| s.channel_id() == channel_id) + .cloned() + .collect(); + result.sort_by(|a, b| b.version_num().cmp(&a.version_num())); + Ok(result) + } + + async fn get_config_snapshot( + &self, + channel_id: ChannelId, + snapshot_id: Uuid, + ) -> DomainResult> { + let snaps = self.snapshots.lock().unwrap(); + Ok(snaps + .iter() + .find(|s| s.channel_id() == channel_id && s.id() == snapshot_id) + .cloned()) + } +} + +// ============================================================================ +// InMemoryScheduleRepository +// ============================================================================ + +pub struct InMemoryScheduleRepository { + pub schedules: Mutex>, + pub playback_records: Mutex>, +} + +impl InMemoryScheduleRepository { + pub fn new() -> Self { + Self { + schedules: Mutex::new(HashMap::new()), + playback_records: Mutex::new(Vec::new()), + } + } +} + +#[async_trait] +impl ScheduleCommand for InMemoryScheduleRepository { + async fn save(&self, schedule: &GeneratedSchedule) -> DomainResult<()> { + self.schedules + .lock() + .unwrap() + .insert(schedule.id().value(), schedule.clone()); + Ok(()) + } + + async fn save_playback_record(&self, record: &PlaybackRecord) -> DomainResult<()> { + self.playback_records.lock().unwrap().push(record.clone()); + Ok(()) + } + + async fn delete_schedules_after( + &self, + channel_id: ChannelId, + target_generation: u32, + ) -> DomainResult<()> { + self.schedules.lock().unwrap().retain(|_, s| { + !(s.channel_id() == channel_id && s.generation() > target_generation) + }); + self.playback_records.lock().unwrap().retain(|r| { + !(r.channel_id() == channel_id && r.generation() > target_generation) + }); + Ok(()) + } +} + +#[async_trait] +impl ScheduleQuery for InMemoryScheduleRepository { + async fn find_active( + &self, + channel_id: ChannelId, + at: DateTime, + ) -> DomainResult> { + let store = self.schedules.lock().unwrap(); + Ok(store + .values() + .find(|s| s.channel_id() == channel_id && s.is_active_at(at)) + .cloned()) + } + + async fn find_latest( + &self, + channel_id: ChannelId, + ) -> DomainResult> { + let store = self.schedules.lock().unwrap(); + Ok(store + .values() + .filter(|s| s.channel_id() == channel_id) + .max_by_key(|s| s.generation()) + .cloned()) + } + + async fn find_playback_history( + &self, + channel_id: ChannelId, + ) -> DomainResult> { + let records = self.playback_records.lock().unwrap(); + Ok(records + .iter() + .filter(|r| r.channel_id() == channel_id) + .cloned() + .collect()) + } + + async fn find_last_slot_per_block( + &self, + channel_id: ChannelId, + ) -> DomainResult> { + let store = self.schedules.lock().unwrap(); + let mut result: HashMap, MediaItemId)> = HashMap::new(); + for sched in store.values().filter(|s| s.channel_id() == channel_id) { + for slot in sched.slots() { + let block_id = slot.source_block_id(); + let should_insert = result + .get(&block_id) + .map_or(true, |(prev_time, _)| slot.start_at() > *prev_time); + if should_insert { + result.insert(block_id, (slot.start_at(), slot.item().id().clone())); + } + } + } + Ok(result + .into_iter() + .map(|(k, (_, v))| (k, v)) + .collect()) + } + + async fn list_schedule_history( + &self, + channel_id: ChannelId, + ) -> DomainResult> { + let store = self.schedules.lock().unwrap(); + let mut result: Vec<_> = store + .values() + .filter(|s| s.channel_id() == channel_id) + .cloned() + .collect(); + result.sort_by(|a, b| b.generation().cmp(&a.generation())); + Ok(result) + } + + async fn get_schedule_by_id( + &self, + channel_id: ChannelId, + schedule_id: ScheduleId, + ) -> DomainResult> { + let store = self.schedules.lock().unwrap(); + Ok(store + .values() + .find(|s| s.channel_id() == channel_id && s.id() == schedule_id) + .cloned()) + } +} + +// ============================================================================ +// InMemoryLibraryRepository +// ============================================================================ + +pub struct InMemoryLibraryRepository { + pub items: Mutex>, + pub sync_logs: Mutex>, + next_log_id: Mutex, +} + +impl InMemoryLibraryRepository { + pub fn new() -> Self { + Self { + items: Mutex::new(HashMap::new()), + sync_logs: Mutex::new(Vec::new()), + next_log_id: Mutex::new(1), + } + } +} + +#[async_trait] +impl LibraryCommand for InMemoryLibraryRepository { + async fn upsert_items(&self, _provider_id: &str, items: Vec) -> DomainResult<()> { + let mut store = self.items.lock().unwrap(); + for item in items { + store.insert(item.id().to_string(), item); + } + Ok(()) + } + + async fn clear_provider(&self, provider_id: &str) -> DomainResult<()> { + self.items + .lock() + .unwrap() + .retain(|_, item| item.provider_id() != provider_id); + Ok(()) + } + + async fn log_sync_start(&self, provider_id: &str) -> DomainResult { + let mut id_counter = self.next_log_id.lock().unwrap(); + let id = *id_counter; + *id_counter += 1; + + let entry = LibrarySyncLogEntry::new(id, provider_id, Utc::now().to_rfc3339()); + self.sync_logs.lock().unwrap().push(entry); + Ok(id) + } + + async fn log_sync_finish(&self, log_id: i64, result: &LibrarySyncResult) -> DomainResult<()> { + let mut logs = self.sync_logs.lock().unwrap(); + if let Some(entry) = logs.iter_mut().find(|e| e.id() == log_id) { + let status = if result.error().is_some() { + "error" + } else { + "success" + }; + *entry = LibrarySyncLogEntry::from_persistence( + entry.id(), + entry.provider_id().to_string(), + entry.started_at().to_string(), + Some(Utc::now().to_rfc3339()), + result.items_found(), + status.to_string(), + result.error().map(String::from), + ); + } + Ok(()) + } +} + +#[async_trait] +impl LibraryQuery for InMemoryLibraryRepository { + async fn search( + &self, + filter: &LibrarySearchFilter, + ) -> DomainResult<(Vec, u32)> { + let store = self.items.lock().unwrap(); + let mut items: Vec<_> = store + .values() + .filter(|item| { + if let Some(pid) = filter.provider_id() { + if item.provider_id() != pid { + return false; + } + } + if let Some(ct) = filter.content_type() { + if item.content_type() != ct { + return false; + } + } + if let Some(term) = filter.search_term() { + if !item.title().to_lowercase().contains(&term.to_lowercase()) { + return false; + } + } + if !filter.genres().is_empty() + && !filter + .genres() + .iter() + .any(|g| item.genres().contains(g)) + { + return false; + } + true + }) + .cloned() + .collect(); + let total = items.len() as u32; + let offset = filter.offset() as usize; + let limit = filter.limit() as usize; + items = items.into_iter().skip(offset).take(limit).collect(); + Ok((items, total)) + } + + async fn get_by_id(&self, id: &str) -> DomainResult> { + Ok(self.items.lock().unwrap().get(id).cloned()) + } + + async fn list_collections( + &self, + provider_id: Option<&str>, + ) -> DomainResult> { + let store = self.items.lock().unwrap(); + let mut seen = HashMap::new(); + for item in store.values() { + if let Some(pid) = provider_id { + if item.provider_id() != pid { + continue; + } + } + if let (Some(cid), Some(cname)) = (item.collection_id(), item.collection_name()) { + seen.entry(cid.to_string()) + .or_insert_with(|| LibraryCollection::new(cid, cname)); + } + } + Ok(seen.into_values().collect()) + } + + async fn list_series(&self, provider_id: Option<&str>) -> DomainResult> { + let store = self.items.lock().unwrap(); + let mut names: Vec = store + .values() + .filter(|item| { + if let Some(pid) = provider_id { + item.provider_id() == pid + } else { + true + } + }) + .filter_map(|item| item.series_name().map(String::from)) + .collect(); + names.sort(); + names.dedup(); + Ok(names) + } + + async fn list_genres( + &self, + _content_type: Option<&ContentType>, + provider_id: Option<&str>, + ) -> DomainResult> { + let store = self.items.lock().unwrap(); + let mut genres: Vec = store + .values() + .filter(|item| { + if let Some(pid) = provider_id { + item.provider_id() == pid + } else { + true + } + }) + .flat_map(|item| item.genres().iter().cloned()) + .collect(); + genres.sort(); + genres.dedup(); + Ok(genres) + } + + async fn latest_sync_status(&self) -> DomainResult> { + let logs = self.sync_logs.lock().unwrap(); + let mut latest: HashMap = HashMap::new(); + for entry in logs.iter() { + let key = entry.provider_id().to_string(); + if !latest.contains_key(&key) + || entry.id() > latest[&key].id() + { + latest.insert(key, entry.clone()); + } + } + Ok(latest.into_values().collect()) + } + + async fn is_sync_running(&self, provider_id: &str) -> DomainResult { + let logs = self.sync_logs.lock().unwrap(); + Ok(logs + .iter() + .any(|e| e.provider_id() == provider_id && e.status() == "running")) + } + + async fn list_shows( + &self, + provider_id: Option<&str>, + search_term: Option<&str>, + _genres: &[String], + ) -> DomainResult> { + let store = self.items.lock().unwrap(); + let mut shows: HashMap)> = HashMap::new(); + for item in store.values() { + if let Some(pid) = provider_id { + if item.provider_id() != pid { + continue; + } + } + if let Some(series) = item.series_name() { + if let Some(term) = search_term { + if !series.to_lowercase().contains(&term.to_lowercase()) { + continue; + } + } + let entry = shows + .entry(series.to_string()) + .or_insert_with(|| (0, std::collections::HashSet::new())); + entry.0 += 1; + if let Some(sn) = item.season_number() { + entry.1.insert(sn); + } + } + } + Ok(shows + .into_iter() + .map(|(name, (ep_count, seasons))| { + ShowSummary::new(name, ep_count, seasons.len() as u32) + }) + .collect()) + } + + async fn list_seasons( + &self, + series_name: &str, + provider_id: Option<&str>, + ) -> DomainResult> { + let store = self.items.lock().unwrap(); + let mut seasons: HashMap = HashMap::new(); + for item in store.values() { + if let Some(pid) = provider_id { + if item.provider_id() != pid { + continue; + } + } + if item.series_name() == Some(series_name) { + if let Some(sn) = item.season_number() { + *seasons.entry(sn).or_insert(0) += 1; + } + } + } + let mut result: Vec<_> = seasons + .into_iter() + .map(|(sn, count)| SeasonSummary::new(sn, count)) + .collect(); + result.sort_by_key(|s| s.season_number()); + Ok(result) + } +} + +// ============================================================================ +// InMemoryActivityLog +// ============================================================================ + +pub struct InMemoryActivityLog { + pub events: Mutex>, +} + +impl InMemoryActivityLog { + pub fn new() -> Self { + Self { + events: Mutex::new(Vec::new()), + } + } +} + +#[async_trait] +impl ActivityLogCommand for InMemoryActivityLog { + async fn log( + &self, + event_type: &str, + detail: &str, + channel_id: Option, + ) -> DomainResult<()> { + let event = ActivityEvent::new( + event_type, + detail, + channel_id.map(|c| c.value()), + ); + self.events.lock().unwrap().push(event); + Ok(()) + } +} + +#[async_trait] +impl ActivityLogQuery for InMemoryActivityLog { + async fn recent(&self, limit: u32) -> DomainResult> { + let events = self.events.lock().unwrap(); + let mut sorted: Vec<_> = events.clone(); + sorted.sort_by(|a, b| b.timestamp().cmp(&a.timestamp())); + Ok(sorted.into_iter().take(limit as usize).collect()) + } +} + +// ============================================================================ +// InMemoryAppSettings +// ============================================================================ + +pub struct InMemoryAppSettings { + pub settings: Mutex>, +} + +impl InMemoryAppSettings { + pub fn new() -> Self { + Self { + settings: Mutex::new(HashMap::new()), + } + } +} + +#[async_trait] +impl AppSettingsRepository for InMemoryAppSettings { + async fn get(&self, key: &str) -> DomainResult> { + Ok(self.settings.lock().unwrap().get(key).cloned()) + } + + async fn set(&self, key: &str, value: &str) -> DomainResult<()> { + self.settings + .lock() + .unwrap() + .insert(key.to_string(), value.to_string()); + Ok(()) + } + + async fn get_all(&self) -> DomainResult> { + Ok(self + .settings + .lock() + .unwrap() + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect()) + } +} + +// ============================================================================ +// InMemoryProviderConfig +// ============================================================================ + +pub struct InMemoryProviderConfig { + pub configs: Mutex>, +} + +impl InMemoryProviderConfig { + pub fn new() -> Self { + Self { + configs: Mutex::new(HashMap::new()), + } + } +} + +#[async_trait] +impl ProviderConfigCommand for InMemoryProviderConfig { + async fn upsert(&self, row: &ProviderConfigRow) -> DomainResult<()> { + self.configs + .lock() + .unwrap() + .insert(row.id().to_string(), row.clone()); + Ok(()) + } + + async fn delete(&self, id: &str) -> DomainResult<()> { + self.configs.lock().unwrap().remove(id); + Ok(()) + } +} + +#[async_trait] +impl ProviderConfigQuery for InMemoryProviderConfig { + async fn get_all(&self) -> DomainResult> { + Ok(self.configs.lock().unwrap().values().cloned().collect()) + } + + async fn get_by_id(&self, id: &str) -> DomainResult> { + Ok(self.configs.lock().unwrap().get(id).cloned()) + } +} + +// ============================================================================ +// InMemoryTranscodeSettings +// ============================================================================ + +pub struct InMemoryTranscodeSettings { + pub cleanup_ttl: Mutex>, +} + +impl InMemoryTranscodeSettings { + pub fn new() -> Self { + Self { + cleanup_ttl: Mutex::new(None), + } + } +} + +#[async_trait] +impl TranscodeSettingsRepository for InMemoryTranscodeSettings { + async fn load_cleanup_ttl(&self) -> DomainResult> { + Ok(*self.cleanup_ttl.lock().unwrap()) + } + + async fn save_cleanup_ttl(&self, hours: u32) -> DomainResult<()> { + *self.cleanup_ttl.lock().unwrap() = Some(hours); + Ok(()) + } +} diff --git a/crates/domain/src/testing/mod.rs b/crates/domain/src/testing/mod.rs new file mode 100644 index 0000000..50c45f3 --- /dev/null +++ b/crates/domain/src/testing/mod.rs @@ -0,0 +1,11 @@ +//! Test doubles for domain ports. +//! +//! Gated behind `#[cfg(feature = "test-helpers")]`. +//! Provides InMemory implementations (for integration tests) and +//! Noop implementations (for unit tests that don't care about persistence). + +mod in_memory; +mod noops; + +pub use in_memory::*; +pub use noops::*; diff --git a/crates/domain/src/testing/noops.rs b/crates/domain/src/testing/noops.rs new file mode 100644 index 0000000..0d50406 --- /dev/null +++ b/crates/domain/src/testing/noops.rs @@ -0,0 +1,137 @@ +//! Noop implementations of domain ports. +//! +//! Return `Ok(())` for writes, `Ok(None)`/`Ok(vec![])` for reads. +//! Useful for unit tests that don't care about persistence/side-effects. + +use async_trait::async_trait; + +use crate::errors::DomainResult; +use crate::events::DomainEvent; +use crate::models::{ + ActivityEvent, LibrarySyncResult, MediaItem, +}; +use crate::ports::{ + ActivityLogCommand, ActivityLogQuery, EventPublisher, IMediaProvider, LibrarySyncAdapter, + ProviderCapabilities, StreamQuality, StreamingProtocol, +}; +use crate::value_objects::{ChannelId, MediaFilter, MediaItemId}; + +// ============================================================================ +// NoopEventPublisher +// ============================================================================ + +pub struct NoopEventPublisher; + +impl NoopEventPublisher { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl EventPublisher for NoopEventPublisher { + async fn publish(&self, _event: DomainEvent) -> DomainResult<()> { + Ok(()) + } +} + +// ============================================================================ +// NoopMediaProvider +// ============================================================================ + +pub struct NoopMediaProvider; + +impl NoopMediaProvider { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl IMediaProvider for NoopMediaProvider { + fn capabilities(&self) -> ProviderCapabilities { + ProviderCapabilities { + collections: false, + series: false, + genres: false, + tags: false, + decade: false, + search: false, + streaming_protocol: StreamingProtocol::Hls, + rescan: false, + transcode: false, + } + } + + async fn fetch_items(&self, _filter: &MediaFilter) -> DomainResult> { + Ok(vec![]) + } + + async fn fetch_by_id(&self, _item_id: &MediaItemId) -> DomainResult> { + Ok(None) + } + + async fn get_stream_url( + &self, + _item_id: &MediaItemId, + _quality: &StreamQuality, + ) -> DomainResult { + Err(crate::errors::DomainError::InfrastructureError( + "NoopMediaProvider does not support streaming".into(), + )) + } +} + +// ============================================================================ +// NoopActivityLog +// ============================================================================ + +pub struct NoopActivityLog; + +impl NoopActivityLog { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl ActivityLogCommand for NoopActivityLog { + async fn log( + &self, + _event_type: &str, + _detail: &str, + _channel_id: Option, + ) -> DomainResult<()> { + Ok(()) + } +} + +#[async_trait] +impl ActivityLogQuery for NoopActivityLog { + async fn recent(&self, _limit: u32) -> DomainResult> { + Ok(vec![]) + } +} + +// ============================================================================ +// NoopLibrarySync +// ============================================================================ + +pub struct NoopLibrarySync; + +impl NoopLibrarySync { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl LibrarySyncAdapter for NoopLibrarySync { + async fn sync_provider( + &self, + _provider: &dyn IMediaProvider, + provider_id: &str, + ) -> LibrarySyncResult { + LibrarySyncResult::new(provider_id, 0, 0) + } +}