From 6a55c952469362b03b0dac3ba180d73c3b4cb42f Mon Sep 17 00:00:00 2001 From: Nicolas Burtey Date: Sat, 9 May 2026 13:22:07 -0400 Subject: [PATCH] test: add proptest coverage for EntityEvents and idempotency_guard Adds two property-based test files exercising the framework's core contracts: - tests/proptest_events.rs covers EntityEvents invariants: mark_new_events_persisted_at sequencing, load_first / load_n JSON roundtrip, load_n grouping across multiple entity ids, and iter_all ordering across arbitrary push/extend/mark interleavings. - tests/proptest_idempotency.rs covers the idempotency_guard! macro: stutter idempotency for both already_applied and resets_on forms, full-retry safety for already_applied-only command sequences, extensional dedup-by-first-occurrence, and resets_on alternation. Each property runs 4096 cases. Total runtime ~0.22s release. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 125 ++++++++++++++++ Cargo.toml | 1 + tests/proptest_events.rs | 249 ++++++++++++++++++++++++++++++++ tests/proptest_idempotency.rs | 258 ++++++++++++++++++++++++++++++++++ 4 files changed, 633 insertions(+) create mode 100644 tests/proptest_events.rs create mode 100644 tests/proptest_idempotency.rs diff --git a/Cargo.lock b/Cargo.lock index e53ae21..ca027ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,6 +150,21 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.11.0" @@ -475,6 +490,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "es-entity" version = "0.10.36-dev" @@ -492,6 +517,7 @@ dependencies = [ "opentelemetry_sdk", "parking_lot", "pin-project", + "proptest", "schemars", "serde", "serde_json", @@ -538,6 +564,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1031,6 +1063,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.1" @@ -1348,6 +1386,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.2", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.45" @@ -1422,6 +1485,15 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "rand_xoshiro" version = "0.6.0" @@ -1538,6 +1610,19 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.37" @@ -1578,6 +1663,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -2065,6 +2162,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -2256,6 +2366,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -2349,6 +2465,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index dd4a15f..29a2762 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ tokio = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } futures = { workspace = true } +proptest = "1.5" [workspace] resolver = "2" diff --git a/tests/proptest_events.rs b/tests/proptest_events.rs new file mode 100644 index 0000000..7304e81 --- /dev/null +++ b/tests/proptest_events.rs @@ -0,0 +1,249 @@ +//! Property-based tests for `EntityEvents` invariants. +//! +//! Targets in `src/events.rs`: +//! - `mark_new_events_persisted_at` sequencing +//! - `load_first` / `load_n` JSON roundtrip +//! - `load_n` grouping across multiple entity ids +//! - `iter_all` ordering (persisted then new, in insertion order) + +use chrono::{TimeZone, Utc}; +use es_entity::*; +use proptest::collection::vec; +use proptest::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum TestEvent { + Created { id: Uuid, name: String }, + Renamed { name: String }, + Tagged { tag: String }, +} + +impl EsEvent for TestEvent { + type EntityId = Uuid; + fn event_context() -> bool { + false + } + fn event_type(&self) -> &'static str { + match self { + TestEvent::Created { .. } => "created", + TestEvent::Renamed { .. } => "renamed", + TestEvent::Tagged { .. } => "tagged", + } + } +} + +struct TestEntity { + events: EntityEvents, +} + +impl EsEntity for TestEntity { + type Event = TestEvent; + type New = NewTestEntity; + fn events(&self) -> &EntityEvents { + &self.events + } + fn events_mut(&mut self) -> &mut EntityEvents { + &mut self.events + } +} + +impl TryFromEvents for TestEntity { + fn try_from_events(events: EntityEvents) -> Result { + Ok(Self { events }) + } +} + +struct NewTestEntity { + id: Uuid, +} + +impl IntoEvents for NewTestEntity { + fn into_events(self) -> EntityEvents { + EntityEvents::init( + self.id, + [TestEvent::Created { + id: self.id, + name: String::new(), + }], + ) + } +} + +// ---------- generators ---------- + +fn arb_event(id: Uuid) -> impl Strategy { + prop_oneof![ + ".*".prop_map(move |name| TestEvent::Created { id, name }), + ".*".prop_map(|name| TestEvent::Renamed { name }), + ".*".prop_map(|tag| TestEvent::Tagged { tag }), + ] +} + +fn arb_event_seq(id: Uuid, max_len: usize) -> impl Strategy> { + vec(arb_event(id), 1..=max_len) +} + +/// "Steps": either push k events, or mark all currently buffered as persisted. +#[derive(Debug, Clone)] +enum Step { + Push(Vec), + MarkPersisted, +} + +fn arb_steps(id: Uuid) -> impl Strategy> { + let step = prop_oneof![ + arb_event_seq(id, 5).prop_map(Step::Push), + Just(Step::MarkPersisted), + ]; + vec(step, 1..15) +} + +// ---------- properties ---------- + +proptest! { + #![proptest_config(ProptestConfig { cases: 4096, ..ProptestConfig::default() })] + + /// After every `mark_new_events_persisted_at` call, persisted events have: + /// - contiguous sequences starting at 1 + /// - monotonically increasing sequences + /// - new_events buffer drained + /// Across arbitrary interleavings of push/extend/mark. + #[test] + fn mark_persisted_sequencing(steps in arb_steps(Uuid::nil())) { + let id = Uuid::nil(); + let mut events: EntityEvents = EntityEvents::init(id, std::iter::empty()); + let mut t = 0i64; + + for step in steps { + match step { + Step::Push(evs) => events.extend(evs), + Step::MarkPersisted => { + if events.any_new() { + t += 1; + events.mark_new_events_persisted_at(Utc.timestamp_opt(t, 0).unwrap()); + } + prop_assert!(!events.any_new(), "buffer should be empty after mark"); + + let seqs: Vec = events.iter_persisted().map(|e| e.sequence).collect(); + for (i, s) in seqs.iter().enumerate() { + prop_assert_eq!(*s, i + 1, "sequences must be contiguous from 1"); + } + } + } + } + } + + /// Roundtrip: events serialized through GenericEvent and back via load_first + /// reproduce the original event sequence in order. + #[test] + fn load_first_roundtrip(evs in arb_event_seq(Uuid::from_u128(1), 20)) { + let id = Uuid::from_u128(1); + let generic: Vec> = evs + .iter() + .enumerate() + .map(|(i, e)| GenericEvent { + entity_id: id, + sequence: (i + 1) as i32, + event: serde_json::to_value(e).unwrap(), + context: None, + recorded_at: Utc.timestamp_opt(1_700_000_000 + i as i64, 0).unwrap(), + }) + .collect(); + + let entity: TestEntity = EntityEvents::load_first(generic) + .expect("load_first") + .expect("entity present"); + + let loaded: Vec<&TestEvent> = entity.events().iter_all().collect(); + prop_assert_eq!(loaded.len(), evs.len()); + for (a, b) in loaded.iter().zip(evs.iter()) { + prop_assert_eq!(*a, b); + } + + // sequences are preserved 1..=N + let seqs: Vec = entity.events().iter_persisted().map(|e| e.sequence).collect(); + prop_assert_eq!(seqs, (1..=evs.len()).collect::>()); + } + + /// `load_n` correctly groups events by entity_id and respects the `n` cap. + /// Generates K entities each with M events; all events for a given id appear + /// contiguously and in sequence order (the documented input contract). + #[test] + fn load_n_grouping( + per_entity in vec(1usize..6, 1..8), + n in 1usize..10, + ) { + let total_entities = per_entity.len(); + let mut generic: Vec> = Vec::new(); + let mut t = 1_700_000_000i64; + + for (idx, count) in per_entity.iter().enumerate() { + let id = Uuid::from_u128(idx as u128 + 1); + for seq in 1..=*count { + generic.push(GenericEvent { + entity_id: id, + sequence: seq as i32, + event: serde_json::to_value(TestEvent::Tagged { + tag: format!("e{idx}-s{seq}"), + }) + .unwrap(), + context: None, + recorded_at: Utc.timestamp_opt(t, 0).unwrap(), + }); + t += 1; + } + } + + let total_events = generic.len(); + let (entities, more) = + EntityEvents::::load_n::(generic, n).expect("load_n"); + + let expected_returned = total_entities.min(n); + prop_assert_eq!(entities.len(), expected_returned); + prop_assert_eq!(more, total_entities > n); + + // Each returned entity contains the right number of events for its position. + for (i, ent) in entities.iter().enumerate() { + prop_assert_eq!(ent.events().len_persisted(), per_entity[i]); + // and the entity_id matches what we generated + prop_assert_eq!(*ent.events().id(), Uuid::from_u128(i as u128 + 1)); + } + + // No event lost when n >= total_entities. + if !more { + let summed: usize = entities.iter().map(|e| e.events().len_persisted()).sum(); + prop_assert_eq!(summed, total_events); + } + } + + /// `iter_all` yields persisted events first (in sequence order), then new events + /// in push order. Holds across any interleaving of push and mark. + #[test] + fn iter_all_ordering(steps in arb_steps(Uuid::nil())) { + let id = Uuid::nil(); + let mut events: EntityEvents = EntityEvents::init(id, std::iter::empty()); + let mut expected: Vec = Vec::new(); + let mut t = 0i64; + + for step in steps { + match step { + Step::Push(evs) => { + expected.extend(evs.iter().cloned()); + events.extend(evs); + } + Step::MarkPersisted => { + if events.any_new() { + t += 1; + events.mark_new_events_persisted_at(Utc.timestamp_opt(t, 0).unwrap()); + } + } + } + // Invariant after every step: iter_all yields exactly `expected`. + let actual: Vec = events.iter_all().cloned().collect(); + prop_assert_eq!(&actual, &expected); + } + } +} diff --git a/tests/proptest_idempotency.rs b/tests/proptest_idempotency.rs new file mode 100644 index 0000000..a467f39 --- /dev/null +++ b/tests/proptest_idempotency.rs @@ -0,0 +1,258 @@ +//! Property-based tests for the `idempotency_guard!` contract. +//! +//! The contract under test: for any sequence of commands whose guards use only +//! `already_applied` (no `resets_on`), applying the same sequence twice produces +//! the same final event stream as applying it once. This is the retry-safety +//! property that callers rely on. +//! +//! Also covers `resets_on` semantics: a "reset" event re-enables the guarded +//! command, so apply/reset/apply produces three events, not two. + +use es_entity::*; +use proptest::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// ---------- toy entity ---------- + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ToyEvent { + Initialized { id: Uuid }, + Renamed { name: String }, + Tagged { tag: String }, + Activated, + Deactivated, +} + +impl EsEvent for ToyEvent { + type EntityId = Uuid; + fn event_context() -> bool { + false + } + fn event_type(&self) -> &'static str { + match self { + ToyEvent::Initialized { .. } => "initialized", + ToyEvent::Renamed { .. } => "renamed", + ToyEvent::Tagged { .. } => "tagged", + ToyEvent::Activated => "activated", + ToyEvent::Deactivated => "deactivated", + } + } +} + +struct Toy { + events: EntityEvents, +} + +impl Toy { + fn new(id: Uuid) -> Self { + Self { + events: EntityEvents::init(id, [ToyEvent::Initialized { id }]), + } + } + + /// `already_applied` only — once renamed to N, repeat is a no-op forever. + fn set_name(&mut self, name: String) -> Idempotent<()> { + idempotency_guard!( + self.events.iter_all(), + already_applied: ToyEvent::Renamed { name: existing } if existing == &name + ); + self.events.push(ToyEvent::Renamed { name }); + Idempotent::Executed(()) + } + + /// `already_applied` only — additive, set semantics. + fn add_tag(&mut self, tag: String) -> Idempotent<()> { + idempotency_guard!( + self.events.iter_all(), + already_applied: ToyEvent::Tagged { tag: existing } if existing == &tag + ); + self.events.push(ToyEvent::Tagged { tag }); + Idempotent::Executed(()) + } + + /// `already_applied + resets_on` — Activate is no-op until a Deactivate + /// resets the scan window. + fn activate(&mut self) -> Idempotent<()> { + idempotency_guard!( + self.events.iter_all().rev(), + already_applied: ToyEvent::Activated, + resets_on: ToyEvent::Deactivated + ); + self.events.push(ToyEvent::Activated); + Idempotent::Executed(()) + } + + fn deactivate(&mut self) -> Idempotent<()> { + idempotency_guard!( + self.events.iter_all().rev(), + already_applied: ToyEvent::Deactivated, + resets_on: ToyEvent::Activated + ); + self.events.push(ToyEvent::Deactivated); + Idempotent::Executed(()) + } + + fn event_stream(&self) -> Vec { + self.events.iter_all().cloned().collect() + } +} + +// ---------- command DSL ---------- + +#[derive(Debug, Clone)] +enum Cmd { + SetName(String), + AddTag(String), + Activate, + Deactivate, +} + +impl Cmd { + fn apply(&self, toy: &mut Toy) -> bool { + let r = match self { + Cmd::SetName(n) => toy.set_name(n.clone()), + Cmd::AddTag(t) => toy.add_tag(t.clone()), + Cmd::Activate => toy.activate(), + Cmd::Deactivate => toy.deactivate(), + }; + r.did_execute() + } +} + +// Small string pool keeps the search space narrow enough that collisions +// (and therefore guard hits) actually happen. +fn arb_name() -> impl Strategy { + prop_oneof![Just("a".into()), Just("b".into()), Just("c".into())] +} +fn arb_tag() -> impl Strategy { + prop_oneof![Just("x".into()), Just("y".into()), Just("z".into())] +} + +/// Commands restricted to `already_applied`-only guards. These are full-retry safe. +fn arb_pure_cmd() -> impl Strategy { + prop_oneof![ + arb_name().prop_map(Cmd::SetName), + arb_tag().prop_map(Cmd::AddTag), + ] +} + +/// All commands, including the `resets_on` ones. +fn arb_any_cmd() -> impl Strategy { + prop_oneof![ + arb_name().prop_map(Cmd::SetName), + arb_tag().prop_map(Cmd::AddTag), + Just(Cmd::Activate), + Just(Cmd::Deactivate), + ] +} + +// ---------- properties ---------- + +proptest! { + #![proptest_config(ProptestConfig { cases: 4096, ..ProptestConfig::default() })] + + /// Stutter: applying any single command twice in a row equals applying it once. + /// Holds for both `already_applied`-only and `resets_on` guards. + #[test] + fn stutter_is_idempotent(cmd in arb_any_cmd()) { + let id = Uuid::nil(); + let mut a = Toy::new(id); + let mut b = Toy::new(id); + + cmd.apply(&mut a); + cmd.apply(&mut b); + let executed_again = cmd.apply(&mut b); + + prop_assert!(!executed_again, "second apply should be AlreadyApplied"); + prop_assert_eq!(a.event_stream(), b.event_stream()); + } + + /// Full retry safety for `already_applied`-only commands: + /// `apply(cs ++ cs)` produces the same event stream as `apply(cs)`. + /// This is the contract callers depend on — re-driving a command sequence + /// after a crash/retry must not produce duplicate events. + #[test] + fn full_retry_is_noop_for_pure_commands(cmds in proptest::collection::vec(arb_pure_cmd(), 0..20)) { + let id = Uuid::nil(); + let mut once = Toy::new(id); + let mut twice = Toy::new(id); + + for c in &cmds { + c.apply(&mut once); + } + for c in &cmds { + c.apply(&mut twice); + } + // Replay + for c in &cmds { + let executed = c.apply(&mut twice); + prop_assert!(!executed, "replay of {:?} pushed a new event", c); + } + + prop_assert_eq!(once.event_stream(), twice.event_stream()); + } + + /// Extensional equality: the resulting event stream of an `already_applied`-only + /// sequence equals the deduplicated first-occurrence subsequence of pushed events. + /// (Not strictly needed for correctness, but a stronger statement that pins down + /// what `already_applied` actually does.) + #[test] + fn pure_commands_dedup_by_first_occurrence(cmds in proptest::collection::vec(arb_pure_cmd(), 0..20)) { + let id = Uuid::nil(); + let mut toy = Toy::new(id); + for c in &cmds { + c.apply(&mut toy); + } + + // Build the expected stream: Initialized, then each unique command in + // first-occurrence order mapped to its event. + let mut seen_names: Vec = Vec::new(); + let mut seen_tags: Vec = Vec::new(); + let mut expected: Vec = vec![ToyEvent::Initialized { id }]; + for c in &cmds { + match c { + Cmd::SetName(n) if !seen_names.contains(n) => { + seen_names.push(n.clone()); + expected.push(ToyEvent::Renamed { name: n.clone() }); + } + Cmd::AddTag(t) if !seen_tags.contains(t) => { + seen_tags.push(t.clone()); + expected.push(ToyEvent::Tagged { tag: t.clone() }); + } + _ => {} + } + } + + prop_assert_eq!(toy.event_stream(), expected); + } + + /// `resets_on` semantics: between two Activates, a Deactivate must allow + /// the second Activate to fire. activate; deactivate; activate produces 3 events + /// past the initial Initialized (any redundant intermediate calls dedup). + /// Generalized: any non-empty alternation of Activate/Deactivate stays non-empty + /// and never produces two consecutive identical states. + #[test] + fn resets_on_allows_alternation(toggles in proptest::collection::vec(prop::bool::ANY, 1..15)) { + let id = Uuid::nil(); + let mut toy = Toy::new(id); + for &active in &toggles { + let _ = if active { toy.activate() } else { toy.deactivate() }; + } + let stream = toy.event_stream(); + + // No two consecutive Activated or two consecutive Deactivated events. + for w in stream.windows(2) { + match (&w[0], &w[1]) { + (ToyEvent::Activated, ToyEvent::Activated) => { + prop_assert!(false, "consecutive Activated events: {:?}", stream); + } + (ToyEvent::Deactivated, ToyEvent::Deactivated) => { + prop_assert!(false, "consecutive Deactivated events: {:?}", stream); + } + _ => {} + } + } + } +}