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 ports;
|
||||
pub mod services;
|
||||
#[cfg(feature = "test-helpers")]
|
||||
pub mod testing;
|
||||
pub mod value_objects;
|
||||
|
||||
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