#![cfg(all(feature = "testing", feature = "derive"))]
#![allow(
clippy::unwrap_used,
clippy::expect_used,
unused_must_use,
reason = "test code; terminal then_expect_* assertions intentionally drop the returned fixture"
)]
use mnesis::testing::SagaFixture;
use mnesis::{AggregateState, DomainEvent, Events, Message, React, Saga, events};
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
struct OrderId([u8; 8]);
impl OrderId {
const fn new(n: u64) -> Self {
Self(n.to_le_bytes())
}
}
impl core::fmt::Display for OrderId {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{}", u64::from_le_bytes(self.0))
}
}
impl AsRef<[u8]> for OrderId {
fn as_ref(&self) -> &[u8] {
&self.0
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum SagaEvent {
PaymentRequested,
OrderCompleted,
}
impl Message for SagaEvent {}
impl DomainEvent for SagaEvent {
fn name(&self) -> &'static str {
match self {
Self::PaymentRequested => "PaymentRequested",
Self::OrderCompleted => "OrderCompleted",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum Intent {
TakePayment,
}
impl Message for Intent {}
#[derive(Clone, Debug, Default, PartialEq)]
struct OrderSagaState {
payment_requested: bool,
completed: bool,
}
impl AggregateState for OrderSagaState {
type Event = SagaEvent;
fn initial() -> Self {
Self::default()
}
fn apply(mut self, event: &SagaEvent) -> Self {
match event {
SagaEvent::PaymentRequested => self.payment_requested = true,
SagaEvent::OrderCompleted => self.completed = true,
}
self
}
}
#[derive(Debug, PartialEq, thiserror::Error)]
#[error("order saga error")]
enum OrderSagaError {
EmptyOrder,
}
#[derive(Debug)]
struct OrderPlaced {
id: u64,
total: u64,
}
impl Message for OrderPlaced {}
impl DomainEvent for OrderPlaced {
fn name(&self) -> &'static str {
"OrderPlaced"
}
}
#[derive(Debug)]
struct PaymentSettled {
id: u64,
}
impl Message for PaymentSettled {}
impl DomainEvent for PaymentSettled {
fn name(&self) -> &'static str {
"PaymentSettled"
}
}
#[mnesis::aggregate(state = OrderSagaState, error = OrderSagaError, id = OrderId)]
struct OrderSaga;
impl Saga for OrderSaga {
type CorrelationKey = u64;
type Command = Intent;
fn intent_for(event: &SagaEvent) -> Option<Intent> {
match event {
SagaEvent::PaymentRequested => Some(Intent::TakePayment),
SagaEvent::OrderCompleted => None,
}
}
}
impl React<OrderPlaced> for OrderSaga {
fn correlate(event: &OrderPlaced) -> Option<u64> {
Some(event.id)
}
fn react(
state: &OrderSagaState,
event: &OrderPlaced,
) -> Result<Option<Events<SagaEvent>>, OrderSagaError> {
if event.total == 0 {
return Err(OrderSagaError::EmptyOrder);
}
if state.payment_requested {
return Ok(None);
}
Ok(Some(events![SagaEvent::PaymentRequested]))
}
}
impl React<PaymentSettled> for OrderSaga {
fn correlate(event: &PaymentSettled) -> Option<u64> {
Some(event.id)
}
fn react(
_state: &OrderSagaState,
_event: &PaymentSettled,
) -> Result<Option<Events<SagaEvent>>, OrderSagaError> {
Ok(Some(events![SagaEvent::OrderCompleted]))
}
}
#[derive(Debug)]
struct OrderExpedited {
id: u64,
}
impl Message for OrderExpedited {}
impl DomainEvent for OrderExpedited {
fn name(&self) -> &'static str {
"OrderExpedited"
}
}
impl React<OrderExpedited, 1> for OrderSaga {
fn correlate(event: &OrderExpedited) -> Option<u64> {
Some(event.id)
}
fn react(
_state: &OrderSagaState,
_event: &OrderExpedited,
) -> Result<Option<Events<SagaEvent, 1>>, OrderSagaError> {
Ok(Some(events![
SagaEvent::PaymentRequested,
SagaEvent::OrderCompleted
]))
}
}
#[test]
fn react_on_empty_history_produces_event_and_intent() {
SagaFixture::<OrderSaga>::new()
.given([])
.when(&OrderPlaced { id: 1, total: 100 })
.then_expect_events([SagaEvent::PaymentRequested])
.then_expect_commands([Intent::TakePayment])
.then_expect_state(|s| assert!(s.payment_requested));
}
#[test]
fn with_id_constructs_equivalently_to_new() {
SagaFixture::<OrderSaga>::with_id(OrderId::new(42))
.given([])
.when(&OrderPlaced { id: 42, total: 5 })
.then_expect_events([SagaEvent::PaymentRequested]);
}
#[test]
fn internal_only_event_projects_no_intent() {
SagaFixture::<OrderSaga>::new()
.given([SagaEvent::PaymentRequested])
.when(&PaymentSettled { id: 1 })
.then_expect_events([SagaEvent::OrderCompleted])
.then_expect_commands([])
.then_expect_state(|s| assert!(s.completed));
}
#[test]
fn multi_event_react_projects_intents_in_order_filtering_internal() {
SagaFixture::<OrderSaga>::new()
.given([])
.when(&OrderExpedited { id: 1 })
.then_expect_events([SagaEvent::PaymentRequested, SagaEvent::OrderCompleted])
.then_expect_commands([Intent::TakePayment])
.then_expect_state(|s| assert!(s.payment_requested && s.completed));
}
#[test]
fn sequence_react_after_prior_own_history_ignores_duplicate() {
SagaFixture::<OrderSaga>::new()
.given([SagaEvent::PaymentRequested])
.when(&OrderPlaced { id: 1, total: 100 })
.then_expect_ignored()
.then_expect_state(|s| assert!(s.payment_requested && !s.completed));
}
#[test]
fn lifecycle_replay_full_history_reaches_terminal_state() {
SagaFixture::<OrderSaga>::new()
.given([SagaEvent::PaymentRequested, SagaEvent::OrderCompleted])
.then_expect_state(|s| assert!(s.payment_requested && s.completed));
}
#[test]
#[should_panic(expected = "replaying saga history event")]
fn lifecycle_malformed_history_panics_in_given() {
small_limit::trigger_panic();
}
mod small_limit {
use super::{Intent, OrderId, OrderSagaError};
use mnesis::testing::SagaFixture;
use mnesis::{Aggregate, AggregateState, DomainEvent, Message, Saga};
use std::num::NonZeroUsize;
#[derive(Clone, Debug, PartialEq, Eq)]
enum Ev {
Tick,
}
impl Message for Ev {}
impl DomainEvent for Ev {
fn name(&self) -> &'static str {
"Tick"
}
}
#[derive(Debug)]
struct St;
impl AggregateState for St {
type Event = Ev;
fn initial() -> Self {
Self
}
fn apply(self, _: &Ev) -> Self {
self
}
}
struct Tiny;
impl Aggregate for Tiny {
type State = St;
type Error = OrderSagaError;
type Id = OrderId;
const MAX_REHYDRATION_EVENTS: NonZeroUsize = NonZeroUsize::new(1).unwrap();
}
impl Saga for Tiny {
type CorrelationKey = u64;
type Command = Intent;
fn intent_for(_: &Ev) -> Option<Intent> {
None
}
}
pub fn trigger_panic() {
let _ = SagaFixture::<Tiny>::new().given([Ev::Tick, Ev::Tick]);
}
}
#[test]
fn boundary_react_rejects_invalid_event_exact() {
SagaFixture::<OrderSaga>::new()
.given([])
.when(&OrderPlaced { id: 1, total: 0 })
.then_expect_error(OrderSagaError::EmptyOrder)
.then_expect_state(|s| assert!(!s.payment_requested));
}
#[test]
fn boundary_react_rejects_invalid_event_matching() {
SagaFixture::<OrderSaga>::new()
.given([])
.when(&OrderPlaced { id: 1, total: 0 })
.then_expect_error_matching(|e| matches!(e, OrderSagaError::EmptyOrder));
}
#[test]
fn correlate_extracts_key_per_upstream_event_type() {
assert_eq!(
<OrderSaga as React<OrderPlaced>>::correlate(&OrderPlaced { id: 7, total: 1 }),
Some(7)
);
assert_eq!(
<OrderSaga as React<PaymentSettled>>::correlate(&PaymentSettled { id: 9 }),
Some(9)
);
}