application: config_snapshots, admin, providers, iptv

This commit is contained in:
2026-07-12 02:14:07 +02:00
parent ebf0614fdf
commit 466d34b5d0
37 changed files with 895 additions and 0 deletions

View 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
}

View File

@@ -0,0 +1,4 @@
/// Update one or more admin settings (key-value pairs).
pub struct UpdateSettingsCommand {
pub settings: Vec<(String, String)>,
}

View 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>,
}

View 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;

View 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};

View File

@@ -0,0 +1,7 @@
/// Get all admin settings.
pub struct GetSettingsQuery;
/// Get recent activity log entries.
pub struct GetActivityLogQuery {
pub limit: u32,
}

View 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"));
}

View 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()));
}

View 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;

View 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,
}

View 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>,
}

View 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
}

View 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;

View 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};

View 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
}

View 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,
}

View 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)
}

View 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;

View 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);
}

View 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);
}

View 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>,
}

View 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;

View 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};

View 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;

View 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="));
}

View 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))
}

View File

@@ -1,4 +1,8 @@
pub mod admin;
pub mod auth;
pub mod channels;
pub mod config_snapshots;
pub mod iptv;
pub mod library;
pub mod providers;
pub mod schedule;

View 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,
}

View 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
}

View 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>,
}

View 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
}

View 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;

View 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};

View File

@@ -0,0 +1,7 @@
/// List all provider configurations.
pub struct ListProvidersQuery;
/// Get a provider configuration by ID.
pub struct GetProviderQuery {
pub id: String,
}

View 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);
}

View 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());
}

View 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;