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:
@@ -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)]
|
||||
|
||||
@@ -6,4 +6,5 @@ pub struct RegisterCommand {
|
||||
pub struct LoginCommand {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub remember_me: bool,
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
24
crates/application/src/auth/refresh.rs
Normal file
24
crates/application/src/auth/refresh.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
6
crates/application/src/auth/results.rs
Normal file
6
crates/application/src/auth/results.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
#[derive(Debug)]
|
||||
pub struct LoginResult {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_in: u64,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
9
crates/application/src/config/deps.rs
Normal file
9
crates/application/src/config/deps.rs
Normal 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>,
|
||||
}
|
||||
55
crates/application/src/config/get_config.rs
Normal file
55
crates/application/src/config/get_config.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
6
crates/application/src/config/mod.rs
Normal file
6
crates/application/src/config/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod deps;
|
||||
pub mod get_config;
|
||||
pub mod queries;
|
||||
|
||||
pub use deps::ConfigDeps;
|
||||
pub use queries::GetConfigQuery;
|
||||
1
crates/application/src/config/queries.rs
Normal file
1
crates/application/src/config/queries.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub struct GetConfigQuery;
|
||||
@@ -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;
|
||||
|
||||
@@ -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}'"
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ pub struct GetEpgQuery {
|
||||
|
||||
pub struct GetStreamUrlQuery {
|
||||
pub channel_id: Uuid,
|
||||
pub item_id: String,
|
||||
}
|
||||
|
||||
pub struct ListHistoryQuery {
|
||||
|
||||
Reference in New Issue
Block a user