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(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.event_store.load_snapshot(&aggregate_id).await {
263 Ok(Some(snap)) => match A::from_snapshot(snap.state.clone()) {
264 Some(mut agg) => {
265 let tail = self
266 .event_store
267 .load_from(&aggregate_id, snap.version + 1)
268 .await
269 .map_err(|source| CommandBusError::LoadFailed {
270 aggregate_id: aggregate_id.clone(),
271 source,
272 })?;
273 let current_version =
274 tail.last().map(|e| e.sequence).unwrap_or(snap.version);
275 for event in &tail {
276 agg.apply(event);
277 }
278 loaded_snapshot_version = snap.version;
279 (agg, current_version)
280 }
281 None => self.full_replay(&aggregate_id).await?,
284 },
285 Ok(None) | Err(_) => self.full_replay(&aggregate_id).await?,
288 }
289 }
290 };
291
292 let new_events = aggregate
294 .handle(command)
295 .await
296 .map_err(|e| CommandBusError::handle_failed(&aggregate_id, e.to_string()))?;
297
298 if new_events.is_empty() {
299 return Ok(vec![]);
300 }
301
302 let audit = context
304 .to_audit()
305 .map_err(|source| CommandBusError::InvalidAudit {
306 aggregate_id: aggregate_id.clone(),
307 source,
308 })?;
309 let new_events: Vec<Event> = new_events
310 .into_iter()
311 .map(|e| e.with_audit(audit.clone()))
312 .collect();
313
314 let version_check = if current_version == 0 {
316 VersionCheck::New
317 } else {
318 VersionCheck::Expected(current_version)
319 };
320
321 self.event_store
322 .append(&aggregate_id, version_check, new_events.clone())
323 .await
324 .map_err(|source| CommandBusError::AppendFailed {
325 aggregate_id: aggregate_id.clone(),
326 source,
327 })?;
328
329 self.event_bus
331 .publish(new_events.clone())
332 .await
333 .map_err(|source| CommandBusError::PublishFailed {
334 aggregate_id: aggregate_id.clone(),
335 source,
336 })?;
337
338 if let SnapshotPolicy::EveryNEvents(n) = self.snapshot_policy {
342 let new_version = new_events
343 .last()
344 .map(|e| e.sequence)
345 .unwrap_or(current_version);
346 if new_version - loaded_snapshot_version >= n {
347 let mut post = aggregate;
352 for event in &new_events {
353 post.apply(event);
354 }
355 if let Some(state) = post.to_snapshot() {
356 let snapshot = Snapshot::new(
357 aggregate_id.clone(),
358 A::aggregate_type(),
359 new_version,
360 state,
361 );
362 let _ = self.event_store.save_snapshot(&snapshot).await;
363 }
364 }
365 }
366
367 Ok(new_events)
368 }
369
370 pub fn event_store(&self) -> &dyn EventStore {
371 self.event_store.as_ref()
372 }
373
374 pub fn event_bus(&self) -> &dyn EventBus {
375 self.event_bus.as_ref()
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382 use crate::aggregate::Aggregate;
383 use crate::event::Event;
384 use crate::event_bus::{EventHandler, InProcessEventBus};
385 use crate::event_store::{
386 EventStore, EventStoreError, EventStoreResult, InMemoryEventStore, VersionCheck,
387 };
388 use async_trait::async_trait;
389 use serde::{Deserialize, Serialize};
390 use serde_json::json;
391 use std::sync::Arc;
392 use tokio::sync::Mutex as TokioMutex;
393
394 #[derive(Debug, Clone, PartialEq)]
395 struct CounterCommand {
396 id: String,
397 increment: i64,
398 }
399
400 impl Command for CounterCommand {
401 fn aggregate_id(&self) -> &str {
402 &self.id
403 }
404 }
405
406 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
409 struct CounterAggregate {
410 id: Option<String>,
411 value: i64,
412 version: i64,
413 }
414
415 #[derive(Debug, thiserror::Error)]
416 enum CounterError {
417 #[error("Negative increment not allowed")]
418 NegativeIncrement,
419 }
420
421 #[async_trait]
422 impl Aggregate for CounterAggregate {
423 type Command = CounterCommand;
424 type Event = ();
425 type Error = CounterError;
426
427 fn aggregate_type() -> &'static str {
428 "Counter"
429 }
430
431 fn version(&self) -> i64 {
432 self.version
433 }
434
435 async fn handle(&self, command: Self::Command) -> Result<Vec<Event>, Self::Error> {
436 if command.increment < 0 {
437 return Err(CounterError::NegativeIncrement);
438 }
439 Ok(vec![Event::new(
440 "Counter",
441 &command.id,
442 self.version + 1,
443 "CounterIncremented",
444 json!({ "increment": command.increment }),
445 )])
446 }
447
448 fn apply(&mut self, event: &Event) {
449 if event.event_type == "CounterIncremented" {
450 self.id = Some(event.aggregate_id.clone());
451 self.value += event.payload["increment"].as_i64().unwrap_or(0);
452 self.version = event.sequence;
453 }
454 }
455
456 fn to_snapshot(&self) -> Option<serde_json::Value> {
457 serde_json::to_value(self).ok()
458 }
459
460 fn from_snapshot(state: serde_json::Value) -> Option<Self> {
461 serde_json::from_value(state).ok()
462 }
463 }
464
465 fn ctx() -> CommandContext {
466 CommandContext::for_actor("test-actor")
467 }
468
469 #[tokio::test]
470 async fn test_command_bus_new() {
471 let _bus = CommandBus::<CounterAggregate>::new(
472 Box::new(InMemoryEventStore::new()),
473 Box::new(InProcessEventBus::new()),
474 );
475 }
476
477 #[tokio::test]
478 async fn test_dispatch_first_command() {
479 let bus = CommandBus::<CounterAggregate>::new(
480 Box::new(InMemoryEventStore::new()),
481 Box::new(InProcessEventBus::new()),
482 );
483 let cmd = CounterCommand {
484 id: "counter-1".into(),
485 increment: 5,
486 };
487 let events = bus.dispatch(cmd, ctx()).await.unwrap();
488 assert_eq!(events.len(), 1);
489 assert_eq!(events[0].event_type, "CounterIncremented");
490 assert_eq!(events[0].sequence, 1);
491 assert!(!events[0].audit.is_pending());
492 assert_eq!(events[0].audit.actor_id, "test-actor");
493 }
494
495 #[tokio::test]
496 async fn test_dispatch_multiple_commands() {
497 let bus = CommandBus::<CounterAggregate>::new(
498 Box::new(InMemoryEventStore::new()),
499 Box::new(InProcessEventBus::new()),
500 );
501 bus.dispatch(
502 CounterCommand {
503 id: "counter-1".into(),
504 increment: 5,
505 },
506 ctx(),
507 )
508 .await
509 .unwrap();
510 let events = bus
511 .dispatch(
512 CounterCommand {
513 id: "counter-1".into(),
514 increment: 3,
515 },
516 ctx(),
517 )
518 .await
519 .unwrap();
520 assert_eq!(events[0].sequence, 2);
521 }
522
523 #[tokio::test]
524 async fn test_dispatch_validates_command() {
525 let bus = CommandBus::<CounterAggregate>::new(
526 Box::new(InMemoryEventStore::new()),
527 Box::new(InProcessEventBus::new()),
528 );
529 let result = bus
530 .dispatch(
531 CounterCommand {
532 id: "c1".into(),
533 increment: -5,
534 },
535 ctx(),
536 )
537 .await;
538 match result.unwrap_err() {
539 CommandBusError::HandleFailed { message, .. } => {
540 assert!(message.contains("Negative increment"));
541 }
542 other => panic!("expected HandleFailed, got {:?}", other),
543 }
544 }
545
546 #[tokio::test]
547 async fn test_dispatch_publishes_events() {
548 let mut event_bus = InProcessEventBus::new();
549 let published = Arc::new(TokioMutex::new(Vec::new()));
550 let captured = published.clone();
551
552 struct H {
553 captured: Arc<TokioMutex<Vec<String>>>,
554 }
555 #[async_trait]
556 impl EventHandler for H {
557 fn handles(&self) -> Vec<String> {
558 vec!["CounterIncremented".to_string()]
559 }
560 async fn handle(
561 &self,
562 event: &Event,
563 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
564 self.captured.lock().await.push(event.event_type.clone());
565 Ok(())
566 }
567 }
568 event_bus.subscribe(Box::new(H { captured })).await.unwrap();
569
570 let bus = CommandBus::<CounterAggregate>::new(
571 Box::new(InMemoryEventStore::new()),
572 Box::new(event_bus),
573 );
574 bus.dispatch(
575 CounterCommand {
576 id: "c1".into(),
577 increment: 5,
578 },
579 ctx(),
580 )
581 .await
582 .unwrap();
583 assert_eq!(published.lock().await.len(), 1);
584 }
585
586 #[tokio::test]
587 async fn test_dispatch_empty_events() {
588 #[derive(Default)]
589 struct NoOpAggregate {
590 version: i64,
591 }
592 struct NoOpCommand {
593 id: String,
594 }
595 impl Command for NoOpCommand {
596 fn aggregate_id(&self) -> &str {
597 &self.id
598 }
599 }
600 #[derive(Debug, thiserror::Error)]
601 #[error("noop")]
602 struct NoOpErr;
603 #[async_trait]
604 impl Aggregate for NoOpAggregate {
605 type Command = NoOpCommand;
606 type Event = ();
607 type Error = NoOpErr;
608 fn aggregate_type() -> &'static str {
609 "NoOp"
610 }
611 fn version(&self) -> i64 {
612 self.version
613 }
614 async fn handle(&self, _: Self::Command) -> Result<Vec<Event>, Self::Error> {
615 Ok(vec![])
616 }
617 fn apply(&mut self, _: &Event) {}
618 }
619 let bus = CommandBus::<NoOpAggregate>::new(
620 Box::new(InMemoryEventStore::new()),
621 Box::new(InProcessEventBus::new()),
622 );
623 let events = bus
624 .dispatch(NoOpCommand { id: "n1".into() }, ctx())
625 .await
626 .unwrap();
627 assert!(events.is_empty());
628 }
629
630 #[tokio::test]
631 async fn test_aggregate_state_reconstruction() {
632 let bus = CommandBus::<CounterAggregate>::new(
633 Box::new(InMemoryEventStore::new()),
634 Box::new(InProcessEventBus::new()),
635 );
636 bus.dispatch(
637 CounterCommand {
638 id: "c1".into(),
639 increment: 5,
640 },
641 ctx(),
642 )
643 .await
644 .unwrap();
645 bus.dispatch(
646 CounterCommand {
647 id: "c1".into(),
648 increment: 3,
649 },
650 ctx(),
651 )
652 .await
653 .unwrap();
654 let events = bus.event_store().load("c1").await.unwrap();
655 let agg = CounterAggregate::from_events(events);
656 assert_eq!(agg.value, 8);
657 assert_eq!(agg.version, 2);
658 }
659
660 #[tokio::test]
661 async fn test_optimistic_concurrency() {
662 struct ConflictingStore;
663 #[async_trait]
664 impl EventStore for ConflictingStore {
665 async fn append(
666 &self,
667 aggregate_id: &str,
668 version_check: VersionCheck,
669 _events: Vec<Event>,
670 ) -> EventStoreResult<()> {
671 if let Some(expected) = version_check.version() {
672 Err(EventStoreError::ConcurrencyConflict {
673 aggregate_id: aggregate_id.to_string(),
674 expected,
675 actual: expected + 1,
676 })
677 } else {
678 Ok(())
679 }
680 }
681 async fn load(&self, _: &str) -> EventStoreResult<Vec<Event>> {
682 Ok(vec![])
683 }
684 async fn load_from(&self, _: &str, _: i64) -> EventStoreResult<Vec<Event>> {
685 Ok(vec![])
686 }
687 async fn stream_all(&self, _: i64) -> EventStoreResult<Vec<Event>> {
688 Ok(vec![])
689 }
690 async fn get_version(&self, _: &str) -> EventStoreResult<i64> {
691 Ok(0)
692 }
693 }
694 let bus = CommandBus::<CounterAggregate>::new(
695 Box::new(ConflictingStore),
696 Box::new(InProcessEventBus::new()),
697 );
698 let err = bus
699 .dispatch(
700 CounterCommand {
701 id: "c1".into(),
702 increment: 5,
703 },
704 ctx(),
705 )
706 .await
707 .unwrap_err();
708 assert!(matches!(
709 err,
710 CommandBusError::AppendFailed {
711 source: EventStoreError::ConcurrencyConflict { .. },
712 ..
713 }
714 ));
715 }
716
717 #[tokio::test]
718 async fn test_dispatch_stamps_audit_on_every_event() {
719 let bus = CommandBus::<CounterAggregate>::new(
720 Box::new(InMemoryEventStore::new()),
721 Box::new(InProcessEventBus::new()),
722 );
723 let ctx = CommandContext {
724 actor_id: "alice-uuid".into(),
725 session_id: Some("sess-1".into()),
726 source_ip: Some("10.0.0.1".into()),
727 user_agent: Some("test-agent".into()),
728 correlation_id: Uuid::new_v4(),
729 causation_id: None,
730 };
731 let corr = ctx.correlation_id;
732 let events = bus
733 .dispatch(
734 CounterCommand {
735 id: "c1".into(),
736 increment: 5,
737 },
738 ctx,
739 )
740 .await
741 .unwrap();
742 assert_eq!(events[0].audit.actor_id, "alice-uuid");
743 assert_eq!(events[0].audit.actor_session_id.as_deref(), Some("sess-1"));
744 assert_eq!(events[0].audit.source_ip.as_deref(), Some("10.0.0.1"));
745 assert_eq!(events[0].audit.user_agent.as_deref(), Some("test-agent"));
746 assert_eq!(events[0].audit.correlation_id, corr);
747 assert!(events[0].audit.timestamp_utc_us > 0);
748 }
749
750 #[tokio::test]
751 async fn test_dispatch_rejects_invalid_actor() {
752 let bus = CommandBus::<CounterAggregate>::new(
753 Box::new(InMemoryEventStore::new()),
754 Box::new(InProcessEventBus::new()),
755 );
756 let bad_ctx = CommandContext {
757 actor_id: "".into(), session_id: None,
759 source_ip: None,
760 user_agent: None,
761 correlation_id: Uuid::new_v4(),
762 causation_id: None,
763 };
764 let err = bus
765 .dispatch(
766 CounterCommand {
767 id: "c1".into(),
768 increment: 5,
769 },
770 bad_ctx,
771 )
772 .await
773 .unwrap_err();
774 assert!(matches!(err, CommandBusError::InvalidAudit { .. }));
775 }
776
777 #[tokio::test]
778 async fn test_concurrent_dispatches_keep_distinct_correlation_ids() {
779 let bus = Arc::new(CommandBus::<CounterAggregate>::new(
780 Box::new(InMemoryEventStore::new()),
781 Box::new(InProcessEventBus::new()),
782 ));
783
784 let ctx_a = CommandContext::for_actor("alice");
785 let ctx_b = CommandContext::for_actor("bob");
786 let corr_a = ctx_a.correlation_id;
787 let corr_b = ctx_b.correlation_id;
788 assert_ne!(corr_a, corr_b);
789
790 let bus_a = bus.clone();
791 let bus_b = bus.clone();
792 let h_a = tokio::spawn(async move {
793 bus_a
794 .dispatch(
795 CounterCommand {
796 id: "agg-a".into(),
797 increment: 1,
798 },
799 ctx_a,
800 )
801 .await
802 });
803 let h_b = tokio::spawn(async move {
804 bus_b
805 .dispatch(
806 CounterCommand {
807 id: "agg-b".into(),
808 increment: 1,
809 },
810 ctx_b,
811 )
812 .await
813 });
814 let res_a = h_a.await.unwrap().unwrap();
815 let res_b = h_b.await.unwrap().unwrap();
816
817 assert_eq!(res_a[0].audit.correlation_id, corr_a);
818 assert_eq!(res_a[0].audit.actor_id, "alice");
819 assert_eq!(res_b[0].audit.correlation_id, corr_b);
820 assert_eq!(res_b[0].audit.actor_id, "bob");
821 }
822
823 #[tokio::test]
824 async fn test_caused_by_inherits_correlation() {
825 let bus = CommandBus::<CounterAggregate>::new(
826 Box::new(InMemoryEventStore::new()),
827 Box::new(InProcessEventBus::new()),
828 );
829 let first_ctx = CommandContext::for_actor("alice");
830 let trigger_corr = first_ctx.correlation_id;
831 let triggers = bus
832 .dispatch(
833 CounterCommand {
834 id: "c1".into(),
835 increment: 5,
836 },
837 first_ctx,
838 )
839 .await
840 .unwrap();
841
842 let follow_ctx = CommandContext::caused_by("projection-worker", &triggers[0]);
843 let follow = bus
844 .dispatch(
845 CounterCommand {
846 id: "c2".into(),
847 increment: 1,
848 },
849 follow_ctx,
850 )
851 .await
852 .unwrap();
853
854 assert_eq!(follow[0].audit.correlation_id, trigger_corr);
855 assert_eq!(follow[0].audit.causation_id, Some(triggers[0].event_id));
856 }
857
858 #[test]
859 fn test_error_messages() {
860 let e = CommandBusError::handle_failed("user-123", "Invalid email");
861 assert!(e.to_string().contains("user-123"));
862 assert!(e.to_string().contains("Invalid email"));
863 assert!(CommandBusError::other("X").to_string().contains("X"));
864 }
865
866 #[tokio::test]
869 async fn test_snapshot_disabled_by_default_writes_nothing() {
870 let bus = CommandBus::<CounterAggregate>::new(
871 Box::new(InMemoryEventStore::new()),
872 Box::new(InProcessEventBus::new()),
873 );
874 for _ in 0..5 {
875 bus.dispatch(
876 CounterCommand {
877 id: "c1".into(),
878 increment: 1,
879 },
880 ctx(),
881 )
882 .await
883 .unwrap();
884 }
885 assert!(bus
886 .event_store()
887 .load_snapshot("c1")
888 .await
889 .unwrap()
890 .is_none());
891 }
892
893 #[tokio::test]
896 async fn test_snapshot_created_when_threshold_crossed() {
897 let bus = CommandBus::<CounterAggregate>::new(
898 Box::new(InMemoryEventStore::new()),
899 Box::new(InProcessEventBus::new()),
900 )
901 .with_snapshot_policy(SnapshotPolicy::EveryNEvents(3));
902
903 for _ in 0..2 {
905 bus.dispatch(
906 CounterCommand {
907 id: "c1".into(),
908 increment: 1,
909 },
910 ctx(),
911 )
912 .await
913 .unwrap();
914 }
915 assert!(bus
916 .event_store()
917 .load_snapshot("c1")
918 .await
919 .unwrap()
920 .is_none());
921
922 bus.dispatch(
924 CounterCommand {
925 id: "c1".into(),
926 increment: 1,
927 },
928 ctx(),
929 )
930 .await
931 .unwrap();
932 let snap = bus
933 .event_store()
934 .load_snapshot("c1")
935 .await
936 .unwrap()
937 .expect("snapshot at threshold");
938 assert_eq!(snap.version, 3);
939 assert_eq!(snap.aggregate_type, "Counter");
940 }
941
942 #[tokio::test]
945 async fn test_snapshot_load_path_matches_full_replay() {
946 let enabled = CommandBus::<CounterAggregate>::new(
947 Box::new(InMemoryEventStore::new()),
948 Box::new(InProcessEventBus::new()),
949 )
950 .with_snapshot_policy(SnapshotPolicy::EveryNEvents(2));
951 let disabled = CommandBus::<CounterAggregate>::new(
952 Box::new(InMemoryEventStore::new()),
953 Box::new(InProcessEventBus::new()),
954 );
955
956 for inc in [3, 4, 5, 6, 7] {
957 enabled
958 .dispatch(
959 CounterCommand {
960 id: "c1".into(),
961 increment: inc,
962 },
963 ctx(),
964 )
965 .await
966 .unwrap();
967 disabled
968 .dispatch(
969 CounterCommand {
970 id: "c1".into(),
971 increment: inc,
972 },
973 ctx(),
974 )
975 .await
976 .unwrap();
977 }
978
979 assert!(enabled
981 .event_store()
982 .load_snapshot("c1")
983 .await
984 .unwrap()
985 .is_some());
986
987 let enabled_state =
988 CounterAggregate::from_events(enabled.event_store().load("c1").await.unwrap());
989 let disabled_state =
990 CounterAggregate::from_events(disabled.event_store().load("c1").await.unwrap());
991 assert_eq!(enabled_state.value, disabled_state.value);
992 assert_eq!(enabled_state.version, disabled_state.version);
993 assert_eq!(enabled_state.value, 25);
994 assert_eq!(enabled_state.version, 5);
995 }
996
997 #[tokio::test]
1000 async fn test_enabled_falls_back_to_replay_without_snapshot() {
1001 let bus = CommandBus::<CounterAggregate>::new(
1004 Box::new(InMemoryEventStore::new()),
1005 Box::new(InProcessEventBus::new()),
1006 )
1007 .with_snapshot_policy(SnapshotPolicy::EveryNEvents(100));
1008
1009 let e1 = bus
1010 .dispatch(
1011 CounterCommand {
1012 id: "c1".into(),
1013 increment: 5,
1014 },
1015 ctx(),
1016 )
1017 .await
1018 .unwrap();
1019 assert_eq!(e1[0].sequence, 1);
1020
1021 let e2 = bus
1022 .dispatch(
1023 CounterCommand {
1024 id: "c1".into(),
1025 increment: 3,
1026 },
1027 ctx(),
1028 )
1029 .await
1030 .unwrap();
1031 assert_eq!(e2[0].sequence, 2);
1032
1033 assert!(bus
1034 .event_store()
1035 .load_snapshot("c1")
1036 .await
1037 .unwrap()
1038 .is_none());
1039 let state = CounterAggregate::from_events(bus.event_store().load("c1").await.unwrap());
1040 assert_eq!(state.value, 8);
1041 assert_eq!(state.version, 2);
1042 }
1043}