domain testing: InMemory repos + Noops behind test-helpers
This commit is contained in:
@@ -3,6 +3,8 @@ pub mod events;
|
|||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
|
#[cfg(feature = "test-helpers")]
|
||||||
|
pub mod testing;
|
||||||
pub mod value_objects;
|
pub mod value_objects;
|
||||||
|
|
||||||
pub use errors::{DomainError, DomainResult};
|
pub use errors::{DomainError, DomainResult};
|
||||||
|
|||||||
785
crates/domain/src/testing/in_memory.rs
Normal file
785
crates/domain/src/testing/in_memory.rs
Normal file
@@ -0,0 +1,785 @@
|
|||||||
|
//! InMemory implementations of all domain port traits.
|
||||||
|
//!
|
||||||
|
//! Each struct uses `Mutex<HashMap<Id, Entity>>` 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<HashMap<Uuid, crate::models::User>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Option<crate::models::User>> {
|
||||||
|
Ok(self.store.lock().unwrap().get(&id.value()).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<crate::models::User>> {
|
||||||
|
let store = self.store.lock().unwrap();
|
||||||
|
Ok(store.values().find(|u| u.subject() == subject).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_email(&self, email: &str) -> DomainResult<Option<crate::models::User>> {
|
||||||
|
let store = self.store.lock().unwrap();
|
||||||
|
Ok(store
|
||||||
|
.values()
|
||||||
|
.find(|u| u.email().as_ref() == email)
|
||||||
|
.cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_users(&self) -> DomainResult<u64> {
|
||||||
|
Ok(self.store.lock().unwrap().len() as u64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// InMemoryChannelRepository
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
pub struct InMemoryChannelRepository {
|
||||||
|
pub channels: Mutex<HashMap<Uuid, Channel>>,
|
||||||
|
pub snapshots: Mutex<Vec<ChannelConfigSnapshot>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String>,
|
||||||
|
) -> DomainResult<ChannelConfigSnapshot> {
|
||||||
|
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<String>,
|
||||||
|
) -> DomainResult<Option<ChannelConfigSnapshot>> {
|
||||||
|
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<Option<Channel>> {
|
||||||
|
Ok(self.channels.lock().unwrap().get(&id.value()).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_owner(&self, owner_id: UserId) -> DomainResult<Vec<Channel>> {
|
||||||
|
let store = self.channels.lock().unwrap();
|
||||||
|
Ok(store
|
||||||
|
.values()
|
||||||
|
.filter(|c| c.owner_id() == owner_id)
|
||||||
|
.cloned()
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_all(&self) -> DomainResult<Vec<Channel>> {
|
||||||
|
Ok(self.channels.lock().unwrap().values().cloned().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_auto_schedule_enabled(&self) -> DomainResult<Vec<Channel>> {
|
||||||
|
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<Vec<ChannelConfigSnapshot>> {
|
||||||
|
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<Option<ChannelConfigSnapshot>> {
|
||||||
|
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<HashMap<Uuid, GeneratedSchedule>>,
|
||||||
|
pub playback_records: Mutex<Vec<PlaybackRecord>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Utc>,
|
||||||
|
) -> DomainResult<Option<GeneratedSchedule>> {
|
||||||
|
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<Option<GeneratedSchedule>> {
|
||||||
|
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<Vec<PlaybackRecord>> {
|
||||||
|
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<HashMap<BlockId, MediaItemId>> {
|
||||||
|
let store = self.schedules.lock().unwrap();
|
||||||
|
let mut result: HashMap<BlockId, (DateTime<Utc>, 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<Vec<GeneratedSchedule>> {
|
||||||
|
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<Option<GeneratedSchedule>> {
|
||||||
|
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<HashMap<String, LibraryItem>>,
|
||||||
|
pub sync_logs: Mutex<Vec<LibrarySyncLogEntry>>,
|
||||||
|
next_log_id: Mutex<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<LibraryItem>) -> 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<i64> {
|
||||||
|
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<LibraryItem>, 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<Option<LibraryItem>> {
|
||||||
|
Ok(self.items.lock().unwrap().get(id).cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_collections(
|
||||||
|
&self,
|
||||||
|
provider_id: Option<&str>,
|
||||||
|
) -> DomainResult<Vec<LibraryCollection>> {
|
||||||
|
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<Vec<String>> {
|
||||||
|
let store = self.items.lock().unwrap();
|
||||||
|
let mut names: Vec<String> = 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<Vec<String>> {
|
||||||
|
let store = self.items.lock().unwrap();
|
||||||
|
let mut genres: Vec<String> = 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<Vec<LibrarySyncLogEntry>> {
|
||||||
|
let logs = self.sync_logs.lock().unwrap();
|
||||||
|
let mut latest: HashMap<String, LibrarySyncLogEntry> = 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<bool> {
|
||||||
|
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<Vec<ShowSummary>> {
|
||||||
|
let store = self.items.lock().unwrap();
|
||||||
|
let mut shows: HashMap<String, (u32, std::collections::HashSet<u32>)> = 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<Vec<SeasonSummary>> {
|
||||||
|
let store = self.items.lock().unwrap();
|
||||||
|
let mut seasons: HashMap<u32, u32> = 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<Vec<ActivityEvent>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ChannelId>,
|
||||||
|
) -> 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<Vec<ActivityEvent>> {
|
||||||
|
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<HashMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Option<String>> {
|
||||||
|
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<Vec<(String, String)>> {
|
||||||
|
Ok(self
|
||||||
|
.settings
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), v.clone()))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// InMemoryProviderConfig
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
pub struct InMemoryProviderConfig {
|
||||||
|
pub configs: Mutex<HashMap<String, ProviderConfigRow>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Vec<ProviderConfigRow>> {
|
||||||
|
Ok(self.configs.lock().unwrap().values().cloned().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_by_id(&self, id: &str) -> DomainResult<Option<ProviderConfigRow>> {
|
||||||
|
Ok(self.configs.lock().unwrap().get(id).cloned())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// InMemoryTranscodeSettings
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
pub struct InMemoryTranscodeSettings {
|
||||||
|
pub cleanup_ttl: Mutex<Option<u32>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Option<u32>> {
|
||||||
|
Ok(*self.cleanup_ttl.lock().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_cleanup_ttl(&self, hours: u32) -> DomainResult<()> {
|
||||||
|
*self.cleanup_ttl.lock().unwrap() = Some(hours);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
11
crates/domain/src/testing/mod.rs
Normal file
11
crates/domain/src/testing/mod.rs
Normal file
@@ -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::*;
|
||||||
137
crates/domain/src/testing/noops.rs
Normal file
137
crates/domain/src/testing/noops.rs
Normal file
@@ -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<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(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<ChannelId>,
|
||||||
|
) -> DomainResult<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ActivityLogQuery for NoopActivityLog {
|
||||||
|
async fn recent(&self, _limit: u32) -> DomainResult<Vec<ActivityEvent>> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user