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