1use crate::aggregate::{Aggregate, Command};
24use crate::audit::{AuditError, AuditMetadata, SYSTEM_ACTOR};
25use crate::event::Event;
26use crate::event_bus::{EventBus, EventBusError};
27use crate::event_store::{EventStore, EventStoreError, VersionCheck};
28use crate::snapshot::Snapshot;
29use std::marker::PhantomData;
30use thiserror::Error;
31use uuid::Uuid;
32
33#[derive(Debug, Clone)]
39pub struct CommandContext {
40 pub actor_id: String,
43
44 pub session_id: Option<String>,
46
47 pub source_ip: Option<String>,
49
50 pub user_agent: Option<String>,
52
53 pub correlation_id: Uuid,
55
56 pub causation_id: Option<Uuid>,
58}
59
60impl CommandContext {
61 pub fn for_actor(actor_id: impl Into<String>) -> Self {
64 Self {
65 actor_id: actor_id.into(),
66 session_id: None,
67 source_ip: None,
68 user_agent: None,
69 correlation_id: Uuid::new_v4(),
70 causation_id: None,
71 }
72 }
73
74 pub fn system() -> Self {
76 Self::for_actor(SYSTEM_ACTOR)
77 }
78
79 pub fn caused_by(actor_id: impl Into<String>, triggering: &Event) -> Self {
82 Self {
83 actor_id: actor_id.into(),
84 session_id: None,
85 source_ip: None,
86 user_agent: None,
87 correlation_id: triggering.audit.correlation_id,
88 causation_id: Some(triggering.event_id),
89 }
90 }
91
92 pub fn to_audit(&self) -> Result<AuditMetadata, AuditError> {
95 let m = AuditMetadata {
96 actor_id: self.actor_id.clone(),
97 actor_session_id: self.session_id.clone(),
98 source_ip: self.source_ip.clone(),
99 user_agent: self.user_agent.clone(),
100 timestamp_utc_us: crate::audit::now_us(),
101 causation_id: self.causation_id,
102 correlation_id: self.correlation_id,
103 };
104 m.validate()?;
105 Ok(m)
106 }
107}
108
109#[cfg(any(test, feature = "test-utils"))]
110impl Default for CommandContext {
111 fn default() -> Self {
112 Self::for_actor("test")
113 }
114}
115
116#[derive(Debug, Error)]
118pub enum CommandBusError {
119 #[error("Failed to load aggregate '{aggregate_id}': {source}")]
120 LoadFailed {
121 aggregate_id: String,
122 #[source]
123 source: EventStoreError,
124 },
125
126 #[error("Command handling failed for aggregate '{aggregate_id}': {message}")]
127 HandleFailed {
128 aggregate_id: String,
129 message: String,
130 },
131
132 #[error("Failed to append events for aggregate '{aggregate_id}': {source}")]
133 AppendFailed {
134 aggregate_id: String,
135 #[source]
136 source: EventStoreError,
137 },
138
139 #[error("Failed to publish events for aggregate '{aggregate_id}': {source}")]
140 PublishFailed {
141 aggregate_id: String,
142 #[source]
143 source: EventBusError,
144 },
145
146 #[error("Audit metadata validation failed for aggregate '{aggregate_id}': {source}")]
147 InvalidAudit {
148 aggregate_id: String,
149 #[source]
150 source: AuditError,
151 },
152
153 #[error("Command bus error: {message}")]
154 Other { message: String },
155}
156
157impl CommandBusError {
158 pub fn handle_failed(aggregate_id: impl Into<String>, message: impl Into<String>) -> Self {
159 CommandBusError::HandleFailed {
160 aggregate_id: aggregate_id.into(),
161 message: message.into(),
162 }
163 }
164
165 pub fn other(message: impl Into<String>) -> Self {
166 CommandBusError::Other {
167 message: message.into(),
168 }
169 }
170}
171
172pub type CommandBusResult<T> = Result<T, CommandBusError>;
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
183pub enum SnapshotPolicy {
184 #[default]
186 Disabled,
187 EveryNEvents(i64),
190}
191
192pub struct CommandBus<A: Aggregate> {
194 event_store: Box<dyn EventStore>,
195 event_bus: Box<dyn EventBus>,
196 snapshot_policy: SnapshotPolicy,
197 _phantom: PhantomData<A>,
198}
199
200impl<A: Aggregate> CommandBus<A> {
201 pub fn new(event_store: Box<dyn EventStore>, event_bus: Box<dyn EventBus>) -> Self {
202 Self {
203 event_store,
204 event_bus,
205 snapshot_policy: SnapshotPolicy::Disabled,
208 _phantom: PhantomData,
209 }
210 }
211
212 pub fn with_snapshot_policy(mut self, policy: SnapshotPolicy) -> Self {
215 self.snapshot_policy = policy;
216 self
217 }
218
219 async fn full_replay(&self, aggregate_id: &str) -> CommandBusResult<(A, i64)> {
223 let events = self
224 .event_store
225 .load_stream(A::aggregate_type(), aggregate_id)
226 .await
227 .map_err(|source| CommandBusError::LoadFailed {
228 aggregate_id: aggregate_id.to_string(),
229 source,
230 })?;
231 let current_version = events.last().map(|e| e.sequence).unwrap_or(0);
232 Ok((A::from_events(events), current_version))
233 }
234
235 pub async fn dispatch(
242 &self,
243 command: A::Command,
244 context: CommandContext,
245 ) -> CommandBusResult<Vec<Event>> {
246 let aggregate_id = command.aggregate_id().to_string();
247
248 let mut loaded_snapshot_version = 0i64;
259 let (aggregate, current_version) = match self.snapshot_policy {
260 SnapshotPolicy::Disabled => self.full_replay(&aggregate_id).await?,
261 SnapshotPolicy::EveryNEvents(_) => {
262 match self
263 .event_store
264 .load_snapshot_for(A::aggregate_type(), &aggregate_id)
265 .await
266 {
267 Ok(Some(snap)) => match A::from_snapshot(snap.state.clone()) {
268 Some(mut agg) => {
269 let tail = self
270 .event_store
271 .load_stream_from(
272 A::aggregate_type(),
273 &aggregate_id,
274 snap.version + 1,
275 )
276 .await
277 .map_err(|source| CommandBusError::LoadFailed {
278 aggregate_id: aggregate_id.clone(),
279 source,
280 })?;
281 let current_version =
282 tail.last().map(|e| e.sequence).unwrap_or(snap.version);
283 for event in &tail {
284 agg.apply(event);
285 }
286 loaded_snapshot_version = snap.version;
287 (agg, current_version)
288 }
289 None => self.full_replay(&aggregate_id).await?,
292 },
293 Ok(None) | Err(_) => self.full_replay(&aggregate_id).await?,
296 }
297 }
298 };
299
300 let new_events = aggregate
302 .handle(command)
303 .await
304 .map_err(|e| CommandBusError::handle_failed(&aggregate_id, e.to_string()))?;
305
306 if new_events.is_empty() {
307 return Ok(vec![]);
308 }
309
310 let audit = context
312 .to_audit()
313 .map_err(|source| CommandBusError::InvalidAudit {
314 aggregate_id: aggregate_id.clone(),
315 source,
316 })?;
317 let new_events: Vec<Event> = new_events
318 .into_iter()
319 .map(|e| e.with_audit(audit.clone()))
320 .collect();
321
322 let version_check = if current_version == 0 {
324 VersionCheck::New
325 } else {
326 VersionCheck::Expected(current_version)
327 };
328
329 self.event_store
330 .append_to(
331 A::aggregate_type(),
332 &aggregate_id,
333 version_check,
334 new_events.clone(),
335 )
336 .await
337 .map_err(|source| CommandBusError::AppendFailed {
338 aggregate_id: aggregate_id.clone(),
339 source,
340 })?;
341
342 self.event_bus
344 .publish(new_events.clone())
345 .await
346 .map_err(|source| CommandBusError::PublishFailed {
347 aggregate_id: aggregate_id.clone(),
348 source,
349 })?;
350
351 if let SnapshotPolicy::EveryNEvents(n) = self.snapshot_policy {
355 let new_version = new_events
356 .last()
357 .map(|e| e.sequence)
358 .unwrap_or(current_version);
359 if new_version - loaded_snapshot_version >= n {
360 let mut post = aggregate;
365 for event in &new_events {
366 post.apply(event);
367 }
368 if let Some(state) = post.to_snapshot() {
369 let snapshot = Snapshot::new(
370 aggregate_id.clone(),
371 A::aggregate_type(),
372 new_version,
373 state,
374 );
375 let _ = self.event_store.save_snapshot(&snapshot).await;
376 }
377 }
378 }
379
380 Ok(new_events)
381 }
382
383 pub fn event_store(&self) -> &dyn EventStore {
384 self.event_store.as_ref()
385 }
386
387 pub fn event_bus(&self) -> &dyn EventBus {
388 self.event_bus.as_ref()
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 use crate::aggregate::Aggregate;
396 use crate::event::Event;
397 use crate::event_bus::{EventHandler, InProcessEventBus};
398 use crate::event_store::{
399 EventStore, EventStoreError, EventStoreResult, InMemoryEventStore, VersionCheck,
400 };
401 use async_trait::async_trait;
402 use serde::{Deserialize, Serialize};
403 use serde_json::json;
404 use std::sync::Arc;
405 use tokio::sync::Mutex as TokioMutex;
406
407 #[derive(Debug, Clone, PartialEq)]
408 struct CounterCommand {
409 id: String,
410 increment: i64,
411 }
412
413 impl Command for CounterCommand {
414 fn aggregate_id(&self) -> &str {
415 &self.id
416 }
417 }
418
419 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
422 struct CounterAggregate {
423 id: Option<String>,
424 value: i64,
425 version: i64,
426 }
427
428 #[derive(Debug, thiserror::Error)]
429 enum CounterError {
430 #[error("Negative increment not allowed")]
431 NegativeIncrement,
432 }
433
434 #[async_trait]
435 impl Aggregate for CounterAggregate {
436 type Command = CounterCommand;
437 type Event = ();
438 type Error = CounterError;
439
440 fn aggregate_type() -> &'static str {
441 "Counter"
442 }
443
444 fn version(&self) -> i64 {
445 self.version
446 }
447
448 async fn handle(&self, command: Self::Command) -> Result<Vec<Event>, Self::Error> {
449 if command.increment < 0 {
450 return Err(CounterError::NegativeIncrement);
451 }
452 Ok(vec![Event::new(
453 "Counter",
454 &command.id,
455 self.version + 1,
456 "CounterIncremented",
457 json!({ "increment": command.increment }),
458 )])
459 }
460
461 fn apply(&mut self, event: &Event) {
462 if event.event_type == "CounterIncremented" {
463 self.id = Some(event.aggregate_id.clone());
464 self.value += event.payload["increment"].as_i64().unwrap_or(0);
465 self.version = event.sequence;
466 }
467 }
468
469 fn to_snapshot(&self) -> Option<serde_json::Value> {
470 serde_json::to_value(self).ok()
471 }
472
473 fn from_snapshot(state: serde_json::Value) -> Option<Self> {
474 serde_json::from_value(state).ok()
475 }
476 }
477
478 fn ctx() -> CommandContext {
479 CommandContext::for_actor("test-actor")
480 }
481
482 #[tokio::test]
483 async fn test_command_bus_new() {
484 let _bus = CommandBus::<CounterAggregate>::new(
485 Box::new(InMemoryEventStore::new()),
486 Box::new(InProcessEventBus::new()),
487 );
488 }
489
490 #[tokio::test]
491 async fn test_dispatch_first_command() {
492 let bus = CommandBus::<CounterAggregate>::new(
493 Box::new(InMemoryEventStore::new()),
494 Box::new(InProcessEventBus::new()),
495 );
496 let cmd = CounterCommand {
497 id: "counter-1".into(),
498 increment: 5,
499 };
500 let events = bus.dispatch(cmd, ctx()).await.unwrap();
501 assert_eq!(events.len(), 1);
502 assert_eq!(events[0].event_type, "CounterIncremented");
503 assert_eq!(events[0].sequence, 1);
504 assert!(!events[0].audit.is_pending());
505 assert_eq!(events[0].audit.actor_id, "test-actor");
506 }
507
508 #[tokio::test]
509 async fn test_dispatch_multiple_commands() {
510 let bus = CommandBus::<CounterAggregate>::new(
511 Box::new(InMemoryEventStore::new()),
512 Box::new(InProcessEventBus::new()),
513 );
514 bus.dispatch(
515 CounterCommand {
516 id: "counter-1".into(),
517 increment: 5,
518 },
519 ctx(),
520 )
521 .await
522 .unwrap();
523 let events = bus
524 .dispatch(
525 CounterCommand {
526 id: "counter-1".into(),
527 increment: 3,
528 },
529 ctx(),
530 )
531 .await
532 .unwrap();
533 assert_eq!(events[0].sequence, 2);
534 }
535
536 #[tokio::test]
537 async fn test_dispatch_validates_command() {
538 let bus = CommandBus::<CounterAggregate>::new(
539 Box::new(InMemoryEventStore::new()),
540 Box::new(InProcessEventBus::new()),
541 );
542 let result = bus
543 .dispatch(
544 CounterCommand {
545 id: "c1".into(),
546 increment: -5,
547 },
548 ctx(),
549 )
550 .await;
551 match result.unwrap_err() {
552 CommandBusError::HandleFailed { message, .. } => {
553 assert!(message.contains("Negative increment"));
554 }
555 other => panic!("expected HandleFailed, got {:?}", other),
556 }
557 }
558
559 #[tokio::test]
560 async fn test_dispatch_publishes_events() {
561 let mut event_bus = InProcessEventBus::new();
562 let published = Arc::new(TokioMutex::new(Vec::new()));
563 let captured = published.clone();
564
565 struct H {
566 captured: Arc<TokioMutex<Vec<String>>>,
567 }
568 #[async_trait]
569 impl EventHandler for H {
570 fn handles(&self) -> Vec<String> {
571 vec!["CounterIncremented".to_string()]
572 }
573 async fn handle(
574 &self,
575 event: &Event,
576 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
577 self.captured.lock().await.push(event.event_type.clone());
578 Ok(())
579 }
580 }
581 event_bus.subscribe(Box::new(H { captured })).await.unwrap();
582
583 let bus = CommandBus::<CounterAggregate>::new(
584 Box::new(InMemoryEventStore::new()),
585 Box::new(event_bus),
586 );
587 bus.dispatch(
588 CounterCommand {
589 id: "c1".into(),
590 increment: 5,
591 },
592 ctx(),
593 )
594 .await
595 .unwrap();
596 assert_eq!(published.lock().await.len(), 1);
597 }
598
599 #[tokio::test]
600 async fn test_dispatch_empty_events() {
601 #[derive(Default)]
602 struct NoOpAggregate {
603 version: i64,
604 }
605 struct NoOpCommand {
606 id: String,
607 }
608 impl Command for NoOpCommand {
609 fn aggregate_id(&self) -> &str {
610 &self.id
611 }
612 }
613 #[derive(Debug, thiserror::Error)]
614 #[error("noop")]
615 struct NoOpErr;
616 #[async_trait]
617 impl Aggregate for NoOpAggregate {
618 type Command = NoOpCommand;
619 type Event = ();
620 type Error = NoOpErr;
621 fn aggregate_type() -> &'static str {
622 "NoOp"
623 }
624 fn version(&self) -> i64 {
625 self.version
626 }
627 async fn handle(&self, _: Self::Command) -> Result<Vec<Event>, Self::Error> {
628 Ok(vec![])
629 }
630 fn apply(&mut self, _: &Event) {}
631 }
632 let bus = CommandBus::<NoOpAggregate>::new(
633 Box::new(InMemoryEventStore::new()),
634 Box::new(InProcessEventBus::new()),
635 );
636 let events = bus
637 .dispatch(NoOpCommand { id: "n1".into() }, ctx())
638 .await
639 .unwrap();
640 assert!(events.is_empty());
641 }
642
643 #[tokio::test]
644 async fn test_aggregate_state_reconstruction() {
645 let bus = CommandBus::<CounterAggregate>::new(
646 Box::new(InMemoryEventStore::new()),
647 Box::new(InProcessEventBus::new()),
648 );
649 bus.dispatch(
650 CounterCommand {
651 id: "c1".into(),
652 increment: 5,
653 },
654 ctx(),
655 )
656 .await
657 .unwrap();
658 bus.dispatch(
659 CounterCommand {
660 id: "c1".into(),
661 increment: 3,
662 },
663 ctx(),
664 )
665 .await
666 .unwrap();
667 let events = bus.event_store().load("c1").await.unwrap();
668 let agg = CounterAggregate::from_events(events);
669 assert_eq!(agg.value, 8);
670 assert_eq!(agg.version, 2);
671 }
672
673 #[tokio::test]
674 async fn test_optimistic_concurrency() {
675 struct ConflictingStore;
676 #[async_trait]
677 impl EventStore for ConflictingStore {
678 async fn append(
679 &self,
680 aggregate_id: &str,
681 version_check: VersionCheck,
682 _events: Vec<Event>,
683 ) -> EventStoreResult<()> {
684 if let Some(expected) = version_check.version() {
685 Err(EventStoreError::ConcurrencyConflict {
686 aggregate_id: aggregate_id.to_string(),
687 expected,
688 actual: expected + 1,
689 })
690 } else {
691 Ok(())
692 }
693 }
694 async fn load(&self, _: &str) -> EventStoreResult<Vec<Event>> {
695 Ok(vec![])
696 }
697 async fn load_from(&self, _: &str, _: i64) -> EventStoreResult<Vec<Event>> {
698 Ok(vec![])
699 }
700 async fn stream_all(&self, _: i64) -> EventStoreResult<Vec<Event>> {
701 Ok(vec![])
702 }
703 async fn get_version(&self, _: &str) -> EventStoreResult<i64> {
704 Ok(0)
705 }
706 }
707 let bus = CommandBus::<CounterAggregate>::new(
708 Box::new(ConflictingStore),
709 Box::new(InProcessEventBus::new()),
710 );
711 let err = bus
712 .dispatch(
713 CounterCommand {
714 id: "c1".into(),
715 increment: 5,
716 },
717 ctx(),
718 )
719 .await
720 .unwrap_err();
721 assert!(matches!(
722 err,
723 CommandBusError::AppendFailed {
724 source: EventStoreError::ConcurrencyConflict { .. },
725 ..
726 }
727 ));
728 }
729
730 #[tokio::test]
731 async fn test_dispatch_stamps_audit_on_every_event() {
732 let bus = CommandBus::<CounterAggregate>::new(
733 Box::new(InMemoryEventStore::new()),
734 Box::new(InProcessEventBus::new()),
735 );
736 let ctx = CommandContext {
737 actor_id: "alice-uuid".into(),
738 session_id: Some("sess-1".into()),
739 source_ip: Some("10.0.0.1".into()),
740 user_agent: Some("test-agent".into()),
741 correlation_id: Uuid::new_v4(),
742 causation_id: None,
743 };
744 let corr = ctx.correlation_id;
745 let events = bus
746 .dispatch(
747 CounterCommand {
748 id: "c1".into(),
749 increment: 5,
750 },
751 ctx,
752 )
753 .await
754 .unwrap();
755 assert_eq!(events[0].audit.actor_id, "alice-uuid");
756 assert_eq!(events[0].audit.actor_session_id.as_deref(), Some("sess-1"));
757 assert_eq!(events[0].audit.source_ip.as_deref(), Some("10.0.0.1"));
758 assert_eq!(events[0].audit.user_agent.as_deref(), Some("test-agent"));
759 assert_eq!(events[0].audit.correlation_id, corr);
760 assert!(events[0].audit.timestamp_utc_us > 0);
761 }
762
763 #[tokio::test]
764 async fn test_dispatch_rejects_invalid_actor() {
765 let bus = CommandBus::<CounterAggregate>::new(
766 Box::new(InMemoryEventStore::new()),
767 Box::new(InProcessEventBus::new()),
768 );
769 let bad_ctx = CommandContext {
770 actor_id: "".into(), session_id: None,
772 source_ip: None,
773 user_agent: None,
774 correlation_id: Uuid::new_v4(),
775 causation_id: None,
776 };
777 let err = bus
778 .dispatch(
779 CounterCommand {
780 id: "c1".into(),
781 increment: 5,
782 },
783 bad_ctx,
784 )
785 .await
786 .unwrap_err();
787 assert!(matches!(err, CommandBusError::InvalidAudit { .. }));
788 }
789
790 #[tokio::test]
791 async fn test_concurrent_dispatches_keep_distinct_correlation_ids() {
792 let bus = Arc::new(CommandBus::<CounterAggregate>::new(
793 Box::new(InMemoryEventStore::new()),
794 Box::new(InProcessEventBus::new()),
795 ));
796
797 let ctx_a = CommandContext::for_actor("alice");
798 let ctx_b = CommandContext::for_actor("bob");
799 let corr_a = ctx_a.correlation_id;
800 let corr_b = ctx_b.correlation_id;
801 assert_ne!(corr_a, corr_b);
802
803 let bus_a = bus.clone();
804 let bus_b = bus.clone();
805 let h_a = tokio::spawn(async move {
806 bus_a
807 .dispatch(
808 CounterCommand {
809 id: "agg-a".into(),
810 increment: 1,
811 },
812 ctx_a,
813 )
814 .await
815 });
816 let h_b = tokio::spawn(async move {
817 bus_b
818 .dispatch(
819 CounterCommand {
820 id: "agg-b".into(),
821 increment: 1,
822 },
823 ctx_b,
824 )
825 .await
826 });
827 let res_a = h_a.await.unwrap().unwrap();
828 let res_b = h_b.await.unwrap().unwrap();
829
830 assert_eq!(res_a[0].audit.correlation_id, corr_a);
831 assert_eq!(res_a[0].audit.actor_id, "alice");
832 assert_eq!(res_b[0].audit.correlation_id, corr_b);
833 assert_eq!(res_b[0].audit.actor_id, "bob");
834 }
835
836 #[tokio::test]
837 async fn test_caused_by_inherits_correlation() {
838 let bus = CommandBus::<CounterAggregate>::new(
839 Box::new(InMemoryEventStore::new()),
840 Box::new(InProcessEventBus::new()),
841 );
842 let first_ctx = CommandContext::for_actor("alice");
843 let trigger_corr = first_ctx.correlation_id;
844 let triggers = bus
845 .dispatch(
846 CounterCommand {
847 id: "c1".into(),
848 increment: 5,
849 },
850 first_ctx,
851 )
852 .await
853 .unwrap();
854
855 let follow_ctx = CommandContext::caused_by("projection-worker", &triggers[0]);
856 let follow = bus
857 .dispatch(
858 CounterCommand {
859 id: "c2".into(),
860 increment: 1,
861 },
862 follow_ctx,
863 )
864 .await
865 .unwrap();
866
867 assert_eq!(follow[0].audit.correlation_id, trigger_corr);
868 assert_eq!(follow[0].audit.causation_id, Some(triggers[0].event_id));
869 }
870
871 #[test]
872 fn test_error_messages() {
873 let e = CommandBusError::handle_failed("user-123", "Invalid email");
874 assert!(e.to_string().contains("user-123"));
875 assert!(e.to_string().contains("Invalid email"));
876 assert!(CommandBusError::other("X").to_string().contains("X"));
877 }
878
879 #[tokio::test]
882 async fn test_snapshot_disabled_by_default_writes_nothing() {
883 let bus = CommandBus::<CounterAggregate>::new(
884 Box::new(InMemoryEventStore::new()),
885 Box::new(InProcessEventBus::new()),
886 );
887 for _ in 0..5 {
888 bus.dispatch(
889 CounterCommand {
890 id: "c1".into(),
891 increment: 1,
892 },
893 ctx(),
894 )
895 .await
896 .unwrap();
897 }
898 assert!(bus
899 .event_store()
900 .load_snapshot("c1")
901 .await
902 .unwrap()
903 .is_none());
904 }
905
906 #[tokio::test]
909 async fn test_snapshot_created_when_threshold_crossed() {
910 let bus = CommandBus::<CounterAggregate>::new(
911 Box::new(InMemoryEventStore::new()),
912 Box::new(InProcessEventBus::new()),
913 )
914 .with_snapshot_policy(SnapshotPolicy::EveryNEvents(3));
915
916 for _ in 0..2 {
918 bus.dispatch(
919 CounterCommand {
920 id: "c1".into(),
921 increment: 1,
922 },
923 ctx(),
924 )
925 .await
926 .unwrap();
927 }
928 assert!(bus
929 .event_store()
930 .load_snapshot("c1")
931 .await
932 .unwrap()
933 .is_none());
934
935 bus.dispatch(
937 CounterCommand {
938 id: "c1".into(),
939 increment: 1,
940 },
941 ctx(),
942 )
943 .await
944 .unwrap();
945 let snap = bus
946 .event_store()
947 .load_snapshot("c1")
948 .await
949 .unwrap()
950 .expect("snapshot at threshold");
951 assert_eq!(snap.version, 3);
952 assert_eq!(snap.aggregate_type, "Counter");
953 }
954
955 #[tokio::test]
958 async fn test_snapshot_load_path_matches_full_replay() {
959 let enabled = CommandBus::<CounterAggregate>::new(
960 Box::new(InMemoryEventStore::new()),
961 Box::new(InProcessEventBus::new()),
962 )
963 .with_snapshot_policy(SnapshotPolicy::EveryNEvents(2));
964 let disabled = CommandBus::<CounterAggregate>::new(
965 Box::new(InMemoryEventStore::new()),
966 Box::new(InProcessEventBus::new()),
967 );
968
969 for inc in [3, 4, 5, 6, 7] {
970 enabled
971 .dispatch(
972 CounterCommand {
973 id: "c1".into(),
974 increment: inc,
975 },
976 ctx(),
977 )
978 .await
979 .unwrap();
980 disabled
981 .dispatch(
982 CounterCommand {
983 id: "c1".into(),
984 increment: inc,
985 },
986 ctx(),
987 )
988 .await
989 .unwrap();
990 }
991
992 assert!(enabled
994 .event_store()
995 .load_snapshot("c1")
996 .await
997 .unwrap()
998 .is_some());
999
1000 let enabled_state =
1001 CounterAggregate::from_events(enabled.event_store().load("c1").await.unwrap());
1002 let disabled_state =
1003 CounterAggregate::from_events(disabled.event_store().load("c1").await.unwrap());
1004 assert_eq!(enabled_state.value, disabled_state.value);
1005 assert_eq!(enabled_state.version, disabled_state.version);
1006 assert_eq!(enabled_state.value, 25);
1007 assert_eq!(enabled_state.version, 5);
1008 }
1009
1010 #[tokio::test]
1013 async fn test_enabled_falls_back_to_replay_without_snapshot() {
1014 let bus = CommandBus::<CounterAggregate>::new(
1017 Box::new(InMemoryEventStore::new()),
1018 Box::new(InProcessEventBus::new()),
1019 )
1020 .with_snapshot_policy(SnapshotPolicy::EveryNEvents(100));
1021
1022 let e1 = bus
1023 .dispatch(
1024 CounterCommand {
1025 id: "c1".into(),
1026 increment: 5,
1027 },
1028 ctx(),
1029 )
1030 .await
1031 .unwrap();
1032 assert_eq!(e1[0].sequence, 1);
1033
1034 let e2 = bus
1035 .dispatch(
1036 CounterCommand {
1037 id: "c1".into(),
1038 increment: 3,
1039 },
1040 ctx(),
1041 )
1042 .await
1043 .unwrap();
1044 assert_eq!(e2[0].sequence, 2);
1045
1046 assert!(bus
1047 .event_store()
1048 .load_snapshot("c1")
1049 .await
1050 .unwrap()
1051 .is_none());
1052 let state = CounterAggregate::from_events(bus.event_store().load("c1").await.unwrap());
1053 assert_eq!(state.value, 8);
1054 assert_eq!(state.version, 2);
1055 }
1056}