fix(presentation): extract 12 handler violations to use cases

- TokenService port + JwtTokenService adapter; login/refresh return LoginResult
- delete get_token (dup of login); create_tokens helper removed
- type UpdateChannelRequest schedule_config/recycle_policy (no serde_json::Value)
- update_settings returns updated Vec; handler calls one use case
- config use case in application::config; handler maps DTO only
- SyncStatusEntry moved to api-types w/ From<LibrarySyncLogEntry>
- trigger_sync: Conflict error, drop sync_trigger.send from handler
- UpsertProviderCommand.config accepts Value; serialization in use case
- get_current_broadcast returns BroadcastWithChannel; one call
- get_stream_url resolves broadcast internally; handler single call
This commit is contained in:
2026-07-12 05:28:49 +02:00
parent 9b18d3ff6d
commit 33b440d297
44 changed files with 414 additions and 280 deletions

View File

@@ -3,11 +3,11 @@ use domain::DomainResult;
use super::commands::UpdateSettingsCommand;
use super::deps::AdminDeps;
pub async fn execute(deps: &AdminDeps, cmd: UpdateSettingsCommand) -> DomainResult<()> {
pub async fn execute(deps: &AdminDeps, cmd: UpdateSettingsCommand) -> DomainResult<Vec<(String, String)>> {
for (key, value) in &cmd.settings {
deps.settings_repo.set(key, value).await?;
}
Ok(())
deps.settings_repo.get_all().await
}
#[cfg(test)]

View File

@@ -6,4 +6,5 @@ pub struct RegisterCommand {
pub struct LoginCommand {
pub email: String,
pub password: String,
pub remember_me: bool,
}

View File

@@ -1,10 +1,11 @@
use std::sync::Arc;
use domain::ports::{AuthService, EventPublisher, UserCommand, UserQuery};
use domain::ports::{AuthService, EventPublisher, TokenService, UserCommand, UserQuery};
pub struct AuthDeps {
pub user_command: Arc<dyn UserCommand>,
pub user_query: Arc<dyn UserQuery>,
pub auth_service: Arc<dyn AuthService>,
pub token_service: Arc<dyn TokenService>,
pub event_publisher: Arc<dyn EventPublisher>,
}

View File

@@ -1,12 +1,12 @@
use domain::models::User;
use domain::{DomainError, DomainResult, Email};
use super::commands::LoginCommand;
use super::deps::AuthDeps;
use super::results::LoginResult;
const INVALID_CREDENTIALS: &str = "Invalid credentials";
pub async fn execute(deps: &AuthDeps, cmd: LoginCommand) -> DomainResult<User> {
pub async fn execute(deps: &AuthDeps, cmd: LoginCommand) -> DomainResult<LoginResult> {
let email = Email::new(&cmd.email)?;
let user = deps
@@ -24,7 +24,19 @@ pub async fn execute(deps: &AuthDeps, cmd: LoginCommand) -> DomainResult<User> {
return Err(DomainError::unauthenticated(INVALID_CREDENTIALS));
}
Ok(user)
let access_token = deps.token_service.create_access_token(&user)?;
let refresh_token = if cmd.remember_me {
Some(deps.token_service.create_refresh_token(&user)?)
} else {
None
};
let expires_in = deps.token_service.token_expiry_secs();
Ok(LoginResult {
access_token,
refresh_token,
expires_in,
})
}
#[cfg(test)]

View File

@@ -2,7 +2,10 @@ pub mod commands;
pub mod deps;
pub mod login;
pub mod queries;
pub mod refresh;
pub mod register;
pub mod results;
pub use commands::{LoginCommand, RegisterCommand};
pub use deps::AuthDeps;
pub use results::LoginResult;

View File

@@ -0,0 +1,24 @@
use domain::{DomainError, DomainResult};
use super::deps::AuthDeps;
use super::results::LoginResult;
pub async fn execute(deps: &AuthDeps, refresh_token: String) -> DomainResult<LoginResult> {
let user_id = deps.token_service.validate_refresh_token(&refresh_token)?;
let user = deps
.user_query
.find_by_id(user_id)
.await?
.ok_or_else(|| DomainError::Unauthenticated("User not found".to_string()))?;
let access_token = deps.token_service.create_access_token(&user)?;
let new_refresh = Some(deps.token_service.create_refresh_token(&user)?);
let expires_in = deps.token_service.token_expiry_secs();
Ok(LoginResult {
access_token,
refresh_token: new_refresh,
expires_in,
})
}

View File

@@ -0,0 +1,6 @@
#[derive(Debug)]
pub struct LoginResult {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_in: u64,
}

View File

@@ -2,14 +2,13 @@ use std::sync::Arc;
use domain::errors::DomainResult;
use domain::ports::AuthService;
use domain::testing::{InMemoryUserRepository, NoopEventPublisher};
use domain::testing::{InMemoryUserRepository, NoopEventPublisher, NoopTokenService};
use domain::{DomainError, Email};
use crate::auth::commands::LoginCommand;
use crate::auth::deps::AuthDeps;
use crate::auth::login;
/// Fake auth service: prefixes "hashed:" for hashing, verifies by checking prefix.
struct FakeAuthService;
impl AuthService for FakeAuthService {
@@ -27,19 +26,15 @@ fn make_deps_with_user(
password_hash: &str,
) -> (AuthDeps, Arc<InMemoryUserRepository>) {
let repo = Arc::new(InMemoryUserRepository::new());
// Seed a user
let e = Email::new(email).unwrap();
let user = domain::models::User::new_local(e, password_hash);
repo.store
.lock()
.unwrap()
.insert(user.id(), user);
repo.store.lock().unwrap().insert(user.id(), user);
let deps = AuthDeps {
user_command: repo.clone(),
user_query: repo.clone(),
auth_service: Arc::new(FakeAuthService),
token_service: Arc::new(NoopTokenService::new()),
event_publisher: Arc::new(NoopEventPublisher::new()),
};
(deps, repo)
@@ -49,17 +44,38 @@ fn make_deps_with_user(
async fn login_succeeds_with_correct_credentials() {
let (deps, _) = make_deps_with_user("alice@example.com", "hashed:password123");
let user = login::execute(
let result = login::execute(
&deps,
LoginCommand {
email: "alice@example.com".into(),
password: "password123".into(),
remember_me: false,
},
)
.await
.unwrap();
assert_eq!(user.email().as_ref(), "alice@example.com");
assert!(!result.access_token.is_empty());
assert!(result.refresh_token.is_none());
}
#[tokio::test]
async fn login_with_remember_me_returns_refresh_token() {
let (deps, _) = make_deps_with_user("alice@example.com", "hashed:password123");
let result = login::execute(
&deps,
LoginCommand {
email: "alice@example.com".into(),
password: "password123".into(),
remember_me: true,
},
)
.await
.unwrap();
assert!(!result.access_token.is_empty());
assert!(result.refresh_token.is_some());
}
#[tokio::test]
@@ -71,6 +87,7 @@ async fn login_fails_with_wrong_password() {
LoginCommand {
email: "alice@example.com".into(),
password: "wrong".into(),
remember_me: false,
},
)
.await;
@@ -91,6 +108,7 @@ async fn login_fails_for_unknown_email() {
LoginCommand {
email: "nobody@example.com".into(),
password: "password123".into(),
remember_me: false,
},
)
.await;
@@ -105,19 +123,15 @@ async fn login_fails_for_unknown_email() {
#[tokio::test]
async fn login_fails_for_oidc_only_user() {
let repo = Arc::new(InMemoryUserRepository::new());
// Create an OIDC user (no password hash)
let email = Email::new("oidc@example.com").unwrap();
let user = domain::models::User::new("oidc|subject", email);
repo.store
.lock()
.unwrap()
.insert(user.id(), user);
repo.store.lock().unwrap().insert(user.id(), user);
let deps = AuthDeps {
user_command: repo.clone(),
user_query: repo.clone(),
auth_service: Arc::new(FakeAuthService),
token_service: Arc::new(NoopTokenService::new()),
event_publisher: Arc::new(NoopEventPublisher::new()),
};
@@ -126,6 +140,7 @@ async fn login_fails_for_oidc_only_user() {
LoginCommand {
email: "oidc@example.com".into(),
password: "password123".into(),
remember_me: false,
},
)
.await;
@@ -144,6 +159,7 @@ async fn login_rejects_invalid_email() {
user_command: repo.clone(),
user_query: repo.clone(),
auth_service: Arc::new(FakeAuthService),
token_service: Arc::new(NoopTokenService::new()),
event_publisher: Arc::new(NoopEventPublisher::new()),
};
@@ -152,6 +168,7 @@ async fn login_rejects_invalid_email() {
LoginCommand {
email: "not-an-email".into(),
password: "password123".into(),
remember_me: false,
},
)
.await;

View File

@@ -2,7 +2,7 @@ use std::sync::Arc;
use domain::errors::DomainResult;
use domain::ports::AuthService;
use domain::testing::{InMemoryUserRepository, NoopEventPublisher};
use domain::testing::{InMemoryUserRepository, NoopEventPublisher, NoopTokenService};
use domain::{DomainError, Email};
use crate::auth::commands::RegisterCommand;
@@ -28,6 +28,7 @@ fn make_deps() -> (AuthDeps, Arc<InMemoryUserRepository>) {
user_command: repo.clone(),
user_query: repo.clone(),
auth_service: Arc::new(FakeAuthService),
token_service: Arc::new(NoopTokenService::new()),
event_publisher: Arc::new(NoopEventPublisher::new()),
};
(deps, repo)

View File

@@ -0,0 +1,9 @@
use std::sync::Arc;
use domain::ports::IProviderRegistry;
pub struct ConfigDeps {
pub provider_registry: Arc<dyn IProviderRegistry>,
pub allow_registration: bool,
pub available_provider_types: Vec<String>,
}

View File

@@ -0,0 +1,55 @@
use domain::ports::ProviderCapabilities;
use super::deps::ConfigDeps;
use super::queries::GetConfigQuery;
pub struct ProviderInfo {
pub id: String,
pub capabilities: ProviderCapabilities,
}
pub struct SystemConfig {
pub allow_registration: bool,
pub providers: Vec<ProviderInfo>,
pub primary_capabilities: ProviderCapabilities,
pub available_provider_types: Vec<String>,
}
pub fn execute(deps: &ConfigDeps, _query: GetConfigQuery) -> SystemConfig {
let provider_ids = deps.provider_registry.provider_ids();
let primary_id = deps.provider_registry.primary_id().to_string();
let providers: Vec<ProviderInfo> = provider_ids
.iter()
.filter_map(|id| {
deps.provider_registry
.capabilities(id)
.map(|caps| ProviderInfo {
id: id.clone(),
capabilities: caps,
})
})
.collect();
let primary_capabilities = deps
.provider_registry
.capabilities(&primary_id)
.unwrap_or(ProviderCapabilities {
collections: false,
series: false,
genres: false,
tags: false,
decade: false,
search: false,
streaming_protocol: domain::ports::StreamingProtocol::DirectFile,
rescan: false,
transcode: false,
});
SystemConfig {
allow_registration: deps.allow_registration,
providers,
primary_capabilities,
available_provider_types: deps.available_provider_types.clone(),
}
}

View File

@@ -0,0 +1,6 @@
pub mod deps;
pub mod get_config;
pub mod queries;
pub use deps::ConfigDeps;
pub use queries::GetConfigQuery;

View File

@@ -0,0 +1 @@
pub struct GetConfigQuery;

View File

@@ -1,6 +1,7 @@
pub mod admin;
pub mod auth;
pub mod channels;
pub mod config;
pub mod config_snapshots;
pub mod iptv;
pub mod library;

View File

@@ -14,7 +14,7 @@ pub async fn execute(
for pid in &provider_ids {
if deps.library_query.is_sync_running(pid).await? {
return Err(DomainError::ValidationError(format!(
return Err(DomainError::Conflict(format!(
"Sync already running for provider '{pid}'"
)));
}

View File

@@ -1,7 +1,7 @@
pub struct UpsertProviderCommand {
pub id: String,
pub provider_type: String,
pub config_json: String,
pub config: serde_json::Value,
pub enabled: bool,
}

View File

@@ -32,7 +32,7 @@ async fn list_returns_all_providers() {
UpsertProviderCommand {
id: id.into(),
provider_type: "jellyfin".into(),
config_json: "{}".into(),
config: serde_json::json!({}),
enabled: true,
},
)

View File

@@ -23,7 +23,7 @@ async fn upsert_stores_provider() {
UpsertProviderCommand {
id: "jf-1".into(),
provider_type: "jellyfin".into(),
config_json: r#"{"url":"http://localhost:8096"}"#.into(),
config: serde_json::json!({"url":"http://localhost:8096"}),
enabled: true,
},
)
@@ -46,7 +46,7 @@ async fn upsert_overwrites_existing() {
UpsertProviderCommand {
id: "jf-1".into(),
provider_type: "jellyfin".into(),
config_json: r#"{"url":"http://old"}"#.into(),
config: serde_json::json!({"url":"http://old"}),
enabled: true,
},
)
@@ -58,7 +58,7 @@ async fn upsert_overwrites_existing() {
UpsertProviderCommand {
id: "jf-1".into(),
provider_type: "jellyfin".into(),
config_json: r#"{"url":"http://new"}"#.into(),
config: serde_json::json!({"url":"http://new"}),
enabled: false,
},
)

View File

@@ -5,10 +5,12 @@ use super::commands::UpsertProviderCommand;
use super::deps::ProviderDeps;
pub async fn execute(deps: &ProviderDeps, cmd: UpsertProviderCommand) -> DomainResult<()> {
let config_json = serde_json::to_string(&cmd.config)
.map_err(|e| domain::DomainError::ValidationError(format!("Invalid config: {e}")))?;
let row = ProviderConfigRow::from_persistence(
cmd.id,
cmd.provider_type,
cmd.config_json,
config_json,
cmd.enabled,
String::new(),
);

View File

@@ -1,16 +1,21 @@
use chrono::Utc;
use domain::models::CurrentBroadcast;
use domain::models::{Channel, CurrentBroadcast};
use domain::value_objects::ChannelId;
use domain::{DomainResult, ScheduleEngineService};
use super::deps::ScheduleDeps;
use super::queries::GetCurrentBroadcastQuery;
pub struct BroadcastWithChannel {
pub broadcast: CurrentBroadcast,
pub channel: Option<Channel>,
}
pub async fn execute(
deps: &ScheduleDeps,
query: GetCurrentBroadcastQuery,
) -> DomainResult<Option<CurrentBroadcast>> {
) -> DomainResult<Option<BroadcastWithChannel>> {
let channel_id = ChannelId::from(query.channel_id);
let now = Utc::now();
@@ -19,7 +24,13 @@ pub async fn execute(
None => return Ok(None),
};
Ok(ScheduleEngineService::get_current_broadcast(&schedule, now))
match ScheduleEngineService::get_current_broadcast(&schedule, now) {
Some(broadcast) => {
let channel = deps.channel_query.find_by_id(channel_id).await?;
Ok(Some(BroadcastWithChannel { broadcast, channel }))
}
None => Ok(None),
}
}
#[cfg(test)]

View File

@@ -1,13 +1,30 @@
use chrono::Utc;
use domain::ports::StreamQuality;
use domain::value_objects::MediaItemId;
use domain::DomainResult;
use domain::value_objects::ChannelId;
use domain::{DomainResult, ScheduleEngineService};
use super::deps::ScheduleDeps;
use super::queries::GetStreamUrlQuery;
pub async fn execute(deps: &ScheduleDeps, query: GetStreamUrlQuery) -> DomainResult<String> {
let item_id = MediaItemId::new(&query.item_id);
deps.schedule_engine
pub async fn execute(deps: &ScheduleDeps, query: GetStreamUrlQuery) -> DomainResult<Option<String>> {
let channel_id = ChannelId::from(query.channel_id);
let now = Utc::now();
let schedule = match deps.schedule_query.find_active(channel_id, now).await? {
Some(s) => s,
None => return Ok(None),
};
let broadcast = match ScheduleEngineService::get_current_broadcast(&schedule, now) {
Some(b) => b,
None => return Ok(None),
};
let item_id = broadcast.slot().item().id().clone();
let url = deps
.schedule_engine
.get_stream_url(&item_id, &StreamQuality::Direct)
.await
.await?;
Ok(Some(url))
}

View File

@@ -14,7 +14,6 @@ pub struct GetEpgQuery {
pub struct GetStreamUrlQuery {
pub channel_id: Uuid,
pub item_id: String,
}
pub struct ListHistoryQuery {