From 6fd47f2d93c8b4813dd845027683424640c0036a Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 12 Jul 2026 01:57:38 +0200 Subject: [PATCH] application: channels bounded context CQRS use cases (create/update/delete/get/list/list_by_owner), ownership checks, auto-snapshot on config change. Channel setters added to domain model. --- crates/application/src/channels/commands.rs | 32 +++ crates/application/src/channels/create.rs | 29 ++ crates/application/src/channels/delete.rs | 36 +++ crates/application/src/channels/deps.rs | 15 ++ crates/application/src/channels/get.rs | 16 ++ crates/application/src/channels/list.rs | 14 + .../application/src/channels/list_by_owner.rs | 16 ++ crates/application/src/channels/mod.rs | 13 + crates/application/src/channels/queries.rs | 14 + .../application/src/channels/tests/create.rs | 65 +++++ .../application/src/channels/tests/delete.rs | 101 +++++++ crates/application/src/channels/tests/get.rs | 66 +++++ crates/application/src/channels/tests/list.rs | 51 ++++ .../src/channels/tests/list_by_owner.rs | 83 ++++++ .../application/src/channels/tests/update.rs | 251 ++++++++++++++++++ crates/application/src/channels/update.rs | 68 +++++ crates/application/src/lib.rs | 1 + crates/domain/src/models/channel.rs | 38 +++ 18 files changed, 909 insertions(+) create mode 100644 crates/application/src/channels/commands.rs create mode 100644 crates/application/src/channels/create.rs create mode 100644 crates/application/src/channels/delete.rs create mode 100644 crates/application/src/channels/deps.rs create mode 100644 crates/application/src/channels/get.rs create mode 100644 crates/application/src/channels/list.rs create mode 100644 crates/application/src/channels/list_by_owner.rs create mode 100644 crates/application/src/channels/mod.rs create mode 100644 crates/application/src/channels/queries.rs create mode 100644 crates/application/src/channels/tests/create.rs create mode 100644 crates/application/src/channels/tests/delete.rs create mode 100644 crates/application/src/channels/tests/get.rs create mode 100644 crates/application/src/channels/tests/list.rs create mode 100644 crates/application/src/channels/tests/list_by_owner.rs create mode 100644 crates/application/src/channels/tests/update.rs create mode 100644 crates/application/src/channels/update.rs diff --git a/crates/application/src/channels/commands.rs b/crates/application/src/channels/commands.rs new file mode 100644 index 0000000..1fcca9a --- /dev/null +++ b/crates/application/src/channels/commands.rs @@ -0,0 +1,32 @@ +use uuid::Uuid; + +use domain::models::ScheduleConfig; +use domain::value_objects::RecyclePolicy; + +/// Create a new channel. +pub struct CreateChannelCommand { + pub owner_id: Uuid, + pub name: String, + pub timezone: String, +} + +/// Update an existing channel (partial — only `Some` fields are applied). +pub struct UpdateChannelCommand { + pub channel_id: Uuid, + /// Used for ownership check. + pub owner_id: Uuid, + pub name: Option, + /// `Some(None)` clears the description; `None` leaves it unchanged. + pub description: Option>, + pub timezone: Option, + pub schedule_config: Option, + pub recycle_policy: Option, + pub auto_schedule: Option, +} + +/// Delete a channel. +pub struct DeleteChannelCommand { + pub channel_id: Uuid, + /// Used for ownership check. + pub owner_id: Uuid, +} diff --git a/crates/application/src/channels/create.rs b/crates/application/src/channels/create.rs new file mode 100644 index 0000000..1e0373e --- /dev/null +++ b/crates/application/src/channels/create.rs @@ -0,0 +1,29 @@ +use domain::events::DomainEvent; +use domain::models::Channel; +use domain::value_objects::UserId; +use domain::DomainResult; + +use super::commands::CreateChannelCommand; +use super::deps::ChannelCommandDeps; + +/// Create a new channel. +/// +/// Flow: convert raw IDs -> build Channel -> save -> publish event -> return. +pub async fn execute(deps: &ChannelCommandDeps, cmd: CreateChannelCommand) -> DomainResult { + let owner_id = UserId::from(cmd.owner_id); + let channel = Channel::new(owner_id, cmd.name, cmd.timezone); + + deps.channel_command.save(&channel).await?; + + deps.event_publisher + .publish(DomainEvent::ChannelCreated { + channel_id: channel.id(), + }) + .await?; + + Ok(channel) +} + +#[cfg(test)] +#[path = "tests/create.rs"] +mod tests; diff --git a/crates/application/src/channels/delete.rs b/crates/application/src/channels/delete.rs new file mode 100644 index 0000000..f72114a --- /dev/null +++ b/crates/application/src/channels/delete.rs @@ -0,0 +1,36 @@ +use domain::events::DomainEvent; +use domain::value_objects::{ChannelId, UserId}; +use domain::{DomainError, DomainResult}; + +use super::commands::DeleteChannelCommand; +use super::deps::ChannelCommandDeps; + +/// Delete a channel after verifying ownership. +/// +/// Flow: find channel -> verify ownership -> delete -> publish event. +pub async fn execute(deps: &ChannelCommandDeps, cmd: DeleteChannelCommand) -> DomainResult<()> { + let channel_id = ChannelId::from(cmd.channel_id); + let owner_id = UserId::from(cmd.owner_id); + + let channel = deps + .channel_query + .find_by_id(channel_id) + .await? + .ok_or(DomainError::ChannelNotFound(cmd.channel_id))?; + + if channel.owner_id() != owner_id { + return Err(DomainError::forbidden("You don't own this channel")); + } + + deps.channel_command.delete(channel_id).await?; + + deps.event_publisher + .publish(DomainEvent::ChannelDeleted { channel_id }) + .await?; + + Ok(()) +} + +#[cfg(test)] +#[path = "tests/delete.rs"] +mod tests; diff --git a/crates/application/src/channels/deps.rs b/crates/application/src/channels/deps.rs new file mode 100644 index 0000000..01d5b0c --- /dev/null +++ b/crates/application/src/channels/deps.rs @@ -0,0 +1,15 @@ +use std::sync::Arc; + +use domain::ports::{ChannelCommand, ChannelQuery, EventPublisher}; + +/// Dependencies for channel write use cases (create, update, delete). +pub struct ChannelCommandDeps { + pub channel_command: Arc, + pub channel_query: Arc, + pub event_publisher: Arc, +} + +/// Dependencies for channel read use cases (get, list, list_by_owner). +pub struct ChannelQueryDeps { + pub channel_query: Arc, +} diff --git a/crates/application/src/channels/get.rs b/crates/application/src/channels/get.rs new file mode 100644 index 0000000..e9234c9 --- /dev/null +++ b/crates/application/src/channels/get.rs @@ -0,0 +1,16 @@ +use domain::models::Channel; +use domain::value_objects::ChannelId; +use domain::DomainResult; + +use super::deps::ChannelQueryDeps; +use super::queries::GetChannelQuery; + +/// Get a single channel by ID. +pub async fn execute(deps: &ChannelQueryDeps, query: GetChannelQuery) -> DomainResult> { + let channel_id = ChannelId::from(query.channel_id); + deps.channel_query.find_by_id(channel_id).await +} + +#[cfg(test)] +#[path = "tests/get.rs"] +mod tests; diff --git a/crates/application/src/channels/list.rs b/crates/application/src/channels/list.rs new file mode 100644 index 0000000..4aa1003 --- /dev/null +++ b/crates/application/src/channels/list.rs @@ -0,0 +1,14 @@ +use domain::models::Channel; +use domain::DomainResult; + +use super::deps::ChannelQueryDeps; +use super::queries::ListChannelsQuery; + +/// List all channels. +pub async fn execute(deps: &ChannelQueryDeps, _query: ListChannelsQuery) -> DomainResult> { + deps.channel_query.find_all().await +} + +#[cfg(test)] +#[path = "tests/list.rs"] +mod tests; diff --git a/crates/application/src/channels/list_by_owner.rs b/crates/application/src/channels/list_by_owner.rs new file mode 100644 index 0000000..76d1ec5 --- /dev/null +++ b/crates/application/src/channels/list_by_owner.rs @@ -0,0 +1,16 @@ +use domain::models::Channel; +use domain::value_objects::UserId; +use domain::DomainResult; + +use super::deps::ChannelQueryDeps; +use super::queries::ListByOwnerQuery; + +/// List channels belonging to a specific owner. +pub async fn execute(deps: &ChannelQueryDeps, query: ListByOwnerQuery) -> DomainResult> { + let owner_id = UserId::from(query.owner_id); + deps.channel_query.find_by_owner(owner_id).await +} + +#[cfg(test)] +#[path = "tests/list_by_owner.rs"] +mod tests; diff --git a/crates/application/src/channels/mod.rs b/crates/application/src/channels/mod.rs new file mode 100644 index 0000000..1040e43 --- /dev/null +++ b/crates/application/src/channels/mod.rs @@ -0,0 +1,13 @@ +pub mod commands; +pub mod create; +pub mod delete; +pub mod deps; +pub mod get; +pub mod list; +pub mod list_by_owner; +pub mod queries; +pub mod update; + +pub use commands::{CreateChannelCommand, DeleteChannelCommand, UpdateChannelCommand}; +pub use deps::{ChannelCommandDeps, ChannelQueryDeps}; +pub use queries::{GetChannelQuery, ListByOwnerQuery, ListChannelsQuery}; diff --git a/crates/application/src/channels/queries.rs b/crates/application/src/channels/queries.rs new file mode 100644 index 0000000..5971a5b --- /dev/null +++ b/crates/application/src/channels/queries.rs @@ -0,0 +1,14 @@ +use uuid::Uuid; + +/// Fetch a single channel by ID. +pub struct GetChannelQuery { + pub channel_id: Uuid, +} + +/// List all channels. +pub struct ListChannelsQuery; + +/// List channels belonging to a specific owner. +pub struct ListByOwnerQuery { + pub owner_id: Uuid, +} diff --git a/crates/application/src/channels/tests/create.rs b/crates/application/src/channels/tests/create.rs new file mode 100644 index 0000000..354d4bf --- /dev/null +++ b/crates/application/src/channels/tests/create.rs @@ -0,0 +1,65 @@ +use std::sync::Arc; + +use domain::testing::{InMemoryChannelRepository, NoopEventPublisher}; +use domain::value_objects::UserId; + +use crate::channels::commands::CreateChannelCommand; +use crate::channels::create; +use crate::channels::deps::ChannelCommandDeps; + +fn make_deps() -> (ChannelCommandDeps, Arc) { + let repo = Arc::new(InMemoryChannelRepository::new()); + let deps = ChannelCommandDeps { + channel_command: repo.clone(), + channel_query: repo.clone(), + event_publisher: Arc::new(NoopEventPublisher::new()), + }; + (deps, repo) +} + +#[tokio::test] +async fn creates_channel_successfully() { + let (deps, repo) = make_deps(); + let owner = UserId::generate(); + + let channel = create::execute( + &deps, + CreateChannelCommand { + owner_id: owner.value(), + name: "Movie Night".into(), + timezone: "America/New_York".into(), + }, + ) + .await + .unwrap(); + + assert_eq!(channel.name(), "Movie Night"); + assert_eq!(channel.timezone(), "America/New_York"); + assert_eq!(channel.owner_id(), owner); + + // Verify persisted + let stored = repo.channels.lock().unwrap(); + assert_eq!(stored.len(), 1); + let persisted = stored.values().next().unwrap(); + assert_eq!(persisted.id(), channel.id()); +} + +#[tokio::test] +async fn create_returns_default_config() { + let (deps, _) = make_deps(); + + let channel = create::execute( + &deps, + CreateChannelCommand { + owner_id: UserId::generate().value(), + name: "Defaults".into(), + timezone: "UTC".into(), + }, + ) + .await + .unwrap(); + + assert!(channel.description().is_none()); + assert!(!channel.auto_schedule()); + assert!(channel.schedule_config().day_blocks().is_empty()); +} diff --git a/crates/application/src/channels/tests/delete.rs b/crates/application/src/channels/tests/delete.rs new file mode 100644 index 0000000..4cf3b37 --- /dev/null +++ b/crates/application/src/channels/tests/delete.rs @@ -0,0 +1,101 @@ +use std::sync::Arc; + +use domain::testing::{InMemoryChannelRepository, NoopEventPublisher}; +use domain::value_objects::UserId; +use domain::DomainError; + +use crate::channels::commands::{CreateChannelCommand, DeleteChannelCommand}; +use crate::channels::deps::ChannelCommandDeps; +use crate::channels::{create, delete}; + +fn make_deps() -> (ChannelCommandDeps, Arc) { + let repo = Arc::new(InMemoryChannelRepository::new()); + let deps = ChannelCommandDeps { + channel_command: repo.clone(), + channel_query: repo.clone(), + event_publisher: Arc::new(NoopEventPublisher::new()), + }; + (deps, repo) +} + +#[tokio::test] +async fn deletes_channel_by_owner() { + let (deps, repo) = make_deps(); + let owner = UserId::generate(); + + let channel = create::execute( + &deps, + CreateChannelCommand { + owner_id: owner.value(), + name: "Doomed".into(), + timezone: "UTC".into(), + }, + ) + .await + .unwrap(); + + delete::execute( + &deps, + DeleteChannelCommand { + channel_id: channel.id().value(), + owner_id: owner.value(), + }, + ) + .await + .unwrap(); + + assert!(repo.channels.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn delete_fails_if_not_owner() { + let (deps, _) = make_deps(); + let owner = UserId::generate(); + let stranger = UserId::generate(); + + let channel = create::execute( + &deps, + CreateChannelCommand { + owner_id: owner.value(), + name: "Protected".into(), + timezone: "UTC".into(), + }, + ) + .await + .unwrap(); + + let result = delete::execute( + &deps, + DeleteChannelCommand { + channel_id: channel.id().value(), + owner_id: stranger.value(), + }, + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + DomainError::Forbidden(_) => {} + other => panic!("expected Forbidden, got: {:?}", other), + } +} + +#[tokio::test] +async fn delete_nonexistent_channel_returns_not_found() { + let (deps, _) = make_deps(); + + let result = delete::execute( + &deps, + DeleteChannelCommand { + channel_id: uuid::Uuid::new_v4(), + owner_id: uuid::Uuid::new_v4(), + }, + ) + .await; + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + DomainError::ChannelNotFound(_) + )); +} diff --git a/crates/application/src/channels/tests/get.rs b/crates/application/src/channels/tests/get.rs new file mode 100644 index 0000000..9c078cd --- /dev/null +++ b/crates/application/src/channels/tests/get.rs @@ -0,0 +1,66 @@ +use std::sync::Arc; + +use domain::testing::{InMemoryChannelRepository, NoopEventPublisher}; +use domain::value_objects::UserId; + +use crate::channels::commands::CreateChannelCommand; +use crate::channels::deps::{ChannelCommandDeps, ChannelQueryDeps}; +use crate::channels::queries::GetChannelQuery; +use crate::channels::{create, get}; + +fn make_deps() -> (ChannelCommandDeps, ChannelQueryDeps) { + let repo = Arc::new(InMemoryChannelRepository::new()); + let cmd_deps = ChannelCommandDeps { + channel_command: repo.clone(), + channel_query: repo.clone(), + event_publisher: Arc::new(NoopEventPublisher::new()), + }; + let query_deps = ChannelQueryDeps { + channel_query: repo, + }; + (cmd_deps, query_deps) +} + +#[tokio::test] +async fn get_existing_channel() { + let (cmd_deps, query_deps) = make_deps(); + + let channel = create::execute( + &cmd_deps, + CreateChannelCommand { + owner_id: UserId::generate().value(), + name: "Findable".into(), + timezone: "UTC".into(), + }, + ) + .await + .unwrap(); + + let found = get::execute( + &query_deps, + GetChannelQuery { + channel_id: channel.id().value(), + }, + ) + .await + .unwrap(); + + assert!(found.is_some()); + assert_eq!(found.unwrap().name(), "Findable"); +} + +#[tokio::test] +async fn get_nonexistent_returns_none() { + let (_, query_deps) = make_deps(); + + let found = get::execute( + &query_deps, + GetChannelQuery { + channel_id: uuid::Uuid::new_v4(), + }, + ) + .await + .unwrap(); + + assert!(found.is_none()); +} diff --git a/crates/application/src/channels/tests/list.rs b/crates/application/src/channels/tests/list.rs new file mode 100644 index 0000000..26ddf19 --- /dev/null +++ b/crates/application/src/channels/tests/list.rs @@ -0,0 +1,51 @@ +use std::sync::Arc; + +use domain::testing::{InMemoryChannelRepository, NoopEventPublisher}; +use domain::value_objects::UserId; + +use crate::channels::commands::CreateChannelCommand; +use crate::channels::deps::{ChannelCommandDeps, ChannelQueryDeps}; +use crate::channels::queries::ListChannelsQuery; +use crate::channels::{create, list}; + +fn make_deps() -> (ChannelCommandDeps, ChannelQueryDeps) { + let repo = Arc::new(InMemoryChannelRepository::new()); + let cmd_deps = ChannelCommandDeps { + channel_command: repo.clone(), + channel_query: repo.clone(), + event_publisher: Arc::new(NoopEventPublisher::new()), + }; + let query_deps = ChannelQueryDeps { + channel_query: repo, + }; + (cmd_deps, query_deps) +} + +#[tokio::test] +async fn list_empty_returns_empty() { + let (_, query_deps) = make_deps(); + + let channels = list::execute(&query_deps, ListChannelsQuery).await.unwrap(); + assert!(channels.is_empty()); +} + +#[tokio::test] +async fn list_returns_all_channels() { + let (cmd_deps, query_deps) = make_deps(); + + for name in ["A", "B", "C"] { + create::execute( + &cmd_deps, + CreateChannelCommand { + owner_id: UserId::generate().value(), + name: name.into(), + timezone: "UTC".into(), + }, + ) + .await + .unwrap(); + } + + let channels = list::execute(&query_deps, ListChannelsQuery).await.unwrap(); + assert_eq!(channels.len(), 3); +} diff --git a/crates/application/src/channels/tests/list_by_owner.rs b/crates/application/src/channels/tests/list_by_owner.rs new file mode 100644 index 0000000..ebce587 --- /dev/null +++ b/crates/application/src/channels/tests/list_by_owner.rs @@ -0,0 +1,83 @@ +use std::sync::Arc; + +use domain::testing::{InMemoryChannelRepository, NoopEventPublisher}; +use domain::value_objects::UserId; + +use crate::channels::commands::CreateChannelCommand; +use crate::channels::deps::{ChannelCommandDeps, ChannelQueryDeps}; +use crate::channels::queries::ListByOwnerQuery; +use crate::channels::{create, list_by_owner}; + +fn make_deps() -> (ChannelCommandDeps, ChannelQueryDeps) { + let repo = Arc::new(InMemoryChannelRepository::new()); + let cmd_deps = ChannelCommandDeps { + channel_command: repo.clone(), + channel_query: repo.clone(), + event_publisher: Arc::new(NoopEventPublisher::new()), + }; + let query_deps = ChannelQueryDeps { + channel_query: repo, + }; + (cmd_deps, query_deps) +} + +#[tokio::test] +async fn filters_by_owner() { + let (cmd_deps, query_deps) = make_deps(); + let alice = UserId::generate(); + let bob = UserId::generate(); + + // Alice: 2 channels + for name in ["Alice-1", "Alice-2"] { + create::execute( + &cmd_deps, + CreateChannelCommand { + owner_id: alice.value(), + name: name.into(), + timezone: "UTC".into(), + }, + ) + .await + .unwrap(); + } + + // Bob: 1 channel + create::execute( + &cmd_deps, + CreateChannelCommand { + owner_id: bob.value(), + name: "Bob-1".into(), + timezone: "UTC".into(), + }, + ) + .await + .unwrap(); + + let alice_channels = list_by_owner::execute( + &query_deps, + ListByOwnerQuery { + owner_id: alice.value(), + }, + ) + .await + .unwrap(); + + assert_eq!(alice_channels.len(), 2); + assert!(alice_channels.iter().all(|c| c.owner_id() == alice)); +} + +#[tokio::test] +async fn no_channels_returns_empty() { + let (_, query_deps) = make_deps(); + + let channels = list_by_owner::execute( + &query_deps, + ListByOwnerQuery { + owner_id: UserId::generate().value(), + }, + ) + .await + .unwrap(); + + assert!(channels.is_empty()); +} diff --git a/crates/application/src/channels/tests/update.rs b/crates/application/src/channels/tests/update.rs new file mode 100644 index 0000000..3522add --- /dev/null +++ b/crates/application/src/channels/tests/update.rs @@ -0,0 +1,251 @@ +use std::sync::Arc; + +use domain::testing::{InMemoryChannelRepository, NoopEventPublisher}; +use domain::value_objects::UserId; +use domain::DomainError; + +use crate::channels::commands::{CreateChannelCommand, UpdateChannelCommand}; +use crate::channels::deps::ChannelCommandDeps; +use crate::channels::{create, update}; + +fn make_deps() -> (ChannelCommandDeps, Arc) { + let repo = Arc::new(InMemoryChannelRepository::new()); + let deps = ChannelCommandDeps { + channel_command: repo.clone(), + channel_query: repo.clone(), + event_publisher: Arc::new(NoopEventPublisher::new()), + }; + (deps, repo) +} + +#[tokio::test] +async fn updates_channel_name() { + let (deps, _) = make_deps(); + let owner = UserId::generate(); + + let channel = create::execute( + &deps, + CreateChannelCommand { + owner_id: owner.value(), + name: "Original".into(), + timezone: "UTC".into(), + }, + ) + .await + .unwrap(); + + let updated = update::execute( + &deps, + UpdateChannelCommand { + channel_id: channel.id().value(), + owner_id: owner.value(), + name: Some("Renamed".into()), + description: None, + timezone: None, + schedule_config: None, + recycle_policy: None, + auto_schedule: None, + }, + ) + .await + .unwrap(); + + assert_eq!(updated.name(), "Renamed"); + assert_eq!(updated.timezone(), "UTC"); // unchanged +} + +#[tokio::test] +async fn update_fails_if_not_owner() { + let (deps, _) = make_deps(); + let owner = UserId::generate(); + let stranger = UserId::generate(); + + let channel = create::execute( + &deps, + CreateChannelCommand { + owner_id: owner.value(), + name: "Protected".into(), + timezone: "UTC".into(), + }, + ) + .await + .unwrap(); + + let result = update::execute( + &deps, + UpdateChannelCommand { + channel_id: channel.id().value(), + owner_id: stranger.value(), + name: Some("Hacked".into()), + description: None, + timezone: None, + schedule_config: None, + recycle_policy: None, + auto_schedule: None, + }, + ) + .await; + + assert!(result.is_err()); + match result.unwrap_err() { + DomainError::Forbidden(_) => {} + other => panic!("expected Forbidden, got: {:?}", other), + } +} + +#[tokio::test] +async fn update_nonexistent_channel_returns_not_found() { + let (deps, _) = make_deps(); + + let result = update::execute( + &deps, + UpdateChannelCommand { + channel_id: uuid::Uuid::new_v4(), + owner_id: uuid::Uuid::new_v4(), + name: Some("Ghost".into()), + description: None, + timezone: None, + schedule_config: None, + recycle_policy: None, + auto_schedule: None, + }, + ) + .await; + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + DomainError::ChannelNotFound(_) + )); +} + +#[tokio::test] +async fn update_config_creates_snapshot() { + let (deps, repo) = make_deps(); + let owner = UserId::generate(); + + let channel = create::execute( + &deps, + CreateChannelCommand { + owner_id: owner.value(), + name: "Snapshotted".into(), + timezone: "UTC".into(), + }, + ) + .await + .unwrap(); + + // Update with new schedule_config + let new_config = domain::models::ScheduleConfig::default(); + update::execute( + &deps, + UpdateChannelCommand { + channel_id: channel.id().value(), + owner_id: owner.value(), + name: None, + description: None, + timezone: None, + schedule_config: Some(new_config), + recycle_policy: None, + auto_schedule: None, + }, + ) + .await + .unwrap(); + + // Verify a config snapshot was created + let snapshots = repo.snapshots.lock().unwrap(); + assert_eq!(snapshots.len(), 1); + assert_eq!(snapshots[0].channel_id(), channel.id()); +} + +#[tokio::test] +async fn update_without_config_skips_snapshot() { + let (deps, repo) = make_deps(); + let owner = UserId::generate(); + + let channel = create::execute( + &deps, + CreateChannelCommand { + owner_id: owner.value(), + name: "NoSnapshot".into(), + timezone: "UTC".into(), + }, + ) + .await + .unwrap(); + + // Update name only — no config change + update::execute( + &deps, + UpdateChannelCommand { + channel_id: channel.id().value(), + owner_id: owner.value(), + name: Some("Renamed".into()), + description: None, + timezone: None, + schedule_config: None, + recycle_policy: None, + auto_schedule: None, + }, + ) + .await + .unwrap(); + + // No snapshot should exist + let snapshots = repo.snapshots.lock().unwrap(); + assert!(snapshots.is_empty()); +} + +#[tokio::test] +async fn update_description_clear() { + let (deps, _) = make_deps(); + let owner = UserId::generate(); + + let channel = create::execute( + &deps, + CreateChannelCommand { + owner_id: owner.value(), + name: "Desc Test".into(), + timezone: "UTC".into(), + }, + ) + .await + .unwrap(); + + // Set description + let updated = update::execute( + &deps, + UpdateChannelCommand { + channel_id: channel.id().value(), + owner_id: owner.value(), + name: None, + description: Some(Some("A description".into())), + timezone: None, + schedule_config: None, + recycle_policy: None, + auto_schedule: None, + }, + ) + .await + .unwrap(); + assert_eq!(updated.description(), Some("A description")); + + // Clear description with Some(None) + let cleared = update::execute( + &deps, + UpdateChannelCommand { + channel_id: channel.id().value(), + owner_id: owner.value(), + name: None, + description: Some(None), + timezone: None, + schedule_config: None, + recycle_policy: None, + auto_schedule: None, + }, + ) + .await + .unwrap(); + assert!(cleared.description().is_none()); +} diff --git a/crates/application/src/channels/update.rs b/crates/application/src/channels/update.rs new file mode 100644 index 0000000..a4de223 --- /dev/null +++ b/crates/application/src/channels/update.rs @@ -0,0 +1,68 @@ +use domain::events::DomainEvent; +use domain::models::Channel; +use domain::value_objects::{ChannelId, UserId}; +use domain::{DomainError, DomainResult}; + +use super::commands::UpdateChannelCommand; +use super::deps::ChannelCommandDeps; + +/// Update an existing channel. +/// +/// Flow: find channel -> verify ownership -> snapshot config if changed -> +/// apply updates -> save -> publish event -> return. +pub async fn execute(deps: &ChannelCommandDeps, cmd: UpdateChannelCommand) -> DomainResult { + let channel_id = ChannelId::from(cmd.channel_id); + let owner_id = UserId::from(cmd.owner_id); + + let mut channel = deps + .channel_query + .find_by_id(channel_id) + .await? + .ok_or(DomainError::ChannelNotFound(cmd.channel_id))?; + + // Ownership check + if channel.owner_id() != owner_id { + return Err(DomainError::forbidden("You don't own this channel")); + } + + // Auto-snapshot the current config before overwriting + if cmd.schedule_config.is_some() { + deps.channel_command + .save_config_snapshot(channel_id, channel.schedule_config(), None) + .await?; + } + + // Apply partial updates + if let Some(name) = cmd.name { + channel.set_name(name); + } + if let Some(description) = cmd.description { + channel.set_description(description); + } + if let Some(timezone) = cmd.timezone { + channel.set_timezone(timezone); + } + if let Some(config) = cmd.schedule_config { + channel.set_schedule_config(config); + } + if let Some(policy) = cmd.recycle_policy { + channel.set_recycle_policy(policy); + } + if let Some(auto) = cmd.auto_schedule { + channel.set_auto_schedule(auto); + } + + deps.channel_command.save(&channel).await?; + + deps.event_publisher + .publish(DomainEvent::ChannelUpdated { + channel_id: channel.id(), + }) + .await?; + + Ok(channel) +} + +#[cfg(test)] +#[path = "tests/update.rs"] +mod tests; diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 0e4a05d..24782a6 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -1 +1,2 @@ pub mod auth; +pub mod channels; diff --git a/crates/domain/src/models/channel.rs b/crates/domain/src/models/channel.rs index f19ee54..04341e2 100644 --- a/crates/domain/src/models/channel.rs +++ b/crates/domain/src/models/channel.rs @@ -194,6 +194,44 @@ impl Channel { pub fn updated_at(&self) -> DateTime { self.updated_at } + + // -- Setters -- + + /// Update the channel name and touch `updated_at`. + pub fn set_name(&mut self, name: impl Into) { + self.name = name.into(); + self.updated_at = Utc::now(); + } + + /// Update the description and touch `updated_at`. + pub fn set_description(&mut self, description: Option) { + self.description = description; + self.updated_at = Utc::now(); + } + + /// Update the timezone and touch `updated_at`. + pub fn set_timezone(&mut self, timezone: impl Into) { + self.timezone = timezone.into(); + self.updated_at = Utc::now(); + } + + /// Replace the schedule config and touch `updated_at`. + pub fn set_schedule_config(&mut self, config: ScheduleConfig) { + self.schedule_config = config; + self.updated_at = Utc::now(); + } + + /// Replace the recycle policy and touch `updated_at`. + pub fn set_recycle_policy(&mut self, policy: RecyclePolicy) { + self.recycle_policy = policy; + self.updated_at = Utc::now(); + } + + /// Toggle auto-schedule and touch `updated_at`. + pub fn set_auto_schedule(&mut self, enabled: bool) { + self.auto_schedule = enabled; + self.updated_at = Utc::now(); + } } // ============================================================================