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.
This commit is contained in:
32
crates/application/src/channels/commands.rs
Normal file
32
crates/application/src/channels/commands.rs
Normal file
@@ -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<String>,
|
||||
/// `Some(None)` clears the description; `None` leaves it unchanged.
|
||||
pub description: Option<Option<String>>,
|
||||
pub timezone: Option<String>,
|
||||
pub schedule_config: Option<ScheduleConfig>,
|
||||
pub recycle_policy: Option<RecyclePolicy>,
|
||||
pub auto_schedule: Option<bool>,
|
||||
}
|
||||
|
||||
/// Delete a channel.
|
||||
pub struct DeleteChannelCommand {
|
||||
pub channel_id: Uuid,
|
||||
/// Used for ownership check.
|
||||
pub owner_id: Uuid,
|
||||
}
|
||||
29
crates/application/src/channels/create.rs
Normal file
29
crates/application/src/channels/create.rs
Normal file
@@ -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<Channel> {
|
||||
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;
|
||||
36
crates/application/src/channels/delete.rs
Normal file
36
crates/application/src/channels/delete.rs
Normal file
@@ -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;
|
||||
15
crates/application/src/channels/deps.rs
Normal file
15
crates/application/src/channels/deps.rs
Normal file
@@ -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<dyn ChannelCommand>,
|
||||
pub channel_query: Arc<dyn ChannelQuery>,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
}
|
||||
|
||||
/// Dependencies for channel read use cases (get, list, list_by_owner).
|
||||
pub struct ChannelQueryDeps {
|
||||
pub channel_query: Arc<dyn ChannelQuery>,
|
||||
}
|
||||
16
crates/application/src/channels/get.rs
Normal file
16
crates/application/src/channels/get.rs
Normal file
@@ -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<Option<Channel>> {
|
||||
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;
|
||||
14
crates/application/src/channels/list.rs
Normal file
14
crates/application/src/channels/list.rs
Normal file
@@ -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<Vec<Channel>> {
|
||||
deps.channel_query.find_all().await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/list.rs"]
|
||||
mod tests;
|
||||
16
crates/application/src/channels/list_by_owner.rs
Normal file
16
crates/application/src/channels/list_by_owner.rs
Normal file
@@ -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<Vec<Channel>> {
|
||||
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;
|
||||
13
crates/application/src/channels/mod.rs
Normal file
13
crates/application/src/channels/mod.rs
Normal file
@@ -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};
|
||||
14
crates/application/src/channels/queries.rs
Normal file
14
crates/application/src/channels/queries.rs
Normal file
@@ -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,
|
||||
}
|
||||
65
crates/application/src/channels/tests/create.rs
Normal file
65
crates/application/src/channels/tests/create.rs
Normal file
@@ -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<InMemoryChannelRepository>) {
|
||||
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());
|
||||
}
|
||||
101
crates/application/src/channels/tests/delete.rs
Normal file
101
crates/application/src/channels/tests/delete.rs
Normal file
@@ -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<InMemoryChannelRepository>) {
|
||||
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(_)
|
||||
));
|
||||
}
|
||||
66
crates/application/src/channels/tests/get.rs
Normal file
66
crates/application/src/channels/tests/get.rs
Normal file
@@ -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());
|
||||
}
|
||||
51
crates/application/src/channels/tests/list.rs
Normal file
51
crates/application/src/channels/tests/list.rs
Normal file
@@ -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);
|
||||
}
|
||||
83
crates/application/src/channels/tests/list_by_owner.rs
Normal file
83
crates/application/src/channels/tests/list_by_owner.rs
Normal file
@@ -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());
|
||||
}
|
||||
251
crates/application/src/channels/tests/update.rs
Normal file
251
crates/application/src/channels/tests/update.rs
Normal file
@@ -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<InMemoryChannelRepository>) {
|
||||
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());
|
||||
}
|
||||
68
crates/application/src/channels/update.rs
Normal file
68
crates/application/src/channels/update.rs
Normal file
@@ -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<Channel> {
|
||||
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;
|
||||
@@ -1 +1,2 @@
|
||||
pub mod auth;
|
||||
pub mod channels;
|
||||
|
||||
@@ -194,6 +194,44 @@ impl Channel {
|
||||
pub fn updated_at(&self) -> DateTime<Utc> {
|
||||
self.updated_at
|
||||
}
|
||||
|
||||
// -- Setters --
|
||||
|
||||
/// Update the channel name and touch `updated_at`.
|
||||
pub fn set_name(&mut self, name: impl Into<String>) {
|
||||
self.name = name.into();
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Update the description and touch `updated_at`.
|
||||
pub fn set_description(&mut self, description: Option<String>) {
|
||||
self.description = description;
|
||||
self.updated_at = Utc::now();
|
||||
}
|
||||
|
||||
/// Update the timezone and touch `updated_at`.
|
||||
pub fn set_timezone(&mut self, timezone: impl Into<String>) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user