application: config_snapshots, admin, providers, iptv
This commit is contained in:
13
crates/application/src/admin/activity_log.rs
Normal file
13
crates/application/src/admin/activity_log.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
use domain::models::ActivityEvent;
|
||||||
|
use domain::DomainResult;
|
||||||
|
|
||||||
|
use super::deps::AdminDeps;
|
||||||
|
use super::queries::GetActivityLogQuery;
|
||||||
|
|
||||||
|
/// Get recent activity log entries.
|
||||||
|
pub async fn execute(
|
||||||
|
deps: &AdminDeps,
|
||||||
|
query: GetActivityLogQuery,
|
||||||
|
) -> DomainResult<Vec<ActivityEvent>> {
|
||||||
|
deps.activity_query.recent(query.limit).await
|
||||||
|
}
|
||||||
4
crates/application/src/admin/commands.rs
Normal file
4
crates/application/src/admin/commands.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
/// Update one or more admin settings (key-value pairs).
|
||||||
|
pub struct UpdateSettingsCommand {
|
||||||
|
pub settings: Vec<(String, String)>,
|
||||||
|
}
|
||||||
9
crates/application/src/admin/deps.rs
Normal file
9
crates/application/src/admin/deps.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ports::{ActivityLogQuery, AppSettingsRepository};
|
||||||
|
|
||||||
|
/// Dependencies for admin use cases.
|
||||||
|
pub struct AdminDeps {
|
||||||
|
pub settings_repo: Arc<dyn AppSettingsRepository>,
|
||||||
|
pub activity_query: Arc<dyn ActivityLogQuery>,
|
||||||
|
}
|
||||||
16
crates/application/src/admin/get_settings.rs
Normal file
16
crates/application/src/admin/get_settings.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
use domain::DomainResult;
|
||||||
|
|
||||||
|
use super::deps::AdminDeps;
|
||||||
|
use super::queries::GetSettingsQuery;
|
||||||
|
|
||||||
|
/// Get all admin settings as key-value pairs.
|
||||||
|
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;
|
||||||
10
crates/application/src/admin/mod.rs
Normal file
10
crates/application/src/admin/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
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};
|
||||||
7
crates/application/src/admin/queries.rs
Normal file
7
crates/application/src/admin/queries.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/// Get all admin settings.
|
||||||
|
pub struct GetSettingsQuery;
|
||||||
|
|
||||||
|
/// Get recent activity log entries.
|
||||||
|
pub struct GetActivityLogQuery {
|
||||||
|
pub limit: u32,
|
||||||
|
}
|
||||||
46
crates/application/src/admin/tests/get_settings.rs
Normal file
46
crates/application/src/admin/tests/get_settings.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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"));
|
||||||
|
}
|
||||||
63
crates/application/src/admin/tests/update_settings.rs
Normal file
63
crates/application/src/admin/tests/update_settings.rs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::testing::{InMemoryActivityLog, InMemoryAppSettings};
|
||||||
|
|
||||||
|
use crate::admin::commands::UpdateSettingsCommand;
|
||||||
|
use crate::admin::deps::AdminDeps;
|
||||||
|
use crate::admin::update_settings;
|
||||||
|
|
||||||
|
fn make_deps() -> AdminDeps {
|
||||||
|
AdminDeps {
|
||||||
|
settings_repo: Arc::new(InMemoryAppSettings::new()),
|
||||||
|
activity_query: Arc::new(InMemoryActivityLog::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_stores_settings() {
|
||||||
|
let deps = make_deps();
|
||||||
|
|
||||||
|
update_settings::execute(
|
||||||
|
&deps,
|
||||||
|
UpdateSettingsCommand {
|
||||||
|
settings: vec![
|
||||||
|
("library_sync_interval_hours".into(), "12".into()),
|
||||||
|
("theme".into(), "dark".into()),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let val = deps.settings_repo.get("library_sync_interval_hours").await.unwrap();
|
||||||
|
assert_eq!(val, Some("12".into()));
|
||||||
|
|
||||||
|
let val2 = deps.settings_repo.get("theme").await.unwrap();
|
||||||
|
assert_eq!(val2, Some("dark".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_overwrites_existing() {
|
||||||
|
let deps = make_deps();
|
||||||
|
|
||||||
|
update_settings::execute(
|
||||||
|
&deps,
|
||||||
|
UpdateSettingsCommand {
|
||||||
|
settings: vec![("key".into(), "old".into())],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
update_settings::execute(
|
||||||
|
&deps,
|
||||||
|
UpdateSettingsCommand {
|
||||||
|
settings: vec![("key".into(), "new".into())],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let val = deps.settings_repo.get("key").await.unwrap();
|
||||||
|
assert_eq!(val, Some("new".into()));
|
||||||
|
}
|
||||||
18
crates/application/src/admin/update_settings.rs
Normal file
18
crates/application/src/admin/update_settings.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use domain::DomainResult;
|
||||||
|
|
||||||
|
use super::commands::UpdateSettingsCommand;
|
||||||
|
use super::deps::AdminDeps;
|
||||||
|
|
||||||
|
/// Update one or more admin settings.
|
||||||
|
///
|
||||||
|
/// Iterates key/value pairs and upserts each one.
|
||||||
|
pub async fn execute(deps: &AdminDeps, cmd: UpdateSettingsCommand) -> DomainResult<()> {
|
||||||
|
for (key, value) in &cmd.settings {
|
||||||
|
deps.settings_repo.set(key, value).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/update_settings.rs"]
|
||||||
|
mod tests;
|
||||||
20
crates/application/src/config_snapshots/commands.rs
Normal file
20
crates/application/src/config_snapshots/commands.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Save a snapshot of the channel's current config.
|
||||||
|
pub struct SaveSnapshotCommand {
|
||||||
|
pub channel_id: Uuid,
|
||||||
|
pub label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the label on an existing snapshot.
|
||||||
|
pub struct PatchLabelCommand {
|
||||||
|
pub channel_id: Uuid,
|
||||||
|
pub snapshot_id: Uuid,
|
||||||
|
pub label: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore a channel's config from a snapshot.
|
||||||
|
pub struct RestoreSnapshotCommand {
|
||||||
|
pub channel_id: Uuid,
|
||||||
|
pub snapshot_id: Uuid,
|
||||||
|
}
|
||||||
9
crates/application/src/config_snapshots/deps.rs
Normal file
9
crates/application/src/config_snapshots/deps.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ports::{ChannelCommand, ChannelQuery};
|
||||||
|
|
||||||
|
/// Dependencies for config snapshot use cases.
|
||||||
|
pub struct ConfigSnapshotDeps {
|
||||||
|
pub channel_command: Arc<dyn ChannelCommand>,
|
||||||
|
pub channel_query: Arc<dyn ChannelQuery>,
|
||||||
|
}
|
||||||
17
crates/application/src/config_snapshots/get.rs
Normal file
17
crates/application/src/config_snapshots/get.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use domain::models::ChannelConfigSnapshot;
|
||||||
|
use domain::value_objects::ChannelId;
|
||||||
|
use domain::DomainResult;
|
||||||
|
|
||||||
|
use super::deps::ConfigSnapshotDeps;
|
||||||
|
use super::queries::GetSnapshotQuery;
|
||||||
|
|
||||||
|
/// Get a specific config snapshot by channel and snapshot ID.
|
||||||
|
pub async fn execute(
|
||||||
|
deps: &ConfigSnapshotDeps,
|
||||||
|
query: GetSnapshotQuery,
|
||||||
|
) -> DomainResult<Option<ChannelConfigSnapshot>> {
|
||||||
|
let channel_id = ChannelId::from(query.channel_id);
|
||||||
|
deps.channel_query
|
||||||
|
.get_config_snapshot(channel_id, query.snapshot_id)
|
||||||
|
.await
|
||||||
|
}
|
||||||
19
crates/application/src/config_snapshots/list.rs
Normal file
19
crates/application/src/config_snapshots/list.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
use domain::models::ChannelConfigSnapshot;
|
||||||
|
use domain::value_objects::ChannelId;
|
||||||
|
use domain::DomainResult;
|
||||||
|
|
||||||
|
use super::deps::ConfigSnapshotDeps;
|
||||||
|
use super::queries::ListSnapshotsQuery;
|
||||||
|
|
||||||
|
/// List all config snapshots for a channel, newest first.
|
||||||
|
pub async fn execute(
|
||||||
|
deps: &ConfigSnapshotDeps,
|
||||||
|
query: ListSnapshotsQuery,
|
||||||
|
) -> DomainResult<Vec<ChannelConfigSnapshot>> {
|
||||||
|
let channel_id = ChannelId::from(query.channel_id);
|
||||||
|
deps.channel_query.list_config_snapshots(channel_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/list.rs"]
|
||||||
|
mod tests;
|
||||||
12
crates/application/src/config_snapshots/mod.rs
Normal file
12
crates/application/src/config_snapshots/mod.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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 deps::ConfigSnapshotDeps;
|
||||||
|
pub use queries::{GetSnapshotQuery, ListSnapshotsQuery};
|
||||||
18
crates/application/src/config_snapshots/patch_label.rs
Normal file
18
crates/application/src/config_snapshots/patch_label.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use domain::models::ChannelConfigSnapshot;
|
||||||
|
use domain::value_objects::ChannelId;
|
||||||
|
use domain::DomainResult;
|
||||||
|
|
||||||
|
use super::commands::PatchLabelCommand;
|
||||||
|
use super::deps::ConfigSnapshotDeps;
|
||||||
|
|
||||||
|
/// Update the label on an existing config snapshot.
|
||||||
|
pub async fn execute(
|
||||||
|
deps: &ConfigSnapshotDeps,
|
||||||
|
cmd: PatchLabelCommand,
|
||||||
|
) -> DomainResult<Option<ChannelConfigSnapshot>> {
|
||||||
|
let channel_id = ChannelId::from(cmd.channel_id);
|
||||||
|
|
||||||
|
deps.channel_command
|
||||||
|
.patch_config_snapshot_label(channel_id, cmd.snapshot_id, cmd.label)
|
||||||
|
.await
|
||||||
|
}
|
||||||
12
crates/application/src/config_snapshots/queries.rs
Normal file
12
crates/application/src/config_snapshots/queries.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// List all config snapshots for a channel (newest first).
|
||||||
|
pub struct ListSnapshotsQuery {
|
||||||
|
pub channel_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a specific config snapshot.
|
||||||
|
pub struct GetSnapshotQuery {
|
||||||
|
pub channel_id: Uuid,
|
||||||
|
pub snapshot_id: Uuid,
|
||||||
|
}
|
||||||
43
crates/application/src/config_snapshots/restore.rs
Normal file
43
crates/application/src/config_snapshots/restore.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
use domain::models::Channel;
|
||||||
|
use domain::value_objects::ChannelId;
|
||||||
|
use domain::{DomainError, DomainResult};
|
||||||
|
|
||||||
|
use super::commands::RestoreSnapshotCommand;
|
||||||
|
use super::deps::ConfigSnapshotDeps;
|
||||||
|
|
||||||
|
/// Restore a channel's config from a snapshot.
|
||||||
|
///
|
||||||
|
/// Flow: find snapshot -> find channel -> snapshot current config (backup) ->
|
||||||
|
/// apply snapshot config to channel -> save channel -> return updated channel.
|
||||||
|
pub async fn execute(
|
||||||
|
deps: &ConfigSnapshotDeps,
|
||||||
|
cmd: RestoreSnapshotCommand,
|
||||||
|
) -> DomainResult<Channel> {
|
||||||
|
let channel_id = ChannelId::from(cmd.channel_id);
|
||||||
|
|
||||||
|
let snapshot = deps
|
||||||
|
.channel_query
|
||||||
|
.get_config_snapshot(channel_id, cmd.snapshot_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(DomainError::ValidationError(format!(
|
||||||
|
"Snapshot {} not found",
|
||||||
|
cmd.snapshot_id
|
||||||
|
)))?;
|
||||||
|
|
||||||
|
let mut channel = deps
|
||||||
|
.channel_query
|
||||||
|
.find_by_id(channel_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(DomainError::ChannelNotFound(cmd.channel_id))?;
|
||||||
|
|
||||||
|
// Auto-snapshot the current config before overwriting
|
||||||
|
deps.channel_command
|
||||||
|
.save_config_snapshot(channel_id, channel.schedule_config(), None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Apply the snapshot's config
|
||||||
|
channel.set_schedule_config(snapshot.config().clone());
|
||||||
|
deps.channel_command.save(&channel).await?;
|
||||||
|
|
||||||
|
Ok(channel)
|
||||||
|
}
|
||||||
30
crates/application/src/config_snapshots/save.rs
Normal file
30
crates/application/src/config_snapshots/save.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use domain::models::ChannelConfigSnapshot;
|
||||||
|
use domain::value_objects::ChannelId;
|
||||||
|
use domain::{DomainError, DomainResult};
|
||||||
|
|
||||||
|
use super::commands::SaveSnapshotCommand;
|
||||||
|
use super::deps::ConfigSnapshotDeps;
|
||||||
|
|
||||||
|
/// Save a snapshot of the channel's current schedule config.
|
||||||
|
///
|
||||||
|
/// Flow: find channel -> snapshot its current config -> return snapshot.
|
||||||
|
pub async fn execute(
|
||||||
|
deps: &ConfigSnapshotDeps,
|
||||||
|
cmd: SaveSnapshotCommand,
|
||||||
|
) -> DomainResult<ChannelConfigSnapshot> {
|
||||||
|
let channel_id = ChannelId::from(cmd.channel_id);
|
||||||
|
|
||||||
|
let channel = deps
|
||||||
|
.channel_query
|
||||||
|
.find_by_id(channel_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(DomainError::ChannelNotFound(cmd.channel_id))?;
|
||||||
|
|
||||||
|
deps.channel_command
|
||||||
|
.save_config_snapshot(channel_id, channel.schedule_config(), cmd.label)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/save.rs"]
|
||||||
|
mod tests;
|
||||||
77
crates/application/src/config_snapshots/tests/list.rs
Normal file
77
crates/application/src/config_snapshots/tests/list.rs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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().value(), 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().value(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.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().value(),
|
||||||
|
label: Some(label.into()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let snaps = list::execute(
|
||||||
|
&deps,
|
||||||
|
ListSnapshotsQuery {
|
||||||
|
channel_id: channel.id().value(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(snaps.len(), 2);
|
||||||
|
// Newest first
|
||||||
|
assert_eq!(snaps[0].version_num(), 2);
|
||||||
|
assert_eq!(snaps[1].version_num(), 1);
|
||||||
|
}
|
||||||
75
crates/application/src/config_snapshots/tests/save.rs
Normal file
75
crates/application/src/config_snapshots/tests/save.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
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::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().value(), channel.clone());
|
||||||
|
channel
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_creates_snapshot() {
|
||||||
|
let (deps, repo) = make_deps();
|
||||||
|
let channel = seed_channel(&repo).await;
|
||||||
|
|
||||||
|
let snap = save::execute(
|
||||||
|
&deps,
|
||||||
|
SaveSnapshotCommand {
|
||||||
|
channel_id: channel.id().value(),
|
||||||
|
label: Some("v1".into()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(snap.channel_id(), channel.id());
|
||||||
|
assert_eq!(snap.label(), Some("v1"));
|
||||||
|
assert_eq!(snap.version_num(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn save_increments_version() {
|
||||||
|
let (deps, repo) = make_deps();
|
||||||
|
let channel = seed_channel(&repo).await;
|
||||||
|
|
||||||
|
save::execute(
|
||||||
|
&deps,
|
||||||
|
SaveSnapshotCommand {
|
||||||
|
channel_id: channel.id().value(),
|
||||||
|
label: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let snap2 = save::execute(
|
||||||
|
&deps,
|
||||||
|
SaveSnapshotCommand {
|
||||||
|
channel_id: channel.id().value(),
|
||||||
|
label: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(snap2.version_num(), 2);
|
||||||
|
}
|
||||||
9
crates/application/src/iptv/deps.rs
Normal file
9
crates/application/src/iptv/deps.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ports::{ChannelQuery, ScheduleQuery};
|
||||||
|
|
||||||
|
/// Dependencies for IPTV export use cases.
|
||||||
|
pub struct IptvDeps {
|
||||||
|
pub channel_query: Arc<dyn ChannelQuery>,
|
||||||
|
pub schedule_query: Arc<dyn ScheduleQuery>,
|
||||||
|
}
|
||||||
18
crates/application/src/iptv/m3u.rs
Normal file
18
crates/application/src/iptv/m3u.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use domain::services::iptv::generate_m3u;
|
||||||
|
use domain::DomainResult;
|
||||||
|
|
||||||
|
use super::deps::IptvDeps;
|
||||||
|
use super::queries::GetM3uQuery;
|
||||||
|
|
||||||
|
/// Generate an M3U playlist for all channels.
|
||||||
|
///
|
||||||
|
/// Flow: fetch all channels -> delegate to domain::generate_m3u -> return string.
|
||||||
|
pub async fn execute(deps: &IptvDeps, query: GetM3uQuery) -> DomainResult<String> {
|
||||||
|
let channels = deps.channel_query.find_all().await?;
|
||||||
|
let token = query.token.as_deref().unwrap_or("");
|
||||||
|
Ok(generate_m3u(&channels, &query.base_url, token))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/m3u.rs"]
|
||||||
|
mod tests;
|
||||||
7
crates/application/src/iptv/mod.rs
Normal file
7
crates/application/src/iptv/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod deps;
|
||||||
|
pub mod m3u;
|
||||||
|
pub mod queries;
|
||||||
|
pub mod xmltv;
|
||||||
|
|
||||||
|
pub use deps::IptvDeps;
|
||||||
|
pub use queries::{GetM3uQuery, GetXmltvQuery};
|
||||||
8
crates/application/src/iptv/queries.rs
Normal file
8
crates/application/src/iptv/queries.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/// Generate an M3U playlist for all channels.
|
||||||
|
pub struct GetM3uQuery {
|
||||||
|
pub base_url: String,
|
||||||
|
pub token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate an XMLTV EPG document for all channels.
|
||||||
|
pub struct GetXmltvQuery;
|
||||||
84
crates/application/src/iptv/tests/m3u.rs
Normal file
84
crates/application/src/iptv/tests/m3u.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::models::Channel;
|
||||||
|
use domain::testing::{InMemoryChannelRepository, InMemoryScheduleRepository};
|
||||||
|
use domain::value_objects::UserId;
|
||||||
|
|
||||||
|
use crate::iptv::deps::IptvDeps;
|
||||||
|
use crate::iptv::m3u;
|
||||||
|
use crate::iptv::queries::GetM3uQuery;
|
||||||
|
|
||||||
|
fn make_deps() -> (IptvDeps, Arc<InMemoryChannelRepository>) {
|
||||||
|
let channel_repo = Arc::new(InMemoryChannelRepository::new());
|
||||||
|
let schedule_repo = Arc::new(InMemoryScheduleRepository::new());
|
||||||
|
let deps = IptvDeps {
|
||||||
|
channel_query: channel_repo.clone(),
|
||||||
|
schedule_query: schedule_repo,
|
||||||
|
};
|
||||||
|
(deps, channel_repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn m3u_empty_channels() {
|
||||||
|
let (deps, _) = make_deps();
|
||||||
|
|
||||||
|
let result = m3u::execute(
|
||||||
|
&deps,
|
||||||
|
GetM3uQuery {
|
||||||
|
base_url: "http://localhost:3000".into(),
|
||||||
|
token: Some("tok123".into()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result, "#EXTM3U\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn m3u_includes_channels() {
|
||||||
|
let (deps, repo) = make_deps();
|
||||||
|
|
||||||
|
let ch = Channel::new(UserId::generate(), "Test TV", "UTC");
|
||||||
|
repo.channels
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(ch.id().value(), ch.clone());
|
||||||
|
|
||||||
|
let result = m3u::execute(
|
||||||
|
&deps,
|
||||||
|
GetM3uQuery {
|
||||||
|
base_url: "http://localhost:3000".into(),
|
||||||
|
token: Some("mytoken".into()),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(result.starts_with("#EXTM3U\n"));
|
||||||
|
assert!(result.contains("Test TV"));
|
||||||
|
assert!(result.contains("token=mytoken"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn m3u_no_token() {
|
||||||
|
let (deps, repo) = make_deps();
|
||||||
|
|
||||||
|
let ch = Channel::new(UserId::generate(), "Ch1", "UTC");
|
||||||
|
repo.channels
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(ch.id().value(), ch);
|
||||||
|
|
||||||
|
let result = m3u::execute(
|
||||||
|
&deps,
|
||||||
|
GetM3uQuery {
|
||||||
|
base_url: "http://localhost:3000".into(),
|
||||||
|
token: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(result.contains("token="));
|
||||||
|
}
|
||||||
27
crates/application/src/iptv/xmltv.rs
Normal file
27
crates/application/src/iptv/xmltv.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
use domain::services::iptv::generate_xmltv;
|
||||||
|
use domain::DomainResult;
|
||||||
|
|
||||||
|
use super::deps::IptvDeps;
|
||||||
|
use super::queries::GetXmltvQuery;
|
||||||
|
|
||||||
|
/// Generate an XMLTV EPG document for all channels with active schedules.
|
||||||
|
///
|
||||||
|
/// Flow: fetch all channels -> for each, find active schedule -> collect slots
|
||||||
|
/// -> delegate to domain::generate_xmltv -> return string.
|
||||||
|
pub async fn execute(deps: &IptvDeps, _query: GetXmltvQuery) -> DomainResult<String> {
|
||||||
|
let channels = deps.channel_query.find_all().await?;
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
let mut slots_by_channel = HashMap::new();
|
||||||
|
for ch in &channels {
|
||||||
|
if let Some(schedule) = deps.schedule_query.find_active(ch.id(), now).await? {
|
||||||
|
slots_by_channel.insert(ch.id(), schedule.slots().to_vec());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(generate_xmltv(&channels, &slots_by_channel))
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
|
pub mod admin;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod channels;
|
pub mod channels;
|
||||||
|
pub mod config_snapshots;
|
||||||
|
pub mod iptv;
|
||||||
pub mod library;
|
pub mod library;
|
||||||
|
pub mod providers;
|
||||||
pub mod schedule;
|
pub mod schedule;
|
||||||
|
|||||||
12
crates/application/src/providers/commands.rs
Normal file
12
crates/application/src/providers/commands.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/// Insert or update a provider configuration.
|
||||||
|
pub struct UpsertProviderCommand {
|
||||||
|
pub id: String,
|
||||||
|
pub provider_type: String,
|
||||||
|
pub config_json: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a provider configuration.
|
||||||
|
pub struct DeleteProviderCommand {
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
9
crates/application/src/providers/delete.rs
Normal file
9
crates/application/src/providers/delete.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use domain::DomainResult;
|
||||||
|
|
||||||
|
use super::commands::DeleteProviderCommand;
|
||||||
|
use super::deps::ProviderDeps;
|
||||||
|
|
||||||
|
/// Delete a provider configuration by ID.
|
||||||
|
pub async fn execute(deps: &ProviderDeps, cmd: DeleteProviderCommand) -> DomainResult<()> {
|
||||||
|
deps.provider_config_command.delete(&cmd.id).await
|
||||||
|
}
|
||||||
9
crates/application/src/providers/deps.rs
Normal file
9
crates/application/src/providers/deps.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ports::{ProviderConfigCommand, ProviderConfigQuery};
|
||||||
|
|
||||||
|
/// Dependencies for provider config use cases.
|
||||||
|
pub struct ProviderDeps {
|
||||||
|
pub provider_config_command: Arc<dyn ProviderConfigCommand>,
|
||||||
|
pub provider_config_query: Arc<dyn ProviderConfigQuery>,
|
||||||
|
}
|
||||||
13
crates/application/src/providers/get.rs
Normal file
13
crates/application/src/providers/get.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
use domain::models::ProviderConfigRow;
|
||||||
|
use domain::DomainResult;
|
||||||
|
|
||||||
|
use super::deps::ProviderDeps;
|
||||||
|
use super::queries::GetProviderQuery;
|
||||||
|
|
||||||
|
/// Get a provider configuration by ID.
|
||||||
|
pub async fn execute(
|
||||||
|
deps: &ProviderDeps,
|
||||||
|
query: GetProviderQuery,
|
||||||
|
) -> DomainResult<Option<ProviderConfigRow>> {
|
||||||
|
deps.provider_config_query.get_by_id(&query.id).await
|
||||||
|
}
|
||||||
17
crates/application/src/providers/list.rs
Normal file
17
crates/application/src/providers/list.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use domain::models::ProviderConfigRow;
|
||||||
|
use domain::DomainResult;
|
||||||
|
|
||||||
|
use super::deps::ProviderDeps;
|
||||||
|
use super::queries::ListProvidersQuery;
|
||||||
|
|
||||||
|
/// List all provider configurations.
|
||||||
|
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;
|
||||||
11
crates/application/src/providers/mod.rs
Normal file
11
crates/application/src/providers/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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 deps::ProviderDeps;
|
||||||
|
pub use queries::{GetProviderQuery, ListProvidersQuery};
|
||||||
7
crates/application/src/providers/queries.rs
Normal file
7
crates/application/src/providers/queries.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/// List all provider configurations.
|
||||||
|
pub struct ListProvidersQuery;
|
||||||
|
|
||||||
|
/// Get a provider configuration by ID.
|
||||||
|
pub struct GetProviderQuery {
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
45
crates/application/src/providers/tests/list.rs
Normal file
45
crates/application/src/providers/tests/list.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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_json: "{}".into(),
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let providers = list::execute(&deps, ListProvidersQuery).await.unwrap();
|
||||||
|
assert_eq!(providers.len(), 2);
|
||||||
|
}
|
||||||
76
crates/application/src/providers/tests/upsert.rs
Normal file
76
crates/application/src/providers/tests/upsert.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::testing::InMemoryProviderConfig;
|
||||||
|
|
||||||
|
use crate::providers::commands::UpsertProviderCommand;
|
||||||
|
use crate::providers::deps::ProviderDeps;
|
||||||
|
use crate::providers::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 upsert_stores_provider() {
|
||||||
|
let deps = make_deps();
|
||||||
|
|
||||||
|
upsert::execute(
|
||||||
|
&deps,
|
||||||
|
UpsertProviderCommand {
|
||||||
|
id: "jf-1".into(),
|
||||||
|
provider_type: "jellyfin".into(),
|
||||||
|
config_json: r#"{"url":"http://localhost:8096"}"#.into(),
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let row = deps.provider_config_query.get_by_id("jf-1").await.unwrap();
|
||||||
|
assert!(row.is_some());
|
||||||
|
let row = row.unwrap();
|
||||||
|
assert_eq!(row.provider_type(), "jellyfin");
|
||||||
|
assert!(row.enabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn upsert_overwrites_existing() {
|
||||||
|
let deps = make_deps();
|
||||||
|
|
||||||
|
upsert::execute(
|
||||||
|
&deps,
|
||||||
|
UpsertProviderCommand {
|
||||||
|
id: "jf-1".into(),
|
||||||
|
provider_type: "jellyfin".into(),
|
||||||
|
config_json: r#"{"url":"http://old"}"#.into(),
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
upsert::execute(
|
||||||
|
&deps,
|
||||||
|
UpsertProviderCommand {
|
||||||
|
id: "jf-1".into(),
|
||||||
|
provider_type: "jellyfin".into(),
|
||||||
|
config_json: r#"{"url":"http://new"}"#.into(),
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let row = deps
|
||||||
|
.provider_config_query
|
||||||
|
.get_by_id("jf-1")
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(row.config_json().contains("new"));
|
||||||
|
assert!(!row.enabled());
|
||||||
|
}
|
||||||
21
crates/application/src/providers/upsert.rs
Normal file
21
crates/application/src/providers/upsert.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use domain::models::ProviderConfigRow;
|
||||||
|
use domain::DomainResult;
|
||||||
|
|
||||||
|
use super::commands::UpsertProviderCommand;
|
||||||
|
use super::deps::ProviderDeps;
|
||||||
|
|
||||||
|
/// Insert or update a provider configuration.
|
||||||
|
pub async fn execute(deps: &ProviderDeps, cmd: UpsertProviderCommand) -> DomainResult<()> {
|
||||||
|
let row = ProviderConfigRow::from_persistence(
|
||||||
|
cmd.id,
|
||||||
|
cmd.provider_type,
|
||||||
|
cmd.config_json,
|
||||||
|
cmd.enabled,
|
||||||
|
String::new(),
|
||||||
|
);
|
||||||
|
deps.provider_config_command.upsert(&row).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/upsert.rs"]
|
||||||
|
mod tests;
|
||||||
Reference in New Issue
Block a user