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:
2026-07-12 01:57:38 +02:00
parent 2976600d12
commit 6fd47f2d93
18 changed files with 909 additions and 0 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -1 +1,2 @@
pub mod auth;
pub mod channels;