From 4bde91f3ef8ea2228c536a970bb40c11d33c115d Mon Sep 17 00:00:00 2001 From: Sinity Date: Mon, 17 Nov 2025 18:28:16 +0100 Subject: [PATCH 1/4] test(harness): reintroduce the sinex_prop baseline - Reapply the shared property harness across the affected suites after the earlier rollback. - Restore the baseline logging/failure behavior needed for later macro fixes. --- .cargo-machete.toml | 6 +- TESTING.md | 8 +- .../adversarial/enhanced_boundary_test.rs | 63 +- crate/lib/sinex-core/tests/event_property.rs | 174 +-- .../property/event_model_fuzzing_test.rs | 142 +- .../event_validation_property_test.rs | 640 ++++---- .../path_sanitization_property_test.rs | 414 +++-- .../tests/property/schema_property_test.rs | 319 ++-- .../property/time_range_property_test.rs | 80 +- .../tests/property/ulid_property_test.rs | 232 ++- .../validation_roundtrip_property_test.rs | 76 +- crate/lib/sinex-core/tests/property_tests.rs | 251 +-- .../sinex-core/tests/unit/preflight_test.rs | 18 +- .../property/automation_property_test.rs | 93 +- .../property/checkpoint_property_test.rs | 625 ++++---- .../property/error_handling_property_test.rs | 52 +- .../tests/property/queue_property_test.rs | 76 +- .../tests/property/satellite_property_test.rs | 665 ++++---- .../validation_invariants_property_test.rs | 71 +- .../tests/property_checkpoint_state.rs | 36 +- .../tests/security/path_validation_test.rs | 4 +- crate/lib/sinex-schema/docs/architecture.md | 5 +- .../tests/ulid_property_from_main_tests.rs | 68 +- .../sinex-schema/tests/ulid_property_tests.rs | 19 +- crate/lib/sinex-schema/tests/ulid_tests.rs | 13 +- crate/lib/sinex-test-utils/macros/src/lib.rs | 1339 +++++++++++++---- .../lib/sinex-test-utils/src/database_pool.rs | 2 +- crate/lib/sinex-test-utils/src/lib.rs | 5 +- .../sinex-test-utils/src/property_testing.rs | 624 ++------ crate/lib/sinex-test-utils/src/runlog.rs | 251 +++ .../lib/sinex-test-utils/src/test_context.rs | 67 +- 31 files changed, 3323 insertions(+), 3115 deletions(-) create mode 100644 crate/lib/sinex-test-utils/src/runlog.rs diff --git a/.cargo-machete.toml b/.cargo-machete.toml index 86fd897b95..834bb31681 100644 --- a/.cargo-machete.toml +++ b/.cargo-machete.toml @@ -11,10 +11,6 @@ sinex-satellite-sdk = ["tonic-build"] sinex-services = ["sinex-test-utils"] sinex-terminal-command-canonicalizer = ["sinex-test-utils"] sinex-health-aggregator = ["sinex-test-utils"] -sinex-db = ["tokio-test"] - -# Used by feature flags or conditional compilation -sinex-db = ["sea-orm-migration", "prometheus", "sysinfo", "procfs", "parking_lot", "sinex-macros"] # Used in binaries that need logging setup sinex-desktop-satellite = ["tracing-subscriber"] @@ -24,4 +20,4 @@ sinex-terminal-satellite = ["tracing-subscriber"] # Platform-specific or conditional dependencies sinex-terminal-satellite = ["xterm-query"] -sinex-system-satellite = ["battery", "procfs", "systemstat"] \ No newline at end of file +sinex-system-satellite = ["battery", "procfs", "systemstat"] diff --git a/TESTING.md b/TESTING.md index acd4521c7d..1093a03257 100644 --- a/TESTING.md +++ b/TESTING.md @@ -89,10 +89,10 @@ tests are reserved for scenarios that truly span multiple crates or binaries. .await?; ``` - **Property tests:** place proptest suites alongside the crate they fuzz (see - below). Use `#[sinex_test(proptest)]` so the harness provides tracing, - timeouts, and `TestContext`; add `cases = 256` (or any other number) to fix - the `PROPTEST_CASES` used by `TestRunner`. Capture any new failing seeds in - that crate’s `tests/property/*.proptest-regressions` files. + below). Use `#[sinex_prop]` (or the block-style `sinex_proptest!`) so the + harness provides tracing, timeouts, context wiring, and seed persistence. + Add `cases = 256` (or any number) to lock the runner config. Capture any + failing seeds in that crate’s `tests/property/*.proptest-regressions` files. ## Property Testing Guidelines diff --git a/crate/lib/sinex-core/tests/adversarial/enhanced_boundary_test.rs b/crate/lib/sinex-core/tests/adversarial/enhanced_boundary_test.rs index ee7987ad06..9a1bc67072 100644 --- a/crate/lib/sinex-core/tests/adversarial/enhanced_boundary_test.rs +++ b/crate/lib/sinex-core/tests/adversarial/enhanced_boundary_test.rs @@ -2,7 +2,7 @@ // // Tests system behavior at boundaries, limits, and edge cases -use sinex_test_utils::prelude::*; +use sinex_test_utils::{prelude::*, sinex_prop}; use proptest::prelude::*; use std::sync::Arc; use tokio::time::{Duration, timeout}; @@ -340,40 +340,35 @@ async fn test_string_length_boundaries(ctx: TestContext) -> Result<()> { } /// Property-based testing for boundary conditions -#[sinex_test] -async fn test_property_based_boundaries(ctx: TestContext) -> Result<()> { - proptest!(|( - array_size in 0..1000usize, - string_len in 0..10000usize, - nest_depth in 0..50usize - )| { - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(async { - let array: Vec = (0..array_size as i32).collect(); - let text = "x".repeat(string_len); - - let mut nested = serde_json::json!("leaf"); - for _ in 0..nest_depth { - nested = serde_json::json!({"child": nested}); - } +#[sinex_prop] +async fn test_property_based_boundaries( + ctx: &TestContext, + #[strategy(0..1000usize)] array_size: usize, + #[strategy(0..10000usize)] string_len: usize, + #[strategy(0..50usize)] nest_depth: usize, +) -> Result<()> { + let array: Vec = (0..array_size as i32).collect(); + let text = "x".repeat(string_len); + + let mut nested = serde_json::json!("leaf"); + for _ in 0..nest_depth { + nested = serde_json::json!({ "child": nested }); + } - let event = ctx.create_test_event( - "property_test", - "boundary", - serde_json::json!({ - "array": array, - "text": text, - "nested": nested, - "array_size": array_size, - "string_len": string_len, - "nest_depth": nest_depth - }), - ); - - // We don't assert success, just that it doesn't panic - let _ = ctx.pool.events().insert(event).await; - }); - }); + let event = ctx.create_test_event( + "property_test", + "boundary", + serde_json::json!({ + "array": array, + "text": text, + "nested": nested, + "array_size": array_size, + "string_len": string_len, + "nest_depth": nest_depth + }), + ); + // We don't assert success, just that it doesn't panic + let _ = ctx.pool.events().insert(event).await; Ok(()) } diff --git a/crate/lib/sinex-core/tests/event_property.rs b/crate/lib/sinex-core/tests/event_property.rs index 76da5f27e5..850d6c18f7 100644 --- a/crate/lib/sinex-core/tests/event_property.rs +++ b/crate/lib/sinex-core/tests/event_property.rs @@ -159,20 +159,14 @@ fn arb_event() -> impl Strategy { // Event Serialization Property Tests // ============================================================================= -#[sinex_test] -fn test_event_serde_roundtrip() -> Result<()> { - proptest::proptest!(|(event in arb_event())| { - // Serialize to JSON +sinex_proptest! { + fn test_event_serde_roundtrip(event: RawEvent in arb_event()) -> Result<()> { let json_str = serde_json::to_string(&event).unwrap(); - - // Deserialize back let deserialized: RawEvent = serde_json::from_str(&json_str).unwrap(); - // Should be identical prop_assert_eq!(event.id.as_ref(), deserialized.id.as_ref()); prop_assert_eq!(event.source, deserialized.source); prop_assert_eq!(event.event_type, deserialized.event_type); - // Compare derived ingest times via ULID timestamps let a = event.id.as_ref().map(|id| id.as_ulid().timestamp()); let b = deserialized.id.as_ref().map(|id| id.as_ulid().timestamp()); prop_assert_eq!(a, b); @@ -180,20 +174,17 @@ fn test_event_serde_roundtrip() -> Result<()> { prop_assert_eq!(event.host, deserialized.host); prop_assert_eq!(event.ingestor_version, deserialized.ingestor_version); prop_assert_eq!(event.payload_schema_id, deserialized.payload_schema_id); - - // For payload, use a custom comparison that handles floating-point precision prop_assert!(json_values_equal(&event.payload, &deserialized.payload)); - }); - Ok(()) + Ok(()) + } } -#[sinex_test] -fn test_event_id_properties() -> Result<()> { - proptest::proptest!(|( - source in arb_source_name(), - event_type in arb_event_type_name(), - payload in arb_json_value() - )| { +sinex_proptest! { + fn test_event_id_properties( + source: String in arb_source_name(), + event_type: String in arb_event_type_name(), + payload: Value in arb_json_value() + ) -> Result<()> { let mut event1 = RawEvent::test_event( EventSource::new(source.clone()), EventType::new(event_type.clone()), @@ -201,7 +192,6 @@ fn test_event_id_properties() -> Result<()> { ); event1.id = Some(Id::from_ulid(Ulid::new())); - // Small delay to reduce the chance of identical ULID timestamps std::thread::yield_now(); let mut event2 = RawEvent::test_event( @@ -211,13 +201,11 @@ fn test_event_id_properties() -> Result<()> { ); event2.id = Some(Id::from_ulid(Ulid::new())); - // Events should be unique even if ULID timestamps collide prop_assert_ne!( event1.id.as_ref().unwrap(), event2.id.as_ref().unwrap() ); - // ts_ingest should be recent let now = Utc::now(); let t1 = event1.id.as_ref().unwrap().as_ulid().timestamp(); let t2 = event2.id.as_ref().unwrap().as_ulid().timestamp(); @@ -225,53 +213,37 @@ fn test_event_id_properties() -> Result<()> { prop_assert!(t2 <= now); prop_assert!(now - t1 < ChronoDuration::seconds(10)); prop_assert!(now - t2 < ChronoDuration::seconds(10)); - }); - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn test_event_field_constraints() -> Result<()> { - proptest::proptest!(|(event in arb_event())| { - // Source should not be empty + fn test_event_field_constraints(event: RawEvent in arb_event()) -> Result<()> { prop_assert!(!event.source.is_empty()); - prop_assert!(event.source.len() <= 255); // Reasonable database limit - - // Event type should not be empty + prop_assert!(event.source.len() <= 255); prop_assert!(!event.event_type.is_empty()); prop_assert!(event.event_type.len() <= 255); - - // Host should not be empty prop_assert!(!event.host.is_empty()); - // Derived ingest time should be recent (within last hour) let now = Utc::now(); let t = event.id.as_ref().unwrap().as_ulid().timestamp(); prop_assert!(t <= now); prop_assert!(now - t < ChronoDuration::hours(1)); - // If ts_orig is present, it should be reasonable if let Some(ts_orig) = event.ts_orig { - // Original timestamp should not be in the far future prop_assert!(ts_orig <= now + ChronoDuration::hours(1)); - // Original timestamp should not be too old (1 year) prop_assert!(ts_orig >= now - ChronoDuration::days(365)); } - // Payload should be valid JSON prop_assert!(serde_json::to_string(&event.payload).is_ok()); - }); - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn test_event_builder_preserves_values() -> Result<()> { - proptest::proptest!(|( - source in arb_source_name(), - event_type in arb_event_type_name(), - payload in arb_json_value(), - ts_orig in arb_timestamp(), - host in arb_hostname() - )| { + fn test_event_builder_preserves_values( + source: String in arb_source_name(), + event_type: String in arb_event_type_name(), + payload: Value in arb_json_value(), + ts_orig: chrono::DateTime in arb_timestamp(), + host: String in arb_hostname() + ) -> Result<()> { let mut event = RawEvent::test_event( EventSource::new(source.clone()), EventType::new(event_type.clone()), @@ -286,17 +258,14 @@ fn test_event_builder_preserves_values() -> Result<()> { prop_assert_eq!(event.payload, payload); prop_assert_eq!(event.ts_orig, Some(ts_orig)); prop_assert_eq!(event.host.as_str(), host); - }); - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn test_multiple_events_created_in_sequence_should_have_ordered_timestamps() -> Result<()> { - proptest::proptest!(|( - source in arb_source_name(), - event_type in arb_event_type_name(), - payloads in prop::collection::vec(arb_json_value(), 2..20) - )| { + fn test_multiple_events_created_in_sequence_should_have_ordered_timestamps( + source: String in arb_source_name(), + event_type: String in arb_event_type_name(), + payloads: Vec in prop::collection::vec(arb_json_value(), 2..20) + ) -> Result<()> { let mut events = Vec::new(); for payload in payloads { @@ -307,27 +276,21 @@ fn test_multiple_events_created_in_sequence_should_have_ordered_timestamps() -> ); event.id = Some(Id::from_ulid(Ulid::new())); events.push(event); - - // Small delay to ensure timestamp ordering std::thread::yield_now(); } - // Timestamps should be in ascending order for window in events.windows(2) { let a = window[0].id.as_ref().unwrap().as_ulid().timestamp(); let b = window[1].id.as_ref().unwrap().as_ulid().timestamp(); prop_assert!(a <= b); } - }); - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn test_event_edge_case_payloads() -> Result<()> { - proptest::proptest!(|( - source in arb_source_name(), - event_type in arb_event_type_name() - )| { + fn test_event_edge_case_payloads( + source: String in arb_source_name(), + event_type: String in arb_event_type_name() + ) -> Result<()> { let edge_cases = vec![ json!(null), json!({}), @@ -336,8 +299,8 @@ fn test_event_edge_case_payloads() -> Result<()> { json!(0), json!(false), json!({"nested": {"deep": {"very": {"deeply": {"nested": "value"}}}}}), - json!((0..100).collect::>()), // Large array - json!({"key": "x".repeat(1000)}), // Large string + json!((0..100).collect::>()), + json!({"key": "x".repeat(1000)}), ]; for payload in edge_cases { @@ -348,13 +311,12 @@ fn test_event_edge_case_payloads() -> Result<()> { ); event.id = Some(Id::from_ulid(Ulid::new())); - // Should serialize and deserialize correctly let json_str = serde_json::to_string(&event).unwrap(); let deserialized: RawEvent = serde_json::from_str(&json_str).unwrap(); prop_assert_eq!(event.payload, deserialized.payload); } - }); - Ok(()) + Ok(()) + } } // ============================================================================= @@ -403,15 +365,13 @@ fn arb_registry_source_name() -> impl Strategy { ] } -#[sinex_test] -fn test_event_type_validation_property() -> Result<()> { - proptest::proptest!(|(event_type_str in arb_event_type())| { +sinex_proptest! { + fn test_event_type_validation_property( + event_type_str: String in arb_event_type() + ) -> Result<()> { let event_type = EventType::new(event_type_str.clone()); - - // Test validation rules match event_type.validate() { Ok(()) => { - // Valid event types should follow naming conventions prop_assert!(!event_type_str.is_empty()); prop_assert!(!event_type_str.starts_with('.')); prop_assert!(!event_type_str.ends_with('.')); @@ -421,50 +381,48 @@ fn test_event_type_validation_property() -> Result<()> { )); } Err(_) => { - // Invalid event types should violate at least one rule - let violates_rules = event_type_str.is_empty() || - event_type_str.starts_with('.') || - event_type_str.ends_with('.') || - event_type_str.contains("..") || - !event_type_str.chars().all(|c| + let violates_rules = event_type_str.is_empty() + || event_type_str.starts_with('.') + || event_type_str.ends_with('.') + || event_type_str.contains("..") + || !event_type_str.chars().all(|c| c.is_ascii_lowercase() || c == '.' || c == '_' || c == '-' ); - prop_assert!(violates_rules, + prop_assert!( + violates_rules, "Event type '{}' failed validation but doesn't violate known rules", - event_type_str); + event_type_str + ); } } - }); - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn test_event_source_validation_property() -> Result<()> { - proptest::proptest!(|(source_str in arb_registry_source_name())| { + fn test_event_source_validation_property( + source_str: String in arb_registry_source_name() + ) -> Result<()> { let source = EventSource::new(source_str.clone()); - - // Test validation rules match source.validate() { Ok(()) => { - // Valid sources should follow naming conventions prop_assert!(!source_str.is_empty()); prop_assert!(source_str.chars().all(|c| c.is_ascii_lowercase() || c == '-' || c == '_' )); } Err(_) => { - // Invalid sources should violate at least one rule - let violates_rules = source_str.is_empty() || - !source_str.chars().all(|c| + let violates_rules = source_str.is_empty() + || !source_str.chars().all(|c| c.is_ascii_lowercase() || c == '-' || c == '_' ); - prop_assert!(violates_rules, + prop_assert!( + violates_rules, "Event source '{}' failed validation but doesn't violate known rules", - source_str); + source_str + ); } } - }); - Ok(()) + Ok(()) + } } // ============================================================================= diff --git a/crate/lib/sinex-core/tests/property/event_model_fuzzing_test.rs b/crate/lib/sinex-core/tests/property/event_model_fuzzing_test.rs index 01a599e1ac..28a8ae8348 100644 --- a/crate/lib/sinex-core/tests/property/event_model_fuzzing_test.rs +++ b/crate/lib/sinex-core/tests/property/event_model_fuzzing_test.rs @@ -398,35 +398,24 @@ fn fuzzed_system_payloads() -> impl Strategy { // ============================================================================ /// Test that event creation and database insertion never panics with arbitrary fuzzed events -#[sinex_test] -fn test_event_creation_never_panics_with_fuzzed_data() -> TestResult<()> { - proptest::proptest!(|(event in fuzzed_events())| { - // The critical assertion: event creation and serialization should never panic, regardless of input - // It should either succeed or return an error gracefully - - // Test JSON serialization (used throughout the system) +sinex_proptest! { + fn test_event_creation_never_panics_with_fuzzed_data( + event in fuzzed_events() + ) -> TestResult<()> { let _ = serde_json::to_string(&event); - - // Test pretty printing as well (used in stdout output) let _ = serde_json::to_string_pretty(&event); - - // Test that we can access event fields without panicking let _ = event.source.as_str(); let _ = event.event_type.as_str(); let _ = event.host.as_str(); let _ = &event.payload; - // derive ingest from ULID if needed let _ = event.ts_orig; let _ = event.ingestor_version; let _ = event.payload_schema_id; - }); - Ok(()) -} + Ok(()) + } /// Test filesystem events with extreme payloads -#[sinex_test] -fn test_filesystem_events_robustness() -> TestResult<()> { - proptest::proptest!(|( + fn test_filesystem_events_robustness( payload in fuzzed_filesystem_payloads(), source in problematic_strings(), event_type in prop_oneof![ @@ -437,7 +426,7 @@ fn test_filesystem_events_robustness() -> TestResult<()> { Just("dir.created"), Just("dir.deleted"), ], - )| { + ) -> TestResult<()> { let mut event = Event::test_event( EventSource::new(if source.is_empty() { "fs".to_string() } else { source }), EventType::new(event_type.to_string()), @@ -445,21 +434,15 @@ fn test_filesystem_events_robustness() -> TestResult<()> { ); event.id = Some(Id::from_ulid(Ulid::new())); - // Should not panic during event operations let _json_result = serde_json::to_string(&event); - - // Test field access let _ = event.source.as_str(); let _ = event.event_type.as_str(); let _ = &event.payload; - }); - Ok(()) -} + Ok(()) + } /// Test terminal events with extreme payloads -#[sinex_test] -fn test_terminal_events_robustness() -> TestResult<()> { - proptest::proptest!(|( + fn test_terminal_events_robustness( payload in fuzzed_terminal_payloads(), source in prop_oneof![ Just("shell.kitty"), @@ -474,7 +457,7 @@ fn test_terminal_events_robustness() -> TestResult<()> { Just("session.started"), Just("session.ended"), ], - )| { + ) -> TestResult<()> { let mut event = Event::test_event( EventSource::new(source.to_string()), EventType::new(event_type.to_string()), @@ -482,27 +465,21 @@ fn test_terminal_events_robustness() -> TestResult<()> { ); event.id = Some(Id::from_ulid(Ulid::new())); - // Should not panic during event operations let _json_result = serde_json::to_string(&event); - - // Test field access let _ = event.source.as_str(); let _ = event.event_type.as_str(); let _ = &event.payload; - }); - Ok(()) -} + Ok(()) + } /// Test clipboard events with extreme payloads -#[sinex_test] -fn test_clipboard_events_robustness() -> TestResult<()> { - proptest::proptest!(|( + fn test_clipboard_events_robustness( payload in fuzzed_clipboard_payloads(), event_type in prop_oneof![ Just("copied"), Just("selected"), ], - )| { + ) -> TestResult<()> { let mut event = Event::test_event( EventSource::new("clipboard".to_string()), EventType::new(event_type.to_string()), @@ -510,21 +487,16 @@ fn test_clipboard_events_robustness() -> TestResult<()> { ); event.id = Some(Id::from_ulid(Ulid::new())); - // Should not panic during event operations let _json_result = serde_json::to_string(&event); - - // Test field access let _ = event.source.as_str(); let _ = event.event_type.as_str(); let _ = &event.payload; - }); - Ok(()) -} + Ok(()) + } /// Test window manager events with extreme payloads -#[sinex_test] -fn test_window_manager_events_robustness() -> TestResult<()> { - proptest::proptest!(|( +sinex_proptest! { + fn test_window_manager_events_robustness( payload in fuzzed_window_manager_payloads(), event_type in prop_oneof![ Just("window.opened"), @@ -536,7 +508,7 @@ fn test_window_manager_events_robustness() -> TestResult<()> { Just("workspace.created"), Just("workspace.destroyed"), ], - )| { + ) -> TestResult<()> { let mut event = Event::test_event( EventSource::new("wm.hyprland".to_string()), EventType::new(event_type.to_string()), @@ -544,21 +516,16 @@ fn test_window_manager_events_robustness() -> TestResult<()> { ); event.id = Some(Id::from_ulid(Ulid::new())); - // Should not panic during event operations let _json_result = serde_json::to_string(&event); - - // Test field access let _ = event.source.as_str(); let _ = event.event_type.as_str(); let _ = &event.payload; - }); - Ok(()) + Ok(()) + } } /// Test system events with extreme payloads -#[sinex_test] -fn test_system_events_robustness() -> TestResult<()> { - proptest::proptest!(|( + fn test_system_events_robustness( payload in fuzzed_system_payloads(), source in prop_oneof![ Just("dbus"), @@ -570,7 +537,7 @@ fn test_system_events_robustness() -> TestResult<()> { Just("entry.written"), Just("state.changed"), ], - )| { + ) -> TestResult<()> { let mut event = Event::test_event( EventSource::new(source.to_string()), EventType::new(event_type.to_string()), @@ -578,23 +545,17 @@ fn test_system_events_robustness() -> TestResult<()> { ); event.id = Some(Id::from_ulid(Ulid::new())); - // Should not panic during event operations let _json_result = serde_json::to_string(&event); - - // Test field access let _ = event.source.as_str(); let _ = event.event_type.as_str(); let _ = &event.payload; - }); - Ok(()) -} + Ok(()) + } /// Test JSON serialization robustness with extreme payloads -#[sinex_test] -fn test_json_serialization_robustness() -> TestResult<()> { - proptest::proptest!(|( - payload in malformed_json_values(), - )| { + fn test_json_serialization_robustness( + payload in malformed_json_values() + ) -> TestResult<()> { let mut event = Event::test_event( EventSource::new("test".to_string()), EventType::new("test.event".to_string()), @@ -602,29 +563,21 @@ fn test_json_serialization_robustness() -> TestResult<()> { ); event.id = Some(Id::from_ulid(Ulid::new())); - // Test that JSON serialization never panics let json_result = serde_json::to_string(&event); match json_result { - Ok(_) => { - // Success is fine - } - Err(_) => { - // Serialization errors are acceptable as long as no panic - } + Ok(_) => {} + Err(_) => {} } - // Test pretty printing as well (used in stdout output) let _ = serde_json::to_string_pretty(&event); - }); - Ok(()) -} + Ok(()) + } -/// Test ULID robustness with extreme timestamps -#[sinex_test] -fn test_ulid_robustness_with_extreme_timestamps() -> TestResult<()> { - proptest::proptest!(|( - timestamp in problematic_timestamps(), - )| { +sinex_proptest! { + /// Test ULID robustness with extreme timestamps + fn test_ulid_robustness_with_extreme_timestamps( + timestamp in problematic_timestamps() + ) -> TestResult<()> { // Test that ULID creation with extreme timestamps doesn't panic let ulid = Ulid::new(); @@ -645,18 +598,15 @@ fn test_ulid_robustness_with_extreme_timestamps() -> TestResult<()> { // Verify the event can be serialized let _json = serde_json::to_string(&event); - }); - Ok(()) -} + Ok(()) + } -/// Test string handling robustness -#[sinex_test] -fn test_string_handling_robustness() -> TestResult<()> { - proptest::proptest!(|( + /// Test string handling robustness + fn test_string_handling_robustness( source in problematic_strings(), event_type in problematic_strings(), host in problematic_strings(), - )| { + ) -> TestResult<()> { let mut event = Event::test_event( EventSource::new(source.clone()), EventType::new(event_type.clone()), @@ -671,8 +621,8 @@ fn test_string_handling_robustness() -> TestResult<()> { // Test that we can create the struct without panicking by checking key fields prop_assert_eq!(event.source.as_str(), source.as_str()); prop_assert_eq!(event.host.as_str(), host.as_str()); - }); - Ok(()) + Ok(()) + } } // ============================================================================ diff --git a/crate/lib/sinex-core/tests/property/event_validation_property_test.rs b/crate/lib/sinex-core/tests/property/event_validation_property_test.rs index b3acc6dbb6..38f8783b65 100644 --- a/crate/lib/sinex-core/tests/property/event_validation_property_test.rs +++ b/crate/lib/sinex-core/tests/property/event_validation_property_test.rs @@ -197,229 +197,294 @@ fn validate_event(event: &RawEvent) -> std::result::Result<(), String> { // Core Property Tests // ============================================================================= -#[sinex_test] -fn test_valid_events_pass_validation() -> color_eyre::eyre::Result<()> { - proptest::proptest! { - #![proptest_config(ProptestConfig::with_cases(1000))] +sinex_proptest! { + fn test_valid_events_pass_validation( + event in arbitrary_event() + ) -> color_eyre::eyre::Result<()> { + let result = validate_event(&event); + prop_assert!(result.is_ok(), "Generated event should pass validation: {:?}", result); + Ok(()) + } - fn property_valid_events_pass_validation( - event in arbitrary_event() - ) { - // Property: All events generated by property builders should pass validation - let result = validate_event(&event); + fn test_empty_source_fails_validation( + event in empty_source_event() + ) -> color_eyre::eyre::Result<()> { + let result = validate_event(&event); + prop_assert!(result.is_err(), "Event with empty source should fail validation"); + if let Err(e) = result { + prop_assert!( + e.to_string().contains("source") || e.to_string().contains("empty"), + "Error should mention source issue: {}", + e + ); + } + Ok(()) + } + + fn test_event_field_constraints( + source in "[a-z][a-z0-9_]{0,49}", + event_type in "[a-z][a-z0-9_.]{0,99}", + host in "[a-zA-Z0-9_.-]{1,255}", + payload in event_payloads() + ) -> color_eyre::eyre::Result<()> { + let mut event = RawEvent::test_event( + EventSource::new(source.clone()), + EventType::new(event_type.clone()), + payload, + ); + event.host = HostName::new(host.clone()); + event.id = Some(Id::from_ulid(Ulid::new())); + + prop_assert!(!event.source.is_empty()); + prop_assert!(!event.event_type.is_empty()); + prop_assert!(event.source.len() <= 50); + prop_assert!(event.event_type.len() <= 100); + prop_assert!(event.host.len() <= 255); + Ok(()) + } - prop_assert!(result.is_ok(), "Generated event should pass validation: {:?}", result); + fn test_payload_size_validation( + size_kb in 1usize..=1000usize + ) -> color_eyre::eyre::Result<()> { + let large_data = "x".repeat(size_kb * 1024); + let payload = json!({ + "data": large_data, + "size_kb": size_kb + }); + + let mut event = RawEvent::test_event( + EventSource::new("test"), + EventType::new("payload.size.test"), + payload, + ); + event.id = Some(Id::from_ulid(Ulid::new())); + + let serialized = serde_json::to_string(&event); + prop_assert!(serialized.is_ok(), "Should serialize large payload"); + + if let Ok(json_str) = serialized { + prop_assert!(json_str.len() > size_kb * 1024, + "Serialized size should exceed payload size"); } + Ok(()) } - Ok(()) -} -#[sinex_test] -fn test_empty_source_fails_validation() -> color_eyre::eyre::Result<()> { - proptest::proptest! { - fn property_empty_source_fails_validation( - event in empty_source_event() - ) { - // Property: Events with empty source should fail validation - let result = validate_event(&event); + fn test_event_timestamp_consistency( + event in arbitrary_event() + ) -> color_eyre::eyre::Result<()> { + if let Some(id) = event.id { + let ingest_ts = id.timestamp(); + let ts_orig = event.ts_orig.unwrap_or(ingest_ts); + prop_assert!(ingest_ts >= ts_orig, + "ULID timestamp should not be before origin time"); + } - prop_assert!(result.is_err(), "Event with empty source should fail validation"); - if let Err(e) = result { - prop_assert!(e.to_string().contains("source") || e.to_string().contains("empty"), - "Error should mention source issue: {}", e); - } + if let Some(ts_orig) = event.ts_orig { + let now = chrono::Utc::now(); + let diff = (now - ts_orig).num_days().abs(); + prop_assert!(diff < 365 * 100, + "Timestamp should be within 100 years of now"); } + Ok(()) } - Ok(()) -} -#[sinex_test] -fn test_event_field_constraints() -> color_eyre::eyre::Result<()> { - proptest::proptest! { - fn property_event_field_constraints( - source in "[a-z][a-z0-9_]{0,49}", - event_type in "[a-z][a-z0-9_.]{0,99}", - host in "[a-zA-Z0-9_.-]{1,255}", - payload in event_payloads() - ) { - // Property: Events with valid field constraints should be constructible - let mut event = RawEvent::test_event( - EventSource::new(source.clone()), - EventType::new(event_type.clone()), - payload, - ); - event.host = HostName::new(host.clone()); - event.id = Some(Id::from_ulid(Ulid::new())); + fn test_event_uniqueness_properties( + events in proptest::collection::vec(arbitrary_event(), 2..100) + ) -> color_eyre::eyre::Result<()> { + let mut ids: Vec = events + .iter() + .filter_map(|e| e.id.clone().map(|id| id.into())) + .collect(); + + let unique_ids: std::collections::HashSet<_> = ids.iter().cloned().collect(); + prop_assert_eq!(ids.len(), unique_ids.len(), + "All event IDs should be unique"); + + ids.sort(); + for window in ids.windows(2) { + prop_assert!(window[0] <= window[1], "IDs should maintain sort order"); + } + Ok(()) + } - prop_assert!(!event.source.is_empty()); - prop_assert!(!event.event_type.is_empty()); - prop_assert!(event.source.len() <= 50); - prop_assert!(event.event_type.len() <= 100); - prop_assert!(event.host.len() <= 255); + fn test_event_metadata_fields( + event in metadata_rich_events() + ) -> color_eyre::eyre::Result<()> { + if let Value::Object(ref map) = event.payload { + if let Some(metadata) = map.get("_metadata") { + prop_assert!(metadata.is_object(), "Metadata should be an object"); + } } + Ok(()) } - Ok(()) -} -#[sinex_test] -fn test_payload_size_validation() -> color_eyre::eyre::Result<()> { - proptest::proptest! { - fn property_payload_size_validation( - size_kb in 1usize..=1000usize // Reduced size for faster tests - ) { - // Property: Events should handle various payload sizes gracefully - let large_data = "x".repeat(size_kb * 1024); - let payload = json!({ - "data": large_data, - "size_kb": size_kb - }); + fn test_boundary_condition_handling( + event in boundary_condition_events() + ) -> color_eyre::eyre::Result<()> { + prop_assert!(!event.source.is_empty(), "Source should not be empty"); + prop_assert!(!event.event_type.is_empty(), "Event type should not be empty"); + if let Some(id) = event.id { + prop_assert_ne!(Into::::into(id), Ulid::nil(), "ID should not be nil"); + } - let mut event = RawEvent::test_event( - EventSource::new("test"), - EventType::new("payload.size.test"), - payload, - ); - event.id = Some(Id::from_ulid(Ulid::new())); + serde_json::to_string(&event.payload) + .expect("Boundary payload should be serializable"); + Ok(()) + } - // Check that large payloads are handled - let serialized = serde_json::to_string(&event); - prop_assert!(serialized.is_ok(), "Should serialize large payload"); + fn test_validation_preserves_error_hierarchy( + event in arbitrary_event() + ) -> color_eyre::eyre::Result<()> { + let result = validate_event(&event); - // Verify size is roughly what we expect (with JSON overhead) - if let Ok(json_str) = serialized { - prop_assert!(json_str.len() > size_kb * 1024, "Serialized size should exceed payload size"); + if let Err(error) = result { + let error_string = error.to_string(); + prop_assert!(!error_string.is_empty(), "Error message should not be empty"); + if event.source.is_empty() { + prop_assert!(error_string.contains("source")); + } + if event.event_type.is_empty() { + prop_assert!(error_string.contains("event_type") || error_string.contains("type")); } } + Ok(()) } - Ok(()) } -#[sinex_test] -fn test_event_timestamp_consistency() -> color_eyre::eyre::Result<()> { - proptest::proptest! { - fn property_event_timestamp_consistency( - event in arbitrary_event() - ) { - // Property: ULID timestamp (ingest) should be >= ts_orig when both exist - if let Some(id) = event.id { - let ingest_ts = id.timestamp(); - let ts_orig = event.ts_orig.unwrap_or(ingest_ts); - prop_assert!(ingest_ts >= ts_orig, - "ULID timestamp should not be before origin time"); - } +sinex_proptest! { + fn property_payload_size_validation( + size_kb in 1usize..=1000usize // Reduced size for faster tests + ) -> color_eyre::eyre::Result<()> { + // Property: Events should handle various payload sizes gracefully + let large_data = "x".repeat(size_kb * 1024); + let payload = json!({ + "data": large_data, + "size_kb": size_kb + }); - // If ts_orig exists, it should be reasonable - if let Some(ts_orig) = event.ts_orig { - let now = chrono::Utc::now(); - let diff = (now - ts_orig).num_days().abs(); - prop_assert!(diff < 365 * 100, "Timestamp should be within 100 years of now"); - } + let mut event = RawEvent::test_event( + EventSource::new("test"), + EventType::new("payload.size.test"), + payload, + ); + event.id = Some(Id::from_ulid(Ulid::new())); + + // Check that large payloads are handled + let serialized = serde_json::to_string(&event); + prop_assert!(serialized.is_ok(), "Should serialize large payload"); + + // Verify size is roughly what we expect (with JSON overhead) + if let Ok(json_str) = serialized { + prop_assert!( + json_str.len() > size_kb * 1024, + "Serialized size should exceed payload size" + ); } + Ok(()) } - Ok(()) -} -#[sinex_test] -fn test_event_uniqueness_properties() -> color_eyre::eyre::Result<()> { - proptest::proptest! { - fn property_event_uniqueness( - events in proptest::collection::vec(arbitrary_event(), 2..100) - ) { - // Property: Events should have unique IDs and maintain ordering - let mut ids: Vec = events.iter().filter_map(|e| e.id.clone().map(|id| id.into())).collect(); - - // Check uniqueness (though IDs are generated and should be unique) - let unique_ids: std::collections::HashSet<_> = ids.iter().cloned().collect(); - prop_assert_eq!(ids.len(), unique_ids.len(), "All event IDs should be unique"); - - // Check that sorting by ID gives consistent order - ids.sort(); - for window in ids.windows(2) { - prop_assert!(window[0] <= window[1], "IDs should maintain sort order"); - } + fn property_event_timestamp_consistency( + event in arbitrary_event() + ) -> color_eyre::eyre::Result<()> { + // Property: ULID timestamp (ingest) should be >= ts_orig when both exist + if let Some(id) = event.id { + let ingest_ts = id.timestamp(); + let ts_orig = event.ts_orig.unwrap_or(ingest_ts); + prop_assert!( + ingest_ts >= ts_orig, + "ULID timestamp should not be before origin time" + ); + } + + // If ts_orig exists, it should be reasonable + if let Some(ts_orig) = event.ts_orig { + let now = chrono::Utc::now(); + let diff = (now - ts_orig).num_days().abs(); + prop_assert!(diff < 365 * 100, "Timestamp should be within 100 years of now"); } + Ok(()) } - Ok(()) -} -#[sinex_test] -fn test_source_event_id_validation() -> color_eyre::eyre::Result<()> { - proptest::proptest! { - fn property_source_event_id_validation( - parent_events in proptest::collection::vec(Just(()).prop_map(|_| Ulid::new()), 0..10), - _event in arbitrary_event() - ) { - // Property: Source event IDs should be valid ULIDs - // Note: source_event_ids field is not available in the current Event type - // This property test validates the ULID values themselves - for id in parent_events { - // ULIDs don't have version numbers like UUIDs, but should not be nil - prop_assert!(!id.is_nil(), "ULID should not be nil"); - } + fn property_event_uniqueness( + events in proptest::collection::vec(arbitrary_event(), 2..100) + ) -> color_eyre::eyre::Result<()> { + // Property: Events should have unique IDs and maintain ordering + let mut ids: Vec = events + .iter() + .filter_map(|e| e.id.clone().map(|id| id.into())) + .collect(); + + // Check uniqueness (though IDs are generated and should be unique) + let unique_ids: std::collections::HashSet<_> = ids.iter().cloned().collect(); + prop_assert_eq!(ids.len(), unique_ids.len(), "All event IDs should be unique"); + + // Check that sorting by ID gives consistent order + ids.sort(); + for window in ids.windows(2) { + prop_assert!(window[0] <= window[1], "IDs should maintain sort order"); } + Ok(()) } - Ok(()) -} -#[sinex_test] -fn test_json_schema_compatibility() -> color_eyre::eyre::Result<()> { - proptest::proptest! { - fn property_json_schema_compatibility( - event in arbitrary_event() - ) { - // Property: Event payloads should be valid JSON that can be schema-validated - let payload_str = serde_json::to_string(&event.payload); - prop_assert!(payload_str.is_ok(), "Payload should serialize to JSON"); - - if let Ok(json_str) = payload_str { - let parsed: std::result::Result = serde_json::from_str(&json_str); - prop_assert!(parsed.is_ok(), "Payload should round-trip through JSON"); - } + fn property_source_event_id_validation( + parent_events in proptest::collection::vec(Just(()).prop_map(|_| Ulid::new()), 0..10), + _event in arbitrary_event() + ) -> color_eyre::eyre::Result<()> { + // Property: Source event IDs should be valid ULIDs + // Note: source_event_ids field is not available in the current Event type + // This property test validates the ULID values themselves + for id in parent_events { + // ULIDs don't have version numbers like UUIDs, but should not be nil + prop_assert!(!id.is_nil(), "ULID should not be nil"); } + Ok(()) } - Ok(()) -} -#[sinex_test] -fn test_event_metadata_fields() -> color_eyre::eyre::Result<()> { - proptest::proptest! { - fn property_event_metadata_fields( - event in metadata_rich_events() - ) { - // Property: Metadata-rich events should have expected optional fields - // Note: source_material fields are not available in Event, focusing on payload metadata - - // Check payload has metadata if it's an object - if let Value::Object(ref map) = event.payload { - if let Some(metadata) = map.get("_metadata") { - prop_assert!(metadata.is_object(), "Metadata should be an object"); - } - } + fn property_json_schema_compatibility( + event in arbitrary_event() + ) -> color_eyre::eyre::Result<()> { + // Property: Event payloads should be valid JSON that can be schema-validated + let payload_str = serde_json::to_string(&event.payload); + prop_assert!(payload_str.is_ok(), "Payload should serialize to JSON"); + + if let Ok(json_str) = payload_str { + let parsed: std::result::Result = serde_json::from_str(&json_str); + prop_assert!(parsed.is_ok(), "Payload should round-trip through JSON"); } + Ok(()) } - Ok(()) -} -#[sinex_test] -fn test_boundary_condition_handling() -> color_eyre::eyre::Result<()> { - proptest::proptest! { - fn property_boundary_condition_handling( - event in boundary_condition_events() - ) { - // Property: Boundary condition events should be processable - // Even boundary cases should have valid structure - prop_assert!(!event.source.is_empty(), "Source should not be empty"); - prop_assert!(!event.event_type.is_empty(), "Event type should not be empty"); - if let Some(id) = event.id { - prop_assert_ne!(Into::::into(id), Ulid::nil(), "ID should not be nil"); + fn property_event_metadata_fields( + event in metadata_rich_events() + ) -> color_eyre::eyre::Result<()> { + // Property: Metadata-rich events should have expected optional fields + // Note: source_material fields are not available in Event, focusing on payload metadata + + // Check payload has metadata if it's an object + if let Value::Object(ref map) = event.payload { + if let Some(metadata) = map.get("_metadata") { + prop_assert!(metadata.is_object(), "Metadata should be an object"); } + } + Ok(()) + } - // Payload should be valid JSON - let _ = serde_json::to_string(&event.payload) - .expect("Boundary payload should be serializable"); + fn property_boundary_condition_handling( + event in boundary_condition_events() + ) -> color_eyre::eyre::Result<()> { + // Property: Boundary condition events should be processable + // Even boundary cases should have valid structure + prop_assert!(!event.source.is_empty(), "Source should not be empty"); + prop_assert!(!event.event_type.is_empty(), "Event type should not be empty"); + if let Some(id) = event.id { + prop_assert_ne!(Into::::into(id), Ulid::nil(), "ID should not be nil"); } + + // Payload should be valid JSON + let _ = serde_json::to_string(&event.payload) + .expect("Boundary payload should be serializable"); + Ok(()) } - Ok(()) } // ============================================================================= @@ -431,37 +496,39 @@ mod concurrent_tests { use super::*; use std::sync::Arc; - #[sinex_test] - fn test_concurrent_event_ordering() { - proptest::proptest! { - fn property_concurrent_event_ordering( - events in concurrent_operation_events() - ) { - // Property: Concurrent events should maintain per-worker ordering - let mut by_worker: std::collections::HashMap> = - std::collections::HashMap::new(); - - for event in events { - if let Value::Object(ref map) = event.payload { - if let (Some(Value::Number(worker_id)), Some(Value::Number(op_id))) = - (map.get("worker_id"), map.get("operation_id")) { - let worker = worker_id.as_u64().unwrap() as usize; - let op = op_id.as_u64().unwrap(); - by_worker.entry(worker).or_default().push(op); - } + use sinex_test_utils::sinex_proptest; + + sinex_proptest! { + fn property_concurrent_event_ordering( + events in concurrent_operation_events() + ) -> color_eyre::eyre::Result<()> { + // Property: Concurrent events should maintain per-worker ordering + let mut by_worker: std::collections::HashMap> = + std::collections::HashMap::new(); + + for event in events { + if let Value::Object(ref map) = event.payload { + if let (Some(Value::Number(worker_id)), Some(Value::Number(op_id))) = + (map.get("worker_id"), map.get("operation_id")) + { + let worker = worker_id.as_u64().unwrap() as usize; + let op = op_id.as_u64().unwrap(); + by_worker.entry(worker).or_default().push(op); } } + } - // Each worker's operations should be in order - for (_, ops) in by_worker { - for window in ops.windows(2) { - prop_assert!(window[0] <= window[1], - "Worker operations should be ordered"); - } + // Each worker's operations should be in order + for (_, ops) in by_worker { + for window in ops.windows(2) { + prop_assert!( + window[0] <= window[1], + "Worker operations should be ordered" + ); } } + Ok(()) } - Ok(()) } } @@ -474,98 +541,99 @@ mod performance_tests { use super::*; use std::time::Instant; - #[sinex_test] - fn test_event_creation_performance() { - proptest::proptest! { - fn property_event_creation_performance( - events in performance_characteristic_events() - ) { - // Property: Event creation should complete in reasonable time - let start = Instant::now(); + use sinex_test_utils::sinex_proptest; - let serialized = serde_json::to_string(&events); + sinex_proptest! { + fn property_event_creation_performance( + events in performance_characteristic_events() + ) -> color_eyre::eyre::Result<()> { + // Property: Event creation should complete in reasonable time + let start = Instant::now(); - let elapsed = start.elapsed(); + let serialized = serde_json::to_string(&events); - prop_assert!(serialized.is_ok(), "Should serialize performance test event"); - prop_assert!(elapsed.as_millis() < 1000, // Increased from 100ms to 1s for large batches - "Serialization should complete within 1000ms, took {}ms", - elapsed.as_millis()); - } + let elapsed = start.elapsed(); + + prop_assert!( + serialized.is_ok(), + "Should serialize performance test event" + ); + prop_assert!( + elapsed.as_millis() < 1000, // Increased from 100ms to 1s for large batches + "Serialization should complete within 1000ms, took {}ms", + elapsed.as_millis() + ); + Ok(()) } - Ok(()) - } - #[sinex_test] - fn test_validation_errors_are_deterministic() { - proptest::proptest! { - fn property_validation_errors_deterministic( - source in "[a-z]*", // May be empty - event_type in "[a-z]*", // May be empty - payload in event_payloads() - ) { - // Property: Same invalid input should always produce same error - if source.is_empty() || event_type.is_empty() { - let mut event1 = RawEvent::test_event( - EventSource::new(source.clone()), - EventType::new(event_type.clone()), - payload.clone(), - ); - event1.id = Some(Id::from_ulid(Ulid::new())); + fn property_validation_errors_deterministic( + source in "[a-z]*", // May be empty + event_type in "[a-z]*", // May be empty + payload in event_payloads() + ) -> color_eyre::eyre::Result<()> { + // Property: Same invalid input should always produce same error + if source.is_empty() || event_type.is_empty() { + let mut event1 = RawEvent::test_event( + EventSource::new(source.clone()), + EventType::new(event_type.clone()), + payload.clone(), + ); + event1.id = Some(Id::from_ulid(Ulid::new())); + + let mut event2 = RawEvent::test_event( + EventSource::new(source), + EventType::new(event_type), + payload, + ); + event2.id = Some(Id::from_ulid(Ulid::new())); + + let result1 = validate_event(&event1); + let result2 = validate_event(&event2); + + // Both should fail with similar errors + prop_assert!(result1.is_err() && result2.is_err()); + + // Error messages should be consistent + if let (Err(e1), Err(e2)) = (result1, result2) { + let msg1 = e1.to_string(); + let msg2 = e2.to_string(); + prop_assert_eq!(msg1, msg2, "Validation errors should be deterministic"); + } + } + Ok(()) + } - let mut event2 = RawEvent::test_event( - EventSource::new(source), - EventType::new(event_type), - payload, - ); - event2.id = Some(Id::from_ulid(Ulid::new())); + fn property_validation_error_hierarchy( + event in arbitrary_event() + ) -> color_eyre::eyre::Result<()> { + // Property: Validation errors should preserve proper error hierarchy + let result = validate_event(&event); - let result1 = validate_event(&event1); - let result2 = validate_event(&event2); + if let Err(error) = result { + let error_string = error.to_string(); - // Both should fail with similar errors - prop_assert!(result1.is_err() && result2.is_err()); + // Error should contain contextual information + prop_assert!(!error_string.is_empty(), "Error message should not be empty"); - // Error messages should be consistent - if let (Err(e1), Err(e2)) = (result1, result2) { - let msg1 = e1.to_string(); - let msg2 = e2.to_string(); - prop_assert_eq!(msg1, msg2, "Validation errors should be deterministic"); - } + // Error should be structured (contain field information if validation failed) + if event.source.is_empty() { + prop_assert!( + error_string.contains("source"), + "Empty source error should mention 'source': {}", + error_string + ); } - } - } - Ok(()) - } - - #[sinex_test] - fn test_validation_preserves_error_hierarchy() { - proptest::proptest! { - fn property_validation_error_hierarchy( - event in arbitrary_event() - ) { - // Property: Validation errors should preserve proper error hierarchy - let result = validate_event(&event); - - if let Err(error) = result { - let error_string = error.to_string(); - - // Error should contain contextual information - prop_assert!(!error_string.is_empty(), "Error message should not be empty"); - - // Error should be structured (contain field information if validation failed) - if event.source.is_empty() { - prop_assert!(error_string.contains("source"), - "Empty source error should mention 'source': {}", error_string); - } - if event.event_type.is_empty() { - prop_assert!(error_string.contains("event_type") || error_string.contains("type"), - "Empty event_type error should mention type: {}", error_string); - } + if event.event_type.is_empty() { + prop_assert!( + error_string.contains("event_type") + || error_string.contains("type"), + "Empty event_type error should mention type: {}", + error_string + ); } } + Ok(()) } - Ok(()) } } diff --git a/crate/lib/sinex-core/tests/property/path_sanitization_property_test.rs b/crate/lib/sinex-core/tests/property/path_sanitization_property_test.rs index edea0c5f5a..f72ce2a4e2 100644 --- a/crate/lib/sinex-core/tests/property/path_sanitization_property_test.rs +++ b/crate/lib/sinex-core/tests/property/path_sanitization_property_test.rs @@ -89,257 +89,235 @@ fn arb_edge_case_path() -> impl Strategy { ] } -#[sinex_test(proptest)] -fn test_validate_path_neutralizes_traversal() -> TestResult { - proptest! { - #![proptest_config(ProptestConfig::with_cases(1000))] - - fn property_validate_path_neutralizes_traversal( - malicious_path in arb_malicious_path() - ) { - // Property: Malicious paths should either be rejected or neutralized - match validate_path(&malicious_path) { - Ok(validated) => { - // If accepted, should not contain dangerous sequences - let validated_str = validated.as_str(); - - // Should not have effective parent directory traversal - prop_assert!( - !path_escapes_root(&validated), - "Validated path should not escape root: {} -> {}", - malicious_path, validated_str - ); - - // Should not contain null bytes - prop_assert!( - !validated_str.contains('\0'), - "Validated path should not contain null bytes: {}", - validated_str - ); - } - Err(_) => { - // Rejection is acceptable for malicious paths - } - } +#[sinex_prop(cases = 1000)] +fn test_validate_path_neutralizes_traversal( + #[strategy(arb_malicious_path())] malicious_path: String, +) -> TestResult<()> { + match validate_path(&malicious_path) { + Ok(validated) => { + let validated_str = validated.as_str(); + prop_assert!( + !path_escapes_root(&validated), + "Validated path should not escape root: {} -> {}", + malicious_path, + validated_str + ); + prop_assert!( + !validated_str.contains('\0'), + "Validated path should not contain null bytes: {}", + validated_str + ); + } + Err(_) => { + // Rejection is acceptable for malicious paths } } Ok(()) } -#[sinex_test(proptest)] -fn test_validate_path_preserves_legitimate_paths() -> TestResult { - proptest! { - fn property_validate_path_preserves_legitimate_paths( - legitimate_path in arb_file_path() - ) { - // Property: Legitimate paths should remain functionally equivalent - if !legitimate_path.contains("..") && !legitimate_path.contains('\0') { - match validate_path(&legitimate_path) { - Ok(validated) => { - // Should preserve essential path structure - if let Some(filename) = Path::new(&legitimate_path).file_name() { - if let Some(validated_filename) = validated.file_name() { - prop_assert_eq!( - filename.to_string_lossy(), - validated_filename, - "Filename should be preserved: {} -> {}", - legitimate_path, validated.as_str() - ); - } - } - } - Err(e) => { - // Some complex legitimate paths might be rejected - that's acceptable - // for security, but the error should be reasonable - prop_assert!( - matches!(e, ValidationError::Path(_)), - "Should fail with path error for legitimate path: {} -> {:?}", - legitimate_path, e - ); - } +#[sinex_prop] +fn test_validate_path_preserves_legitimate_paths( + #[strategy(arb_file_path())] legitimate_path: String, +) -> TestResult<()> { + if legitimate_path.contains("..") || legitimate_path.contains('\0') { + return Ok(()); + } + + match validate_path(&legitimate_path) { + Ok(validated) => { + if let Some(filename) = Path::new(&legitimate_path).file_name() { + if let Some(validated_filename) = validated.file_name() { + prop_assert_eq!( + filename.to_string_lossy(), + validated_filename, + "Filename should be preserved: {} -> {}", + legitimate_path, + validated.as_str() + ); } } } + Err(e) => { + prop_assert!( + matches!(e, ValidationError::Path(_)), + "Should fail with path error for legitimate path: {} -> {:?}", + legitimate_path, + e + ); + } } Ok(()) } -#[sinex_test(proptest)] -fn test_event_sanitization_is_idempotent() -> TestResult { - proptest! { - fn property_event_sanitization_is_idempotent( - path in prop_oneof![arb_file_path(), arb_malicious_path(), arb_edge_case_path()] - ) { - // Property: Sanitizing the same event twice should yield the same result - let mut event1 = Event::test_event( - EventSource::new(path.clone()), - EventType::new("test.event"), - json!({"test": "data"}), - ); - event1.id = Some(Id::from_ulid(Ulid::new())); - - let mut event2 = event1.clone(); - - let _was_modified1 = EventSanitizer::sanitize_event(&mut event1).unwrap_or(false); - let _was_modified2 = EventSanitizer::sanitize_event(&mut event2).unwrap_or(false); - - // After first sanitization, second should not modify further - let mut event1_copy = event1.clone(); - let was_modified_again = EventSanitizer::sanitize_event(&mut event1_copy).unwrap_or(false); +#[sinex_prop] +fn test_event_sanitization_is_idempotent( + #[strategy(prop_oneof![ + arb_file_path(), + arb_malicious_path(), + arb_edge_case_path() + ])] + path: String, +) -> TestResult<()> { + let mut event1 = Event::test_event( + EventSource::new(path.clone()), + EventType::new("test.event"), + json!({"test": "data"}), + ); + event1.id = Some(Id::from_ulid(Ulid::new())); + + let mut event2 = event1.clone(); + + let _ = EventSanitizer::sanitize_event(&mut event1).unwrap_or(false); + let _ = EventSanitizer::sanitize_event(&mut event2).unwrap_or(false); + + let mut event1_copy = event1.clone(); + let was_modified_again = EventSanitizer::sanitize_event(&mut event1_copy).unwrap_or(false); + + prop_assert!( + !was_modified_again, + "Second sanitization should not modify already-clean event: {}", + path + ); + prop_assert_eq!( + event1.source, + event1_copy.source, + "Source should be stable after sanitization: {}", + path + ); + Ok(()) +} - prop_assert!(!was_modified_again, "Second sanitization should not modify already-clean event: {}", path); - prop_assert_eq!(event1.source, event1_copy.source, "Source should be stable after sanitization: {}", path); - } +#[sinex_prop] +fn test_path_sanitization_removes_dangerous_sequences( + #[strategy(arb_malicious_path())] malicious_path: String, +) -> TestResult<()> { + let mut event = Event::test_event( + EventSource::new(malicious_path.clone()), + EventType::new("security.test"), + json!({"path": malicious_path.clone()}), + ); + event.id = Some(Id::from_ulid(Ulid::new())); + + let _ = EventSanitizer::sanitize_event(&mut event).unwrap_or(false); + + prop_assert!( + !event.source.contains(".."), + "Sanitized event source should not contain '..': {} -> {}", + malicious_path, + event.source.as_str() + ); + prop_assert!( + !event.source.contains('\0'), + "Sanitized event source should not contain null bytes: {} -> {}", + malicious_path, + event.source.as_str() + ); + + if let Some(path_val) = event.payload.get("path").and_then(|v| v.as_str()) { + prop_assert!( + !path_val.contains(".."), + "Sanitized payload path should not contain '..': {} -> {}", + malicious_path, + path_val + ); } Ok(()) } -#[sinex_test(proptest)] -fn test_path_sanitization_removes_dangerous_sequences() -> TestResult { - proptest! { - fn property_path_sanitization_removes_dangerous_sequences( - malicious_path in arb_malicious_path() - ) { - // Property: Sanitized paths should not contain known dangerous patterns - let mut event = Event::test_event( - EventSource::new(malicious_path.clone()), - EventType::new("security.test"), - json!({"path": malicious_path.clone()}), - ); - event.id = Some(Id::from_ulid(Ulid::new())); - - let _was_modified = EventSanitizer::sanitize_event(&mut event).unwrap_or(false); - - // Should not contain effective ".." sequences in source +#[sinex_prop] +fn test_path_validation_handles_unicode_safely( + #[strategy(proptest::string::string_regex("[\\u{0}-\\u{FFFF}]{1,50}").unwrap())] + unicode_path: String, +) -> TestResult<()> { + let result = validate_path(&unicode_path); + match result { + Ok(validated) => { prop_assert!( - !event.source.contains(".."), - "Sanitized event source should not contain '..': {} -> {}", - malicious_path, event.source.as_str() + validated.as_str().len() <= unicode_path.len() + 100, + "Unicode normalization should not explode size" ); - - // Should not contain null bytes in source + } + Err(e) => { prop_assert!( - !event.source.contains('\0'), - "Sanitized event source should not contain null bytes: {} -> {}", - malicious_path, event.source.as_str() + matches!(e, ValidationError::Path(_)), + "Unicode path rejection should report a path error: {:?}", + e ); - - // Check payload for path field - if let Some(path_val) = event.payload.get("path").and_then(|v| v.as_str()) { - prop_assert!( - !path_val.contains(".."), - "Sanitized payload path should not contain '..': {} -> {}", - malicious_path, path_val - ); - } } } Ok(()) } -#[sinex_test(proptest)] -fn test_path_validation_handles_unicode_safely() -> TestResult { - proptest! { - fn property_path_validation_handles_unicode_safely( - unicode_path in "[\\u{0}-\\u{FFFF}]{1,50}" - ) { - // Property: Unicode paths should be handled without crashes - let result = validate_path(&unicode_path); - - // Should not panic - match result { - Ok(validated) => { - // Valid unicode should be preserved or safely normalized - prop_assert!(validated.as_str().len() <= unicode_path.len() + 100); // Allow for normalization - } - Err(e) => { - // Rejection is fine for problematic unicode but should be a path error - prop_assert!( - matches!(e, ValidationError::Path(_)), - "Unicode path rejection should report a path error: {:?}", - e - ); - } - } - } - } +#[sinex_prop] +fn test_safe_content_preservation_in_events( + #[strategy(proptest::string::string_regex("[a-zA-Z0-9_. /-]{1,100}").unwrap())] + safe_string: String, +) -> TestResult<()> { + let mut event = Event::test_event( + EventSource::new(safe_string.clone()), + EventType::new("safe.test"), + json!({"content": safe_string.clone()}), + ); + event.id = Some(Id::from_ulid(Ulid::new())); + + let original_alphanum: String = safe_string + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect(); + + let _ = EventSanitizer::sanitize_event(&mut event).unwrap_or(false); + + let sanitized_source_alphanum: String = event + .source + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect(); + + prop_assert_eq!( + original_alphanum, + sanitized_source_alphanum, + "Alphanumeric characters should be preserved in source: '{}' -> '{}'", + safe_string, + event.source.as_str() + ); Ok(()) } -#[sinex_test(proptest)] -fn test_safe_content_preservation_in_events() -> TestResult { - proptest! { - fn property_safe_content_preservation_in_events( - safe_string in "[a-zA-Z0-9_. /-]{1,100}" - ) { - // Property: Safe ASCII content should be mostly preserved in events - let mut event = Event::test_event( - EventSource::new(safe_string.clone()), - EventType::new("safe.test"), - json!({"content": safe_string.clone()}), - ); - event.id = Some(Id::from_ulid(Ulid::new())); - - let original_alphanum: String = safe_string.chars() - .filter(|c| c.is_ascii_alphanumeric()).collect(); - - let _was_modified = EventSanitizer::sanitize_event(&mut event).unwrap_or(false); - - // Should preserve alphanumeric characters in source - let sanitized_source_alphanum: String = event.source.chars() - .filter(|c| c.is_ascii_alphanumeric()).collect(); - - prop_assert_eq!( - original_alphanum, sanitized_source_alphanum, - "Alphanumeric characters should be preserved in source: '{}' -> '{}'", - safe_string, event.source.as_str() +#[sinex_prop] +fn test_path_length_limits_enforced( + #[strategy(1usize..10000usize)] path_length: usize, +) -> TestResult<()> { + let long_path = "a".repeat(path_length); + let result = validate_path(&long_path); + + if path_length > 4096 { + prop_assert!( + result.is_err(), + "Path of length {} should be rejected", + path_length + ); + if let Err(e) = result { + prop_assert!( + matches!(e, ValidationError::Path(_)), + "Should fail with path validation error for length {}", + path_length ); } - } - Ok(()) -} - -#[sinex_test(proptest)] -fn test_path_length_limits_enforced() -> TestResult { - proptest! { - fn property_path_length_limits_enforced( - path_length in 1usize..10000usize - ) { - // Property: Very long paths should be rejected - let long_path = "a".repeat(path_length); - let result = validate_path(&long_path); - - if path_length > 4096 { - // Should be rejected for being too long + } else { + match result { + Ok(validated) => { prop_assert!( - result.is_err(), - "Path of length {} should be rejected", path_length + validated.as_str().len() <= 4096, + "Validated path should respect length limits" + ); + } + Err(e) => { + prop_assert!( + matches!(e, ValidationError::Path(_)), + "Unexpected error type for length {}: {:?}", + path_length, + e ); - if let Err(e) = result { - prop_assert!( - matches!(e, ValidationError::Path(_)), - "Should fail with path validation error for length {}", path_length - ); - } - } else { - // Should be acceptable if within limits - match result { - Ok(validated) => { - prop_assert!( - validated.as_str().len() <= 4096, - "Validated path should respect length limits" - ); - } - Err(e) => { - // May still fail for other reasons - ensure it's a path validation error - prop_assert!( - matches!(e, ValidationError::Path(_)), - "Unexpected error type for length {}: {:?}", - path_length, - e - ); - } - } } } } diff --git a/crate/lib/sinex-core/tests/property/schema_property_test.rs b/crate/lib/sinex-core/tests/property/schema_property_test.rs index 9185ce84d7..d653bce2da 100644 --- a/crate/lib/sinex-core/tests/property/schema_property_test.rs +++ b/crate/lib/sinex-core/tests/property/schema_property_test.rs @@ -13,6 +13,7 @@ use sinex_core::{ DbPoolExt, }; use sinex_test_utils::{prelude::*, TestResult}; +use std::collections::HashMap; // ============================================================================= // JSON Schema Validation Properties @@ -150,53 +151,43 @@ fn arb_event_source_type() -> impl Strategy { ) } -#[sinex_test(proptest)] -fn test_json_validation_normal_payloads() -> TestResult { - proptest!(|(payload in arb_event_payload())| { - // Validation should not panic and should return a consistent result - let json_str = payload.to_string(); - let result = validate_json(&json_str); - - // Any result is acceptable - just ensure it doesn't crash - drop(result); - }); +#[sinex_prop] +fn test_json_validation_normal_payloads( + #[strategy(arb_event_payload())] payload: Value, +) -> TestResult<()> { + let json_str = payload.to_string(); + let result = validate_json(&json_str); + drop(result); Ok(()) } -#[sinex_test(proptest)] -fn test_json_validation_security_payloads() -> TestResult { - proptest!(|(payload in arb_problematic_payload())| { - // Validation should handle problematic payloads safely - let json_str = payload.to_string(); - let result = validate_json(&json_str); - - // Should not panic or cause memory issues - drop(result); - }); +#[sinex_prop] +fn test_json_validation_security_payloads( + #[strategy(arb_problematic_payload())] payload: Value, +) -> TestResult<()> { + let json_str = payload.to_string(); + let result = validate_json(&json_str); + drop(result); Ok(()) } -#[sinex_test(proptest)] -fn test_json_validation_consistency() -> TestResult { - proptest!(|(payload in arb_event_payload())| { - // Validation should be deterministic - same payload should always get same result - let json_str = payload.to_string(); - let result1 = validate_json(&json_str); - let result2 = validate_json(&json_str); - - match (&result1, &result2) { - (Ok(_), Ok(_)) => { - // Both passed - }, - (Err(e1), Err(e2)) => { - // Both failed - error types should be the same - prop_assert_eq!(std::mem::discriminant(e1), std::mem::discriminant(e2)); - }, - _ => { - prop_assert!(false, "Validation was not consistent"); - } +#[sinex_prop] +fn test_json_validation_consistency( + #[strategy(arb_event_payload())] payload: Value, +) -> TestResult<()> { + let json_str = payload.to_string(); + let result1 = validate_json(&json_str); + let result2 = validate_json(&json_str); + + match (&result1, &result2) { + (Ok(_), Ok(_)) => {} + (Err(e1), Err(e2)) => { + prop_assert_eq!(std::mem::discriminant(e1), std::mem::discriminant(e2)); } - }); + _ => { + prop_assert!(false, "Validation was not consistent"); + } + } Ok(()) } @@ -205,11 +196,11 @@ fn test_json_validation_consistency() -> TestResult { // ============================================================================= /// Test schema evolution and backward compatibility -#[sinex_test(proptest)] -fn test_schema_evolution_properties() -> TestResult { - proptest!(|( - base_payload in arb_event_payload(), - additional_fields in prop::collection::hash_map( +#[sinex_prop] +fn test_schema_evolution_properties( + #[strategy(arb_event_payload())] base_payload: Value, + #[strategy( + prop::collection::hash_map( "[a-zA-Z][a-zA-Z0-9_]{0,20}", prop_oneof![ any::().prop_map(|s| json!(s)), @@ -218,51 +209,41 @@ fn test_schema_evolution_properties() -> TestResult { ], 0..=5 ) - )| { - // Create evolved payload with additional fields - let mut evolved_payload = base_payload.clone(); - if let Value::Object(ref mut obj) = evolved_payload { - for (key, value) in additional_fields { - obj.insert(key, value); - } - } else { - // If base is not an object, create a new object with the base as a field - let mut new_obj = serde_json::Map::new(); - new_obj.insert("base".to_string(), base_payload.clone()); - for (key, value) in additional_fields { - new_obj.insert(key, value); - } - evolved_payload = Value::Object(new_obj); + )] + additional_fields: HashMap, +) -> TestResult<()> { + let mut evolved_payload = base_payload.clone(); + if let Value::Object(ref mut obj) = evolved_payload { + for (key, value) in additional_fields { + obj.insert(key, value); } + } else { + let mut new_obj = serde_json::Map::new(); + new_obj.insert("base".to_string(), base_payload.clone()); + for (key, value) in additional_fields { + new_obj.insert(key, value); + } + evolved_payload = Value::Object(new_obj); + } - // Validation should handle both versions gracefully - let base_json = base_payload.to_string(); - let evolved_json = evolved_payload.to_string(); - let base_result = validate_json(&base_json); - let evolved_result = validate_json(&evolved_json); - - // Both should either pass or fail with the same error type - // (since schema evolution should not break validation completely) - match (base_result, evolved_result) { - (Ok(_), Ok(_)) => { - // Both passed - ideal scenario - }, - (Err(e1), Err(e2)) => { - // Both failed - error types should be compatible - prop_assert_eq!( - std::mem::discriminant(&e1), - std::mem::discriminant(&e2), - "Schema evolution should not change fundamental validation behavior" - ); - }, - (Ok(_), Err(_)) => { - // Base passed, evolved failed - acceptable if additional fields are invalid - }, - (Err(_), Ok(_)) => { - // Base failed, evolved passed - could happen if additional fields fix validation - } + let base_json = base_payload.to_string(); + let evolved_json = evolved_payload.to_string(); + let base_result = validate_json(&base_json); + let evolved_result = validate_json(&evolved_json); + + match (base_result, evolved_result) { + (Ok(_), Ok(_)) => {} + (Err(e1), Err(e2)) => { + prop_assert_eq!( + std::mem::discriminant(&e1), + std::mem::discriminant(&e2), + "Schema evolution should not change fundamental validation behavior" + ); } - }); + _ => { + // Mixed outcomes are acceptable; evolution may introduce new constraints. + } + } Ok(()) } @@ -271,60 +252,63 @@ fn test_schema_evolution_properties() -> TestResult { // ============================================================================= /// Test validation chain behavior with various inputs -#[sinex_test(proptest)] -fn test_validation_chain_properties() -> TestResult { - proptest!(|( - test_strings in prop::collection::vec(".*", 1..=10) - )| { - for test_string in test_strings.iter() { - // Use basic validation patterns instead of complex chaining - let is_empty = test_string.is_empty(); - let is_too_long = test_string.len() > 1000; - - // Property: Empty strings should be considered invalid - if is_empty { - prop_assert!(test_string.is_empty(), "Empty strings should be empty"); - } +#[sinex_prop] +fn test_validation_chain_properties( + #[strategy( + prop::collection::vec( + proptest::string::string_regex(".*").unwrap(), + 1..=10 + ) + )] + test_strings: Vec, +) -> TestResult<()> { + for test_string in test_strings.iter() { + let is_empty = test_string.is_empty(); + let is_too_long = test_string.len() > 1000; + + if is_empty { + prop_assert!(test_string.is_empty(), "Empty strings should be empty"); + } - // Property: Very long strings should be detectable - if is_too_long { - prop_assert!(test_string.len() > 1000, "Long strings should be long"); - } + if is_too_long { + prop_assert!(test_string.len() > 1000, "Long strings should be long"); + } - // Property: Non-empty, reasonable length strings should be valid - if !is_empty && !is_too_long { - prop_assert!(!test_string.is_empty(), "Valid strings should not be empty"); - prop_assert!(test_string.len() <= 1000, "Valid strings should not be too long"); - } + if !is_empty && !is_too_long { + prop_assert!(!test_string.is_empty(), "Valid strings should not be empty"); + prop_assert!( + test_string.len() <= 1000, + "Valid strings should not be too long" + ); } - }); + } Ok(()) } /// Test validation chain with numeric values -#[sinex_test(proptest)] -fn test_validation_chain_numeric_properties() -> TestResult { - proptest!(|( - test_numbers in prop::collection::vec(any::(), 1..=10) - )| { - for &test_number in test_numbers.iter() { - // Property: Numbers should have predictable range behavior - let in_valid_range = (0..=1000).contains(&test_number); - let too_small = test_number < 0; - let too_large = test_number > 1000; - - // Exactly one should be true - let flags = [in_valid_range, too_small, too_large]; - let true_count = flags.iter().filter(|&&x| x).count(); - prop_assert_eq!(true_count, 1, "Exactly one range condition should be true for {}", test_number); - - // Verify range classifications - if in_valid_range { - prop_assert!(test_number >= 0, "Valid range numbers should be >= 0"); - prop_assert!(test_number <= 1000, "Valid range numbers should be <= 1000"); - } +#[sinex_prop] +fn test_validation_chain_numeric_properties( + #[strategy(prop::collection::vec(any::(), 1..=10))] test_numbers: Vec, +) -> TestResult<()> { + for &test_number in test_numbers.iter() { + let in_valid_range = (0..=1000).contains(&test_number); + let too_small = test_number < 0; + let too_large = test_number > 1000; + + let flags = [in_valid_range, too_small, too_large]; + let true_count = flags.iter().filter(|&&x| x).count(); + prop_assert_eq!( + true_count, + 1, + "Exactly one range condition should be true for {}", + test_number + ); + + if in_valid_range { + prop_assert!(test_number >= 0, "Valid range numbers should be >= 0"); + prop_assert!(test_number <= 1000, "Valid range numbers should be <= 1000"); } - }); + } Ok(()) } @@ -439,48 +423,43 @@ async fn test_json_validation_database_integration( // Performance Properties // ============================================================================= -#[sinex_test] -fn test_validation_performance_properties() -> TestResult { - proptest!(|( - payload_sizes in prop::collection::vec(100usize..=10000, 1..=10), - validation_count in 10usize..=100 - )| { - for &payload_size in &payload_sizes { - // Create payload of specified size - let large_payload = json!({ - "data": "x".repeat(payload_size), - "size": payload_size, - "type": "performance_test" - }); - - // Measure validation time - let start = std::time::Instant::now(); - - let json_str = large_payload.to_string(); - for _ in 0..validation_count { - let _ = validate_json(&json_str); - } +#[sinex_prop] +fn test_validation_performance_properties( + #[strategy(prop::collection::vec(100usize..=10000, 1..=10))] payload_sizes: Vec, + #[strategy(10usize..=100usize)] validation_count: usize, +) -> TestResult<()> { + for &payload_size in &payload_sizes { + let large_payload = json!({ + "data": "x".repeat(payload_size), + "size": payload_size, + "type": "performance_test" + }); + + let start = std::time::Instant::now(); + let json_str = large_payload.to_string(); + for _ in 0..validation_count { + let _ = validate_json(&json_str); + } - let elapsed = start.elapsed(); - let avg_time = elapsed.as_nanos() as f64 / validation_count as f64; + let elapsed = start.elapsed(); + let avg_time = elapsed.as_nanos() as f64 / validation_count as f64; - // Property: Average validation time should be reasonable + prop_assert!( + avg_time < 5_000_000.0, + "Validation too slow: {:.2}ns average for {} byte payload", + avg_time, + payload_size + ); + + if payload_size > 1000 { prop_assert!( - avg_time < 5_000_000.0, // Allow up to 5ms per validation to accommodate CI variance - "Validation too slow: {:.2}ns average for {} byte payload", - avg_time, payload_size + avg_time < (payload_size as f64 * 2000.0), + "Validation scaling poorly: {:.2}ns for {} bytes", + avg_time, + payload_size ); - - // Property: Validation time should not grow exponentially with size - if payload_size > 1000 { - prop_assert!( - avg_time < (payload_size as f64 * 2000.0), // Less than 2ns per byte - "Validation scaling poorly: {:.2}ns for {} bytes", - avg_time, payload_size - ); - } } - }); + } Ok(()) } diff --git a/crate/lib/sinex-core/tests/property/time_range_property_test.rs b/crate/lib/sinex-core/tests/property/time_range_property_test.rs index 1441b349c9..431fc804b9 100644 --- a/crate/lib/sinex-core/tests/property/time_range_property_test.rs +++ b/crate/lib/sinex-core/tests/property/time_range_property_test.rs @@ -1,6 +1,6 @@ use chrono::{DateTime, Duration, Utc}; use proptest::prelude::*; -use sinex_test_utils::{sinex_test, TestResult}; +use sinex_test_utils::{sinex_prop, TestResult}; /// Generate arbitrary time ranges within a useful bound. fn arb_time_range() -> impl Strategy, DateTime)> { @@ -57,48 +57,54 @@ fn partition_range( parts } -#[sinex_test(proptest)] -fn time_range_ordering_invariant() -> TestResult { - proptest!(|(range in arb_time_range())| { - let (start, end) = range; - prop_assert!(start <= end); - prop_assert!((end - start) >= chrono::Duration::zero()); - }); +#[sinex_prop] +fn time_range_ordering_invariant( + #[strategy(arb_time_range())] range: (DateTime, DateTime), +) -> TestResult<()> { + let (start, end) = range; + prop_assert!(start <= end); + prop_assert!((end - start) >= chrono::Duration::zero()); Ok(()) } -#[sinex_test(proptest)] -fn time_range_overlap_symmetry() -> TestResult { - proptest!(|(ranges in proptest::collection::vec(arb_time_range(), 2..=2))| { - let a = ranges[0]; - let b = ranges[1]; - prop_assert_eq!(ranges_overlap(a, b), ranges_overlap(b, a)); - if let Some((start, end)) = intersect_ranges(a, b) { - prop_assert!(ranges_overlap(a, b)); - prop_assert!(start <= end); - } - }); +#[sinex_prop] +fn time_range_overlap_symmetry( + #[strategy(proptest::collection::vec(arb_time_range(), 2..=2))] ranges: Vec<( + DateTime, + DateTime, + )>, +) -> TestResult<()> { + let a = ranges[0]; + let b = ranges[1]; + prop_assert_eq!(ranges_overlap(a, b), ranges_overlap(b, a)); + if let Some((start, end)) = intersect_ranges(a, b) { + prop_assert!(ranges_overlap(a, b)); + prop_assert!(start <= end); + } Ok(()) } -#[sinex_test(proptest)] -fn time_range_partition_covers_interval() -> TestResult { - proptest!(|((range, count) in arb_time_range().prop_flat_map(|r| (Just(r), 1usize..=16)))| { - let parts = partition_range(range, count); - prop_assert_eq!(parts.len(), count); - if let Some(first) = parts.first() { - prop_assert_eq!(first.0, range.0); - } - if let Some(last) = parts.last() { - prop_assert_eq!(last.1, range.1); - } +#[sinex_prop] +fn time_range_partition_covers_interval( + #[strategy( + arb_time_range().prop_flat_map(|r| (Just(r), 1usize..=16)) + )] + range_and_count: ((DateTime, DateTime), usize), +) -> TestResult<()> { + let (range, count) = range_and_count; + let parts = partition_range(range, count); + prop_assert_eq!(parts.len(), count); + if let Some(first) = parts.first() { + prop_assert_eq!(first.0, range.0); + } + if let Some(last) = parts.last() { + prop_assert_eq!(last.1, range.1); + } - // partitions are ordered and non-overlapping - for window in parts.windows(2) { - let a = window[0]; - let b = window[1]; - prop_assert!(a.1 <= b.0); - } - }); + for window in parts.windows(2) { + let a = window[0]; + let b = window[1]; + prop_assert!(a.1 <= b.0); + } Ok(()) } diff --git a/crate/lib/sinex-core/tests/property/ulid_property_test.rs b/crate/lib/sinex-core/tests/property/ulid_property_test.rs index c3275dfb5d..552a8e12e7 100644 --- a/crate/lib/sinex-core/tests/property/ulid_property_test.rs +++ b/crate/lib/sinex-core/tests/property/ulid_property_test.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Duration as ChronoDuration, Utc}; use proptest::prelude::*; use proptest::strategy::ValueTree; use sinex_core::types::Ulid; -use sinex_test_utils::sinex_test; +use sinex_test_utils::sinex_proptest; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Barrier}; use std::thread; @@ -30,27 +30,20 @@ fn spin_for(duration: Duration) { // ULID Generation and Ordering Properties // ============================================================================= -/// Test that ULIDs generated from chronologically ordered timestamps maintain order -#[sinex_test] -fn test_ulid_chronological_ordering() -> color_eyre::eyre::Result<()> { - proptest::proptest!(|( +sinex_proptest! { + fn test_ulid_chronological_ordering( count in 2usize..10, delay_micros in 100u64..1000 - )| { + ) -> color_eyre::eyre::Result<()> { let mut ulids = Vec::new(); - - // Generate ULIDs with micro-delays to ensure monotonic ordering for i in 0..count { if i > 0 { - // Add tiny delay to ensure different timestamps for monotonic generation for _ in 0..delay_micros { std::thread::yield_now(); } } ulids.push(Ulid::new()); } - - // Verify ULIDs maintain chronological order for window in ulids.windows(2) { let (prev, curr) = (&window[0], &window[1]); prop_assert!( @@ -62,27 +55,21 @@ fn test_ulid_chronological_ordering() -> color_eyre::eyre::Result<()> { curr.timestamp() ); } - }); - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn test_ulid_uniqueness_under_rapid_generation() -> color_eyre::eyre::Result<()> { - proptest::proptest!(|(count in 2usize..1000)| { + fn test_ulid_uniqueness_under_rapid_generation( + count in 2usize..1000 + ) -> color_eyre::eyre::Result<()> { let base_time = Utc::now(); let mut ulids = Vec::new(); - - // Generate ULIDs rapidly (simulating high-frequency events) for i in 0..count { let timestamp = base_time + ChronoDuration::milliseconds(i as i64); ulids.push(Ulid::from_datetime(timestamp)); } - - // Verify all ULIDs are unique let mut sorted_ulids = ulids.clone(); sorted_ulids.sort(); sorted_ulids.dedup(); - prop_assert_eq!( ulids.len(), sorted_ulids.len(), @@ -90,28 +77,25 @@ fn test_ulid_uniqueness_under_rapid_generation() -> color_eyre::eyre::Result<()> ulids.len(), sorted_ulids.len() ); - }); - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn test_ulid_timestamp_extraction() -> color_eyre::eyre::Result<()> { - proptest::proptest!(|(timestamp in 1577836800u64..1893456000u64)| { // 2020-2030 range + fn test_ulid_timestamp_extraction( + timestamp in 1577836800u64..1893456000u64 + ) -> color_eyre::eyre::Result<()> { let dt = DateTime::from_timestamp(timestamp as i64, 0).unwrap_or(Utc::now()); let ulid = Ulid::from_datetime(dt); let extracted_timestamp = ulid.timestamp(); - - // ULID timestamp should be within 1ms of original let time_diff = (timestamp * 1000) as i64 - extracted_timestamp.timestamp_millis(); prop_assert!( - time_diff.abs() <= 1000, // 1 second tolerance for edge cases + time_diff.abs() <= 1000, "ULID timestamp extraction inaccurate: original={}, extracted={}, diff={}ms", timestamp * 1000, extracted_timestamp.timestamp_millis(), time_diff ); - }); - Ok(()) + Ok(()) + } } // Note: Event generator tests removed as generators are not available in current test utils @@ -173,94 +157,75 @@ fn generate_ulids_concurrently( all_ulids } -#[sinex_test] -fn test_concurrent_ulid_uniqueness() -> color_eyre::eyre::Result<()> { - proptest::proptest!(|( - (num_threads, ulids_per_thread, max_delay_ms) in arb_concurrent_params() - )| { +sinex_proptest! { + fn test_concurrent_ulid_uniqueness( + params in arb_concurrent_params() + ) -> color_eyre::eyre::Result<()> { + let (num_threads, ulids_per_thread, max_delay_ms) = params; let ulids = generate_ulids_concurrently(num_threads, ulids_per_thread, max_delay_ms); - // All ULIDs should be unique let mut seen = HashSet::new(); for (_, ulid, _) in &ulids { prop_assert!(seen.insert(*ulid), "Found duplicate ULID: {}", ulid); } - // Should have generated expected total count prop_assert_eq!(ulids.len(), num_threads * ulids_per_thread); - }); - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn test_concurrent_ulid_time_ordering() -> color_eyre::eyre::Result<()> { - proptest::proptest!(|( - (num_threads, ulids_per_thread, max_delay_ms) in arb_concurrent_params() - )| { + fn test_concurrent_ulid_time_ordering( + params in arb_concurrent_params() + ) -> color_eyre::eyre::Result<()> { + let (num_threads, ulids_per_thread, max_delay_ms) = params; let ulids = generate_ulids_concurrently(num_threads, ulids_per_thread, max_delay_ms); - // Group ULIDs by their millisecond timestamps let mut timestamp_groups: HashMap> = HashMap::new(); for (_, ulid, _) in &ulids { let ts_ms = ulid.timestamp().timestamp_millis(); timestamp_groups.entry(ts_ms).or_default().push(*ulid); } - // Within each millisecond, ULIDs should be sortable by their full value for (ts_ms, mut group_ulids) in timestamp_groups { if group_ulids.len() > 1 { - let _original = group_ulids.clone(); group_ulids.sort(); - - // ULIDs in the same millisecond should have different random parts - // so sorting them should give a consistent order for window in group_ulids.windows(2) { prop_assert_ne!(window[0], window[1], "ULIDs at timestamp {} should be unique", ts_ms); } } } - }); - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn test_concurrent_ulid_timestamp_correlation() -> color_eyre::eyre::Result<()> { - proptest::proptest!(|( - (num_threads, ulids_per_thread, _) in arb_concurrent_params() - )| { - // Use no delay for this test to minimize timing variance +sinex_proptest! { + fn test_concurrent_ulid_timestamp_correlation( + params in arb_concurrent_params() + ) -> color_eyre::eyre::Result<()> { + let (num_threads, ulids_per_thread, _) = params; let ulids = generate_ulids_concurrently(num_threads, ulids_per_thread, 0); let test_start = Utc::now(); let test_end = Utc::now(); - // All ULID timestamps should be within the test timeframe - for (_, ulid, _generation_instant) in ulids { + for (_, ulid, _) in ulids { let ulid_timestamp = ulid.timestamp(); - - // ULID timestamp should be reasonable (within test window + some tolerance) prop_assert!(ulid_timestamp >= test_start - ChronoDuration::seconds(1)); prop_assert!(ulid_timestamp <= test_end + ChronoDuration::seconds(1)); } - }); - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn test_concurrent_ulid_thread_distribution() -> color_eyre::eyre::Result<()> { - proptest::proptest!(|( - (num_threads, ulids_per_thread, max_delay_ms) in arb_concurrent_params() - )| { + fn test_concurrent_ulid_thread_distribution( + params in arb_concurrent_params() + ) -> color_eyre::eyre::Result<()> { + let (num_threads, ulids_per_thread, max_delay_ms) = params; let ulids = generate_ulids_concurrently(num_threads, ulids_per_thread, max_delay_ms); - // Count ULIDs per thread let mut thread_counts: HashMap = HashMap::new(); for (thread_id, _, _) in &ulids { *thread_counts.entry(*thread_id).or_default() += 1; } - // Each thread should have generated exactly the expected count for thread_id in 0..num_threads { prop_assert_eq!( thread_counts.get(&thread_id).copied().unwrap_or(0), @@ -270,61 +235,47 @@ fn test_concurrent_ulid_thread_distribution() -> color_eyre::eyre::Result<()> { ulids_per_thread ); } - }); - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn test_high_contention_ulid_generation() -> color_eyre::eyre::Result<()> { - proptest::proptest!(|( + fn test_high_contention_ulid_generation( burst_size in 50usize..=200, num_bursts in 2usize..=5 - )| { + ) -> color_eyre::eyre::Result<()> { let mut all_ulids = Vec::new(); for _burst in 0..num_bursts { - // Generate many ULIDs in rapid succession let barrier = Arc::new(Barrier::new(burst_size)); let mut handles = Vec::new(); - for _i in 0..burst_size { + for _ in 0..burst_size { let barrier = Arc::clone(&barrier); - let handle = thread::spawn(move || { + handles.push(thread::spawn(move || { barrier.wait(); - // Generate immediately after barrier Ulid::new() - }); - handles.push(handle); + })); } - // Collect burst results for handle in handles { all_ulids.push(handle.join().unwrap()); } - // Small delay between bursts spin_for(Duration::from_millis(10)); } - // All ULIDs should be unique despite high contention let mut seen = HashSet::new(); for ulid in &all_ulids { prop_assert!(seen.insert(*ulid), "High contention caused duplicate ULID: {}", ulid); } prop_assert_eq!(all_ulids.len(), burst_size * num_bursts); - }); - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn test_ulid_ordering_with_timing_patterns() -> color_eyre::eyre::Result<()> { - proptest::proptest!(|( + fn test_ulid_ordering_with_timing_patterns( pattern_delays in prop::collection::vec(0u64..=50, 5..=20) - )| { + ) -> color_eyre::eyre::Result<()> { let mut ulids_with_delays = Vec::new(); - - // Generate ULIDs with specific delay patterns for delay_ms in pattern_delays { let start_time = Instant::now(); spin_for(Duration::from_millis(delay_ms)); @@ -332,26 +283,38 @@ fn test_ulid_ordering_with_timing_patterns() -> color_eyre::eyre::Result<()> { ulids_with_delays.push((ulid, start_time.elapsed())); } - // ULIDs should be ordered by generation time for window in ulids_with_delays.windows(2) { let (ulid1, delay1) = window[0]; let (ulid2, delay2) = window[1]; - - // If second ULID was generated after first (accounting for delays), - // it should compare greater if delay2 > delay1 { prop_assert!(ulid2 > ulid1, "ULID ordering should respect generation delays: {} > {} (delays: {:?} vs {:?})", ulid2, ulid1, delay2, delay1); } + prop_assert!(ulid2.timestamp() >= ulid1.timestamp()); + } + Ok(()) + } + + fn test_ulid_ordering_property_in_memory( + ulids in arb_ulid_sequence(2, 20) + ) -> color_eyre::eyre::Result<()> { + let mut sorted_ulids = ulids.clone(); + sorted_ulids.sort(); + prop_assert_eq!(ulids.clone(), sorted_ulids, + "ULIDs with increasing timestamps should already be in sorted order"); - // Timestamps should reflect the ordering - prop_assert!(ulid2.timestamp() >= ulid1.timestamp(), - "ULID timestamps should be monotonic: {} >= {}", - ulid2.timestamp(), ulid1.timestamp()); + for i in 1..ulids.len() { + prop_assert!(ulids[i] > ulids[i-1], + "ULID at index {} ({}) should be greater than previous ({})", + i, ulids[i], ulids[i-1]); } - }); - Ok(()) + + let unique_set: HashSet<_> = ulids.iter().collect(); + prop_assert_eq!(unique_set.len(), ulids.len(), + "All ULIDs in sequence should be unique"); + Ok(()) + } } // ============================================================================= @@ -392,33 +355,25 @@ fn arb_ulid_from_time_range( }) } -#[sinex_test] -fn test_ulid_ordering_property_in_memory() -> color_eyre::eyre::Result<()> { - proptest::proptest!(|( + fn test_ulid_ordering_property_in_memory( ulids in arb_ulid_sequence(2, 20) - )| { - // Property: ULIDs generated with increasing timestamps should be ordered + ) -> color_eyre::eyre::Result<()> { let mut sorted_ulids = ulids.clone(); sorted_ulids.sort(); - - // The original sequence should already be sorted since we used increasing times prop_assert_eq!(ulids.clone(), sorted_ulids, "ULIDs with increasing timestamps should already be in sorted order"); - // Property: Each ULID should be greater than the previous one for i in 1..ulids.len() { prop_assert!(ulids[i] > ulids[i-1], - "ULID at index {} ({}) should be greater than previous ({}) for monotonic sequence", + "ULID at index {} ({}) should be greater than previous ({})", i, ulids[i], ulids[i-1]); } - // Property: All ULIDs should be unique let unique_set: HashSet<_> = ulids.iter().collect(); prop_assert_eq!(unique_set.len(), ulids.len(), "All ULIDs in sequence should be unique"); - }); - Ok(()) -} + Ok(()) + } // Database test temporarily disabled due to direct sqlx usage // TODO: Reimplement using repository pattern @@ -427,7 +382,7 @@ fn test_ulid_ordering_property_in_memory() -> color_eyre::eyre::Result<()> { // TODO: Reimplement using repository pattern /* #[sinex_test] async fn test_ulid_range_query_property(ctx: TestContext) -> TestResult<()> { - proptest::proptest!(|( + sinex_proptest!(|( batch1_size in 2..8usize, batch2_size in 2..8usize, gap_minutes in 1..30i64, @@ -540,7 +495,7 @@ async fn test_ulid_range_query_property(ctx: TestContext) -> TestResult<()> { #[sinex_test] fn test_ulid_timestamp_extraction_property() { - proptest::proptest!(|( + sinex_proptest!(|( time_offset_hours in -24..24i64, time_offset_minutes in 0..60i64, time_offset_seconds in 0..60i64, @@ -577,14 +532,12 @@ fn test_ulid_timestamp_extraction_property() { }); } */ -#[cfg_attr(not(feature = "slow-tests"), ignore)] -#[sinex_test] -fn test_ulid_monotonic_property_with_rapid_generation() -> color_eyre::eyre::Result<()> { - proptest::proptest!(|( +sinex_proptest! { + #[cfg_attr(not(feature = "slow-tests"), ignore)] + fn test_ulid_monotonic_property_with_rapid_generation( generation_count in 5..50usize, - delay_microseconds in 0..1000u64, - )| { - // Property: Rapidly generated ULIDs should maintain ordering even with small delays + delay_microseconds in 0..1000u64 + ) -> color_eyre::eyre::Result<()> { let mut ulids = Vec::new(); let mut timestamps = Vec::new(); @@ -601,7 +554,6 @@ fn test_ulid_monotonic_property_with_rapid_generation() -> color_eyre::eyre::Res ulids.push(ulid); timestamps.push(timestamp); - // Property: Each ULID should be unique for (previous_index, previous_ulid) in ulids.iter().take(i).enumerate() { prop_assert!( ulid != *previous_ulid, @@ -612,30 +564,26 @@ fn test_ulid_monotonic_property_with_rapid_generation() -> color_eyre::eyre::Res } } - // Property: ULIDs should be in increasing order for i in 1..ulids.len() { prop_assert!(ulids[i] >= ulids[i-1], - "ULID at index {} should be >= previous ULID for monotonic sequence", i); + "ULID at index {} should be >= previous ULID", i); } - // Property: Timestamps should be non-decreasing (allowing equal for same millisecond) for i in 1..timestamps.len() { prop_assert!(timestamps[i] >= timestamps[i-1], "Timestamp at index {} should be >= previous timestamp", i); } - // Property: All ULIDs should be unique let unique_ulids: HashSet<_> = ulids.iter().collect(); prop_assert_eq!(unique_ulids.len(), ulids.len(), "All rapidly generated ULIDs should be unique"); - // Property: Sorted order should match generation order let mut sorted_ulids = ulids.clone(); sorted_ulids.sort(); prop_assert_eq!(ulids, sorted_ulids, "ULIDs should already be in sorted order due to monotonic generation"); - }); - Ok(()) + Ok(()) + } } // Foreign key test temporarily disabled due to direct sqlx usage diff --git a/crate/lib/sinex-core/tests/property/validation_roundtrip_property_test.rs b/crate/lib/sinex-core/tests/property/validation_roundtrip_property_test.rs index b698c58bbc..8b55f2bbb4 100644 --- a/crate/lib/sinex-core/tests/property/validation_roundtrip_property_test.rs +++ b/crate/lib/sinex-core/tests/property/validation_roundtrip_property_test.rs @@ -1,30 +1,10 @@ //! Property tests ensuring sanitized events survive database roundtrips. -use once_cell::sync::Lazy; use proptest::prelude::*; use sinex_core::db::sanitization::EventSanitizer; use sinex_core::types::domain::{EventSource, EventType}; use sinex_core::{Event, Provenance}; use sinex_test_utils::prelude::*; -use std::future::Future; -use std::sync::Mutex; - -static TEST_RUNTIME: Lazy> = Lazy::new(|| { - Mutex::new( - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .expect("failed to build tokio runtime for validation property tests"), - ) -}); - -fn run_async(future: F) -> T -where - F: Future, -{ - let runtime = TEST_RUNTIME.lock().expect("tokio runtime mutex poisoned"); - runtime.block_on(future) -} fn arb_event_payload() -> impl Strategy { prop_oneof![ @@ -37,40 +17,26 @@ fn arb_event_payload() -> impl Strategy { ] } -#[sinex_test] -fn sanitized_events_roundtrip_through_db() -> color_eyre::eyre::Result<()> { - proptest!(|(payload in arb_event_payload())| { - run_async(async move { - let ctx = TestContext::new() - .await - .map_err(|e| proptest::test_runner::TestCaseError::fail(e.to_string()))?; - - let mut event = Event::test_event( - EventSource::new("validation.cross"), - EventType::new("sanitization.check"), - payload, - ); - - EventSanitizer::sanitize_event(&mut event) - .map_err(|e| proptest::test_runner::TestCaseError::fail(e.to_string()))?; - if let Provenance::Material { id, .. } = &event.provenance { - ctx.ensure_source_material(*id, None) - .await - .map_err(|e| proptest::test_runner::TestCaseError::fail(e.to_string()))?; - } - let stored = ctx - .pool - .events() - .insert(event.clone()) - .await - .map_err(|e| proptest::test_runner::TestCaseError::fail(e.to_string()))?; - - prop_assert_eq!(stored.source, event.source); - prop_assert_eq!(stored.event_type, event.event_type); - prop_assert_eq!(stored.payload, event.payload); - - Ok::<_, proptest::test_runner::TestCaseError>(()) - })?; - }); +#[sinex_prop] +async fn sanitized_events_roundtrip_through_db( + ctx: &TestContext, + #[strategy(arb_event_payload())] payload: serde_json::Value, +) -> TestResult<()> { + let mut event = Event::test_event( + EventSource::new("validation.cross"), + EventType::new("sanitization.check"), + payload, + ); + + EventSanitizer::sanitize_event(&mut event)?; + if let Provenance::Material { id, .. } = &event.provenance { + ctx.ensure_source_material(*id, None).await?; + } + + let stored = ctx.pool.events().insert(event.clone()).await?; + + prop_assert_eq!(stored.source, event.source); + prop_assert_eq!(stored.event_type, event.event_type); + prop_assert_eq!(stored.payload, event.payload); Ok(()) } diff --git a/crate/lib/sinex-core/tests/property_tests.rs b/crate/lib/sinex-core/tests/property_tests.rs index 704771f87b..79d6dcee47 100644 --- a/crate/lib/sinex-core/tests/property_tests.rs +++ b/crate/lib/sinex-core/tests/property_tests.rs @@ -12,55 +12,44 @@ use serde_json::json; use std::collections::HashSet; // Using shorter imports from sinex-core's re-exports use sinex_core::{Event, EventSource, EventType, HostName, Id, JsonValue, Ulid}; -use sinex_test_utils::{sinex_test, TestResult}; +use sinex_test_utils::{sinex_proptest, TestResult}; // ============================================================================= // ULID PROPERTY TESTS - Invariants for time-ordered identifiers // ============================================================================= -#[sinex_test] -fn test_ulid_generation_properties() -> TestResult { - proptest!(|( - count in 1..1000usize - )| { +sinex_proptest! { + fn test_ulid_generation_properties(count: usize in 1..1000usize) -> TestResult<()> { let mut ulids = Vec::with_capacity(count); for _ in 0..count { ulids.push(Ulid::new()); } - // Property: All ULIDs should be unique let unique: HashSet<_> = ulids.iter().cloned().collect(); prop_assert_eq!(unique.len(), ulids.len()); - // Property: First timestamp should be less than or equal to the final timestamp if let (Some(first), Some(last)) = (ulids.first(), ulids.last()) { prop_assert!(first.timestamp() <= last.timestamp()); } - }); - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn test_ulid_string_properties() -> TestResult { - proptest!(|( - bytes in prop::array::uniform16(any::()) - )| { + fn test_ulid_string_properties( + bytes: [u8; 16] in prop::array::uniform16(any::()) + ) -> TestResult<()> { let ulid = Ulid::from_bytes(bytes).expect("Raw bytes should always form a ULID"); let ulid_str = ulid.to_string(); let parsed = ulid_str .parse::() .expect("String produced from ULID should parse"); prop_assert_eq!(parsed, ulid); - }); - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn test_ulid_ordering_transitivity() -> TestResult { - proptest!(|( - count in 3..100usize, - delay_ms in 0..10u64 - )| { + fn test_ulid_ordering_transitivity( + count: usize in 3..100usize, + delay_ms: u64 in 0..10u64 + ) -> TestResult<()> { let mut ulids = Vec::new(); for _ in 0..count { ulids.push(Ulid::new()); @@ -71,14 +60,13 @@ fn test_ulid_ordering_transitivity() -> TestResult { } } - // Property: Ordering should be transitive for i in 0..ulids.len().saturating_sub(2) { - if ulids[i] <= ulids[i+1] && ulids[i+1] <= ulids[i+2] { - prop_assert!(ulids[i] <= ulids[i+2]); + if ulids[i] <= ulids[i + 1] && ulids[i + 1] <= ulids[i + 2] { + prop_assert!(ulids[i] <= ulids[i + 2]); } } - }); - Ok(()) + Ok(()) + } } // ============================================================================= @@ -121,44 +109,37 @@ prop_compose! { } } -#[sinex_test] -fn test_event_creation_properties() -> TestResult { - proptest!(|( - source in arb_event_source(), - event_type in arb_event_type(), - payload in arb_json_value() - )| { +sinex_proptest! { + fn test_event_creation_properties( + source: EventSource in arb_event_source(), + event_type: EventType in arb_event_type(), + payload: JsonValue in arb_json_value() + ) -> TestResult<()> { let mut event = Event::::test_event( source.clone(), event_type.clone(), payload.clone(), ); - // Simulate DB-assigned ID to derive ingest time event.id = Some(Id::from_ulid(Ulid::new())); - // Property: Event should preserve all input values prop_assert_eq!(event.source, source); prop_assert_eq!(event.event_type, event_type); prop_assert_eq!(event.payload, payload); - // Property: Event should have a reasonable ULID-based ingest time let now = chrono::Utc::now(); let ts = event.id.as_ref().unwrap().as_ulid().timestamp(); prop_assert!(ts <= now); prop_assert!(ts > now - chrono::Duration::minutes(1)); - }); - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn test_event_json_serialization_properties() -> TestResult { - proptest!(|( - source in "[a-zA-Z0-9_-]{1,20}", - event_type in "[a-zA-Z0-9_.]{1,30}", - string_value in "[a-zA-Z0-9 ._-]*", - number_value in any::(), - bool_value in any::() - )| { + fn test_event_json_serialization_properties( + source: String in "[a-zA-Z0-9_-]{1,20}", + event_type: String in "[a-zA-Z0-9_.]{1,30}", + string_value: String in "[a-zA-Z0-9 ._-]*", + number_value: i64 in any::(), + bool_value: bool in any::() + ) -> TestResult<()> { let payload = json!({ "string": string_value, "number": number_value, @@ -171,144 +152,103 @@ fn test_event_json_serialization_properties() -> TestResult { payload, ); - // Property: Serialization should never fail for valid events let json_result = serde_json::to_string(&original_event); prop_assert!(json_result.is_ok()); let json_str = json_result.expect("JSON serialization should succeed for valid event"); - - // Property: Deserialization should be inverse of serialization let deserialize_result = serde_json::from_str::>(&json_str); prop_assert!(deserialize_result.is_ok()); - let deserialized_event = deserialize_result.expect("JSON deserialization should succeed for valid JSON"); + let deserialized_event = + deserialize_result.expect("JSON deserialization should succeed for valid JSON"); - // Property: Round-trip should preserve all fields prop_assert_eq!(deserialized_event.source, original_event.source); prop_assert_eq!(deserialized_event.event_type, original_event.event_type); prop_assert_eq!(deserialized_event.payload, original_event.payload); prop_assert_eq!(deserialized_event.id, original_event.id); - }); - Ok(()) + Ok(()) + } } // ============================================================================= // DOMAIN TYPE PROPERTY TESTS - String wrapper robustness // ============================================================================= -#[sinex_test] -fn test_event_source_properties() -> TestResult { - proptest!(|( - source_str in ".*" - )| { +sinex_proptest! { + fn test_event_source_properties(source_str: String in ".*") -> TestResult<()> { let source = EventSource::new(&source_str); - - // Property: EventSource should preserve input string prop_assert_eq!(source.as_str(), source_str.as_str()); - // Property: Clone should be identical let cloned = source.clone(); prop_assert_eq!(source.clone(), cloned); - // Property: Different creation methods should be equal for same string let static_source = EventSource::new(&source_str); prop_assert_eq!(source.clone(), static_source); - }); - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn test_event_type_properties() -> color_eyre::eyre::Result<()> { - proptest!(|( - type_str in ".*" - )| { + fn test_event_type_properties(type_str: String in ".*") -> TestResult<()> { let event_type = EventType::new(&type_str); - - // Property: EventType should preserve input string prop_assert_eq!(event_type.as_str(), type_str.as_str()); - // Property: Clone should be identical let cloned = event_type.clone(); prop_assert_eq!(event_type, cloned); - }); - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn test_hostname_properties() -> color_eyre::eyre::Result<()> { - proptest!(|( - hostname_str in "[a-zA-Z0-9._-]{1,100}" - )| { + fn test_hostname_properties( + hostname_str: String in "[a-zA-Z0-9._-]{1,100}" + ) -> TestResult<()> { let hostname = HostName::new(&hostname_str); - - // Property: HostName should preserve input string prop_assert_eq!(hostname.as_str(), hostname_str.as_str()); - // Property: Clone should be identical let cloned = hostname.clone(); prop_assert_eq!(hostname, cloned); - }); - Ok(()) + Ok(()) + } } // ============================================================================= // GENERIC ID PROPERTY TESTS - Type-safe identifier properties // ============================================================================= -#[sinex_test] -fn test_generic_id_properties() -> color_eyre::eyre::Result<()> { - proptest!(|( - count in 1..1000usize - )| { - let mut ids = Vec::new(); +sinex_proptest! { + fn test_generic_id_properties(count: usize in 1..1000usize) -> TestResult<()> { + let mut ids = Vec::with_capacity(count); for _ in 0..count { ids.push(Id::>::new()); } - // Property: All IDs should be unique (check pairwise) for (i, id1) in ids.iter().enumerate() { for id2 in ids.iter().skip(i + 1) { prop_assert_ne!(id1, id2); } } - // Property: All IDs should have valid string representations for id in &ids { let id_str = id.to_string(); prop_assert_eq!(id_str.len(), 26); prop_assert!(id_str.chars().all(|c| c.is_ascii_alphanumeric())); } - // Property: IDs should be sortable by string representation let mut id_strings: Vec = ids.iter().map(|id| id.to_string()).collect(); id_strings.sort(); - - // Property: Sorting should be consistent for window in id_strings.windows(2) { prop_assert!(window[0] <= window[1]); } - }); - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn test_id_ulid_conversion_properties() -> color_eyre::eyre::Result<()> { - proptest!(|( - count in 1..100usize - )| { + fn test_id_ulid_conversion_properties(count: usize in 1..100usize) -> TestResult<()> { for _ in 0..count { let original_id = Id::>::new(); - - // Property: ID β†’ ULID β†’ ID should be identity let ulid: Ulid = original_id.clone().into(); let converted_id = Id::>::from(ulid); prop_assert_eq!(original_id.clone(), converted_id); - - // Property: String conversion should be consistent prop_assert_eq!(original_id.to_string(), ulid.to_string()); } - }); - Ok(()) + Ok(()) + } } // ============================================================================= @@ -323,13 +263,12 @@ fn test_id_ulid_conversion_properties() -> color_eyre::eyre::Result<()> { // EDGE CASE PROPERTY TESTS - Boundary conditions and special cases // ============================================================================= -#[sinex_test] -fn test_unicode_handling_properties() -> color_eyre::eyre::Result<()> { - proptest!(|( - unicode_source in "\\PC*", // Any valid Unicode except control characters - unicode_type in "\\PC*", - unicode_value in "\\PC*" - )| { +sinex_proptest! { + fn test_unicode_handling_properties( + unicode_source: String in "\\PC*", + unicode_type: String in "\\PC*", + unicode_value: String in "\\PC*" + ) -> TestResult<()> { let event = Event::::test_event( EventSource::new(&unicode_source), EventType::new(&unicode_type), @@ -340,12 +279,10 @@ fn test_unicode_handling_properties() -> color_eyre::eyre::Result<()> { }), ); - // Property: Unicode should be preserved in all fields prop_assert_eq!(event.source.as_str(), unicode_source.as_str()); prop_assert_eq!(event.event_type.as_str(), unicode_type.as_str()); prop_assert_eq!(event.payload["unicode_value"].clone(), json!(unicode_value)); - // Property: Event should serialize/deserialize correctly with Unicode let json_result = serde_json::to_string(&event); prop_assert!(json_result.is_ok()); @@ -355,16 +292,13 @@ fn test_unicode_handling_properties() -> color_eyre::eyre::Result<()> { let deserialized = deserialized_result.unwrap(); prop_assert_eq!(deserialized.source.as_str(), unicode_source.as_str()); prop_assert_eq!(deserialized.event_type.as_str(), unicode_type.as_str()); - }); - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn test_large_payload_properties() -> color_eyre::eyre::Result<()> { - proptest!(|( - string_size in 1000..100000usize, - array_size in 100..10000usize - )| { + fn test_large_payload_properties( + string_size: usize in 1000..100000usize, + array_size: usize in 100..10000usize + ) -> TestResult<()> { let large_string = "x".repeat(string_size); let large_array: Vec = (0..array_size).map(|i| i as i32).collect(); @@ -383,7 +317,6 @@ fn test_large_payload_properties() -> color_eyre::eyre::Result<()> { payload.clone(), ); - // Property: Large payloads should be handled correctly prop_assert_eq!(event.payload.clone(), payload); prop_assert_eq!( event.payload["large_string"].as_str().unwrap().len(), @@ -394,31 +327,25 @@ fn test_large_payload_properties() -> color_eyre::eyre::Result<()> { array_size ); - // Property: Large events should serialize successfully let json_result = serde_json::to_string(&event); prop_assert!(json_result.is_ok()); - // Property: Serialized large events should deserialize successfully let json_str = json_result.unwrap(); let deserialize_result = serde_json::from_str::>(&json_str); prop_assert!(deserialize_result.is_ok()); - }); - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn test_concurrent_operation_properties() -> color_eyre::eyre::Result<()> { - proptest!(|( - thread_count in 2..10usize, - operations_per_thread in 10..100usize - )| { + fn test_concurrent_operation_properties( + thread_count: usize in 2..10usize, + operations_per_thread: usize in 10..100usize + ) -> TestResult<()> { use std::sync::{Arc, Mutex}; use std::thread; let results = Arc::new(Mutex::new(Vec::new())); let mut handles = Vec::new(); - // Property: Concurrent ULID generation should always produce unique IDs for _ in 0..thread_count { let results_clone = results.clone(); let handle = thread::spawn(move || { @@ -439,37 +366,25 @@ fn test_concurrent_operation_properties() -> color_eyre::eyre::Result<()> { let final_results = results.lock().expect("mutex poisoned"); let expected_count = thread_count * operations_per_thread; - // Property: Should have expected number of ULIDs prop_assert_eq!(final_results.len(), expected_count); - - // Property: All ULIDs should be unique despite concurrent generation let unique: HashSet<_> = final_results.iter().cloned().collect(); prop_assert_eq!(unique.len(), final_results.len()); - }); - Ok(()) -} - -// ============================================================================= -// VALIDATION PROPERTY TESTS - Input validation and error handling -// ============================================================================= + Ok(()) + } -#[sinex_test] -fn test_validation_properties() -> color_eyre::eyre::Result<()> { - proptest!(|( - source_len in 0..1000usize, - type_len in 0..1000usize - )| { + fn test_validation_properties( + source_len: usize in 0..1000usize, + type_len: usize in 0..1000usize + ) -> TestResult<()> { let source_str = "a".repeat(source_len); let type_str = "b".repeat(type_len); let source = EventSource::new(&source_str); let event_type = EventType::new(&type_str); - // Property: Domain types should accept any length input prop_assert_eq!(source.as_str().len(), source_len); prop_assert_eq!(event_type.as_str().len(), type_len); - // Property: Events should be creatable with any valid domain types let event = Event::::test_event( source, event_type, @@ -478,8 +393,8 @@ fn test_validation_properties() -> color_eyre::eyre::Result<()> { prop_assert_eq!(event.source.as_str().len(), source_len); prop_assert_eq!(event.event_type.as_str().len(), type_len); - }); - Ok(()) + Ok(()) + } } // REGRESSION PROPERTY TESTS - Preserve important system invariants diff --git a/crate/lib/sinex-core/tests/unit/preflight_test.rs b/crate/lib/sinex-core/tests/unit/preflight_test.rs index 39483fec3d..fcb873661a 100644 --- a/crate/lib/sinex-core/tests/unit/preflight_test.rs +++ b/crate/lib/sinex-core/tests/unit/preflight_test.rs @@ -601,9 +601,10 @@ async fn test_phase7_integration_success(ctx: TestContext) -> color_eyre::eyre:: .unwrap_or_else(|_| "postgresql:///sinex_test?host=/run/postgresql".to_string()); env::set_var("DATABASE_URL", &db_url); - let (status, details, messages): VerificationTuple = verification::verify_end_to_end_integration() - .await - .map_err(|e| color_eyre::eyre::eyre!("Integration verification failed: {}", e))?; + let (status, details, messages): VerificationTuple = + verification::verify_end_to_end_integration() + .await + .map_err(|e| color_eyre::eyre::eyre!("Integration verification failed: {}", e))?; // Should pass or warn assert!(matches!( @@ -623,11 +624,12 @@ async fn test_phase7_integration_success(ctx: TestContext) -> color_eyre::eyre:: async fn test_phase7_integration_db_failure() -> color_eyre::eyre::Result<()> { env::set_var("DATABASE_URL", "postgresql://invalid:5432/nonexistent"); - let (status, _details, messages): VerificationTuple = verification::verify_end_to_end_integration() - .await - .map_err(|e| { - color_eyre::eyre::eyre!("Integration test should handle DB failure: {}", e) - })?; + let (status, _details, messages): VerificationTuple = + verification::verify_end_to_end_integration() + .await + .map_err(|e| { + color_eyre::eyre::eyre!("Integration test should handle DB failure: {}", e) + })?; assert_eq!(status, VerificationStatus::Fail); assert!(messages diff --git a/crate/lib/sinex-satellite-sdk/tests/property/automation_property_test.rs b/crate/lib/sinex-satellite-sdk/tests/property/automation_property_test.rs index 280397a618..99f405f03d 100644 --- a/crate/lib/sinex-satellite-sdk/tests/property/automation_property_test.rs +++ b/crate/lib/sinex-satellite-sdk/tests/property/automation_property_test.rs @@ -9,7 +9,7 @@ use serde_json::json; use sinex_core::types::domain::{EventSource, EventType}; use sinex_core::{Event, JsonValue}; use sinex_satellite_sdk::{Checkpoint, ProcessorType, ScanArgs, TimeHorizon}; -use sinex_test_utils::prelude::*; +use sinex_test_utils::{prelude::*, sinex_proptest}; use std::collections::HashMap; /// Create property test strategies for events @@ -80,13 +80,12 @@ fn create_test_event( ) } -/// Test checkpoint handling properties -#[sinex_test] -fn test_checkpoint_properties() -> color_eyre::eyre::Result<()> { - proptest!(|( +sinex_proptest! { + /// Test checkpoint handling properties + fn test_checkpoint_properties( message_count in 0u64..10000u64, timestamp_offset in 0i64..86400i64, - )| { + ) -> color_eyre::eyre::Result<()> { // Test different checkpoint types let checkpoints = vec![ Checkpoint::None, @@ -114,19 +113,18 @@ fn test_checkpoint_properties() -> color_eyre::eyre::Result<()> { // Property: Checkpoint descriptions should be non-empty prop_assert!(!checkpoint.description().is_empty()); } - }); - Ok(()) + Ok(()) + } } -/// Test scan args validation properties -#[sinex_test] -fn test_scan_args_properties() -> color_eyre::eyre::Result<()> { - proptest!(|( +sinex_proptest! { + /// Test scan args validation properties + fn test_scan_args_properties( max_events in 0u64..10000u64, dry_run in any::(), interactive in any::(), skip_duplicates in any::(), - )| { + ) -> color_eyre::eyre::Result<()> { let args = ScanArgs { targets: vec!["test-target".to_string()], dry_run, @@ -145,8 +143,8 @@ fn test_scan_args_properties() -> color_eyre::eyre::Result<()> { prop_assert!(deserialized.is_ok()); // Note: We can't test equality because ScanArgs doesn't derive Eq/PartialEq } - }); - Ok(()) + Ok(()) + } } /// Test processor type consistency @@ -168,12 +166,11 @@ fn test_processor_type_properties() -> color_eyre::eyre::Result<()> { Ok(()) } -/// Test event processing determinism (without actual scan) -#[sinex_test] -fn test_event_creation_determinism() -> color_eyre::eyre::Result<()> { - proptest!(|( +sinex_proptest! { + /// Test event processing determinism (without actual scan) + fn test_event_creation_determinism( events in proptest::collection::vec(arb_event_data(), 1..=20), - )| { + ) -> color_eyre::eyre::Result<()> { // Property: Same event data should produce equivalent events for (source, event_type, payload) in events.iter() { let event1 = create_test_event(source, event_type, payload.clone()); @@ -187,14 +184,13 @@ fn test_event_creation_determinism() -> color_eyre::eyre::Result<()> { // Properties that should be the same (schemaless events have None ID) prop_assert_eq!(event1.id, event2.id); // Both should be None } - }); - Ok(()) + Ok(()) + } } -/// Test error handling with malformed data -#[sinex_test] -fn test_error_handling_robustness() -> color_eyre::eyre::Result<()> { - proptest!(|( +sinex_proptest! { + /// Test error handling with malformed data + fn test_error_handling_robustness( malformed_payloads in proptest::collection::vec( prop_oneof![ Just(json!(null)), @@ -206,7 +202,7 @@ fn test_error_handling_robustness() -> color_eyre::eyre::Result<()> { ], 1..=5 ), - )| { + ) -> color_eyre::eyre::Result<()> { // Property: Event creation should handle malformed payloads gracefully for payload in malformed_payloads.iter() { let event = create_test_event("test-source", "test.event", payload.clone()); @@ -220,16 +216,15 @@ fn test_error_handling_robustness() -> color_eyre::eyre::Result<()> { let serialized = serde_json::to_string(&event); prop_assert!(serialized.is_ok()); } - }); - Ok(()) + Ok(()) + } } -/// Test checkpoint description consistency -#[sinex_test] -fn test_checkpoint_description_properties() -> color_eyre::eyre::Result<()> { - proptest!(|( +sinex_proptest! { + /// Test checkpoint description consistency + fn test_checkpoint_description_properties( message_count in 0u64..1000u64, - )| { + ) -> color_eyre::eyre::Result<()> { let checkpoint1 = Checkpoint::Internal { event_id: sinex_core::types::ulid::Ulid::new(), message_count, @@ -257,16 +252,15 @@ fn test_checkpoint_description_properties() -> color_eyre::eyre::Result<()> { let desc1 = checkpoint1.description().to_lowercase(); let desc4 = checkpoint4.description().to_lowercase(); prop_assert!(desc1.contains("internal") == desc4.contains("internal")); - }); - Ok(()) + Ok(()) + } } -/// Test time horizon property consistency -#[sinex_test] -fn test_time_horizon_behavior_properties() -> color_eyre::eyre::Result<()> { - proptest!(|( +sinex_proptest! { + /// Test time horizon property consistency + fn test_time_horizon_behavior_properties( hours_forward in 1u32..24u32, // 1 hour to 1 day - )| { + ) -> color_eyre::eyre::Result<()> { let end_time = Utc::now() + chrono::Duration::hours(hours_forward as i64); let horizons = vec![ @@ -305,17 +299,16 @@ fn test_time_horizon_behavior_properties() -> color_eyre::eyre::Result<()> { // Note: Can't test equality directly due to potential precision issues } } - }); - Ok(()) + Ok(()) + } } -/// Test automation-related data structure consistency -#[sinex_test] -fn test_automation_data_structure_consistency() -> color_eyre::eyre::Result<()> { - proptest!(|( +sinex_proptest! { + /// Test automation-related data structure consistency + fn test_automation_data_structure_consistency( event_count in 1..100usize, batch_size in 1..50usize, - )| { + ) -> color_eyre::eyre::Result<()> { // Property: Event batches should be processable let mut events = Vec::new(); @@ -348,6 +341,6 @@ fn test_automation_data_structure_consistency() -> color_eyre::eyre::Result<()> prop_assert_eq!(event.event_type.as_str(), "batch.test"); prop_assert!(event.payload.is_object()); } - }); - Ok(()) + Ok(()) + } } diff --git a/crate/lib/sinex-satellite-sdk/tests/property/checkpoint_property_test.rs b/crate/lib/sinex-satellite-sdk/tests/property/checkpoint_property_test.rs index 9ba8c7d4d8..f3431fa3f9 100644 --- a/crate/lib/sinex-satellite-sdk/tests/property/checkpoint_property_test.rs +++ b/crate/lib/sinex-satellite-sdk/tests/property/checkpoint_property_test.rs @@ -5,31 +5,12 @@ #![allow(dead_code)] -use once_cell::sync::Lazy; use proptest::prelude::*; use proptest::strategy::ValueTree; use sinex_satellite_sdk::Checkpoint; use sinex_satellite_sdk::{CheckpointManager, CheckpointState}; -use sinex_test_utils::prelude::*; -use std::future::Future; -use std::sync::{Arc, Mutex}; - -static TEST_RUNTIME: Lazy> = Lazy::new(|| { - Mutex::new( - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .expect("failed to build tokio test runtime"), - ) -}); - -fn run_async(future: F) -> T -where - F: Future, -{ - let runtime = TEST_RUNTIME.lock().expect("tokio runtime mutex poisoned"); - runtime.block_on(future) -} +use sinex_test_utils::{prelude::*, sinex_prop}; +use std::sync::Arc; // ============================================================================= // Property Test Strategies @@ -62,357 +43,325 @@ fn checkpoint_data() -> impl Strategy { // Property Tests // ============================================================================= -// Test that checkpoint updates are idempotent -proptest! { - fn checkpoint_updates_are_idempotent( - processor_name in processor_names(), - processed_count in 0u64..10000u64, - last_processed_id in prop::option::of("[0-9A-HJKMNP-TV-Z]{26}"), - checkpoint_data in checkpoint_data(), - ) { - run_async(async { - let ctx = TestContext::new().await.unwrap(); - let pool = ctx.pool.clone(); - - let checkpoint_manager = CheckpointManager::new( - pool.clone(), - processor_name.clone(), - format!("{processor_name}-group"), - format!("{processor_name}-consumer"), - ); - - // Create initial checkpoint state - let checkpoint = if let Some(id) = last_processed_id.clone() { - Checkpoint::Stream { - message_id: id, - event_id: None, - } - } else { - Checkpoint::None - }; - - let initial_state = CheckpointState { - checkpoint, - processed_count, - last_activity: chrono::Utc::now(), - data: Some(checkpoint_data.clone()), - version: 2, - }; - - // Save checkpoint twice - checkpoint_manager.save_checkpoint(&initial_state).await.unwrap(); - checkpoint_manager.save_checkpoint(&initial_state).await.unwrap(); - - // Verify state is consistent - let state = checkpoint_manager.get_checkpoint_stats().await.unwrap(); - prop_assert_eq!(state.max_processed, initial_state.processed_count); - prop_assert!(state.last_update.is_some()); - - Ok::<(), proptest::test_runner::TestCaseError>(()) - })?; - } +#[sinex_prop] +async fn checkpoint_updates_are_idempotent( + #[strategy(processor_names())] processor_name: String, + #[strategy(0u64..10000u64)] processed_count: u64, + #[strategy(prop::option::of("[0-9A-HJKMNP-TV-Z]{26}"))] last_processed_id: Option, + #[strategy(checkpoint_data())] checkpoint_data: serde_json::Value, +) -> TestResult<()> { + let ctx = TestContext::new().await?; + let pool = ctx.pool.clone(); + + let checkpoint_manager = CheckpointManager::new( + pool.clone(), + processor_name.clone(), + format!("{processor_name}-group"), + format!("{processor_name}-consumer"), + ); + + // Create initial checkpoint state + let checkpoint = if let Some(id) = last_processed_id.clone() { + Checkpoint::Stream { + message_id: id, + event_id: None, + } + } else { + Checkpoint::None + }; + + let initial_state = CheckpointState { + checkpoint, + processed_count, + last_activity: chrono::Utc::now(), + data: Some(checkpoint_data.clone()), + version: 2, + }; + + // Save checkpoint twice + checkpoint_manager.save_checkpoint(&initial_state).await?; + checkpoint_manager.save_checkpoint(&initial_state).await?; + + // Verify state is consistent + let state = checkpoint_manager.get_checkpoint_stats().await?; + prop_assert_eq!(state.max_processed, initial_state.processed_count); + prop_assert!(state.last_update.is_some()); + Ok(()) } -// Test checkpoint recovery under various failure scenarios -proptest! { - fn checkpoint_recovery_is_robust( - processor_name in processor_names(), - checkpoints in proptest::collection::vec( - (0u64..1000u64, checkpoint_data()), - 1..=10 - ), - ) { - run_async(async { - let ctx = TestContext::new().await.unwrap(); - let pool = ctx.pool.clone(); - - let checkpoint_manager = CheckpointManager::new( - pool.clone(), - processor_name.clone(), - format!("{processor_name}-group"), - format!("{processor_name}-consumer"), - ); - - // Save multiple checkpoints with increasing counts - let mut expected_final_count = 0u64; - for (i, (processed_count, data)) in checkpoints.iter().enumerate() { - expected_final_count = *processed_count; - - let state = CheckpointState { - checkpoint: Checkpoint::Stream { - message_id: format!("message-{i}"), - event_id: None, - }, - processed_count: *processed_count, - last_activity: chrono::Utc::now(), - data: Some(data.clone()), - version: 2, - }; - - checkpoint_manager.save_checkpoint(&state).await.unwrap(); - } - - // Verify final state - let state = checkpoint_manager.get_checkpoint_stats().await.unwrap(); - prop_assert_eq!(state.max_processed, expected_final_count); - - Ok::<(), proptest::test_runner::TestCaseError>(()) - })?; +#[sinex_prop] +async fn checkpoint_recovery_is_robust( + #[strategy(processor_names())] processor_name: String, + #[strategy(proptest::collection::vec((0u64..1000u64, checkpoint_data()), 1..=10))] + checkpoints: Vec<(u64, serde_json::Value)>, +) -> TestResult<()> { + let ctx = TestContext::new().await?; + let pool = ctx.pool.clone(); + + let checkpoint_manager = CheckpointManager::new( + pool.clone(), + processor_name.clone(), + format!("{processor_name}-group"), + format!("{processor_name}-consumer"), + ); + + // Save multiple checkpoints with increasing counts + let mut expected_final_count = 0u64; + for (i, (processed_count, data)) in checkpoints.iter().enumerate() { + expected_final_count = *processed_count; + + let state = CheckpointState { + checkpoint: Checkpoint::Stream { + message_id: format!("message-{i}"), + event_id: None, + }, + processed_count: *processed_count, + last_activity: chrono::Utc::now(), + data: Some(data.clone()), + version: 2, + }; + + checkpoint_manager.save_checkpoint(&state).await?; } -} - -// Test concurrent checkpoint access -proptest! { - fn concurrent_checkpoint_access_is_safe( - processor_name in processor_names(), - concurrent_updates in proptest::collection::vec(0u64..1000u64, 1..=20), - ) { - run_async(async { - let ctx = TestContext::new().await.unwrap(); - let pool = ctx.pool.clone(); - - let checkpoint_manager = Arc::new(CheckpointManager::new( - pool.clone(), - processor_name.clone(), - format!("{processor_name}-group"), - format!("{processor_name}-consumer"), - )); - - // Launch concurrent update tasks - let mut handles = Vec::new(); - for (i, processed_count) in concurrent_updates.iter().enumerate() { - let manager = checkpoint_manager.clone(); - let count = *processed_count; - let handle = tokio::spawn(async move { - let state = CheckpointState { - checkpoint: Checkpoint::Stream { - message_id: format!("concurrent-{i}"), - event_id: None, - }, - processed_count: count, - last_activity: chrono::Utc::now(), - data: Some(serde_json::json!({"task": i})), - version: 2, - }; - - manager.save_checkpoint(&state).await - }); - handles.push(handle); - } - - // Wait for all updates to complete - let mut results = Vec::new(); - for handle in handles { - results.push(handle.await.unwrap()); - } - - // Verify all updates succeeded (or failed gracefully) - let successful_updates = results.iter().filter(|r| r.is_ok()).count(); - prop_assert!(successful_updates > 0, "At least one update should succeed"); - // Verify final state is consistent - let final_state = checkpoint_manager.get_checkpoint_stats().await.unwrap(); - prop_assert!(final_state.last_update.is_some()); - - Ok::<(), proptest::test_runner::TestCaseError>(()) - })?; - } + // Verify final state + let state = checkpoint_manager.get_checkpoint_stats().await?; + prop_assert_eq!(state.max_processed, expected_final_count); + Ok(()) } -// Test checkpoint state transitions -proptest! { - fn checkpoint_state_transitions_are_valid( - processor_name in processor_names(), - initial_count in 0u64..100u64, - increments in proptest::collection::vec(1u64..100u64, 1..=10), - ) { - run_async(async { - let ctx = TestContext::new().await.unwrap(); - let pool = ctx.pool.clone(); - - let checkpoint_manager = CheckpointManager::new( - pool.clone(), - processor_name.clone(), - format!("{processor_name}-group"), - format!("{processor_name}-consumer"), - ); - - // Initialize with starting count - let mut current_count = initial_count; - let mut state = CheckpointState { +#[sinex_prop] +async fn concurrent_checkpoint_access_is_safe( + #[strategy(processor_names())] processor_name: String, + #[strategy(proptest::collection::vec(0u64..1000u64, 1..=20))] concurrent_updates: Vec, +) -> TestResult<()> { + let ctx = TestContext::new().await?; + let pool = ctx.pool.clone(); + + let checkpoint_manager = Arc::new(CheckpointManager::new( + pool.clone(), + processor_name.clone(), + format!("{processor_name}-group"), + format!("{processor_name}-consumer"), + )); + + // Launch concurrent update tasks + let mut handles = Vec::new(); + for (i, processed_count) in concurrent_updates.iter().enumerate() { + let manager = checkpoint_manager.clone(); + let count = *processed_count; + let handle = tokio::spawn(async move { + let state = CheckpointState { checkpoint: Checkpoint::Stream { - message_id: "initial".to_string(), + message_id: format!("concurrent-{i}"), event_id: None, }, - processed_count: current_count, + processed_count: count, last_activity: chrono::Utc::now(), - data: Some(serde_json::json!({"sequence": 0})), + data: Some(serde_json::json!({ "task": i })), version: 2, }; - checkpoint_manager.save_checkpoint(&state).await.unwrap(); - - // Apply increments sequentially - for (i, increment) in increments.iter().enumerate() { - current_count += increment; - state.processed_count = current_count; - state.set_last_processed_id(Some(format!("step-{i}"))); - state.version += 1; - state.data = Some(serde_json::json!({"sequence": i + 1})); - - checkpoint_manager.save_checkpoint(&state).await.unwrap(); - - // Verify the state was updated correctly - let retrieved_state = checkpoint_manager.get_checkpoint_stats().await.unwrap(); - prop_assert_eq!(retrieved_state.max_processed, current_count); - prop_assert!(retrieved_state.last_update.is_some()); - } + manager.save_checkpoint(&state).await + }); + handles.push(handle); + } - Ok::<(), proptest::test_runner::TestCaseError>(()) - })?; + // Wait for all updates to complete + let mut results = Vec::new(); + for handle in handles { + results.push(handle.await.expect("task join")); } -} -// Test checkpoint data integrity -proptest! { - fn checkpoint_data_integrity_is_preserved( - processor_name in processor_names(), - test_data in checkpoint_data(), - operations in proptest::collection::vec( - prop_oneof![ - Just("save".to_string()), - Just("load".to_string()), - Just("update".to_string()), - ], - 1..=50 - ), - ) { - run_async(async { - let ctx = TestContext::new().await.unwrap(); - let pool = ctx.pool.clone(); - - let checkpoint_manager = CheckpointManager::new( - pool.clone(), - processor_name.clone(), - format!("{processor_name}-group"), - format!("{processor_name}-consumer"), - ); - - let mut expected_data = test_data.clone(); - let mut processed_count = 0u64; - - // Execute operations sequence - for (i, operation) in operations.iter().enumerate() { - match operation.as_str() { - "save" => { - let state = CheckpointState { - checkpoint: Checkpoint::Stream { - message_id: format!("op-{i}"), - event_id: None, - }, - processed_count, - last_activity: chrono::Utc::now(), - data: Some(expected_data.clone()), - version: 2, - }; - - checkpoint_manager.save_checkpoint(&state).await.unwrap(); - } - "load" => { - let stats = checkpoint_manager.get_checkpoint_stats().await.unwrap(); - prop_assert_eq!(stats.max_processed, processed_count); - prop_assert!(stats.last_update.is_some()); - } - "update" => { - processed_count += 1; - expected_data = serde_json::json!({"updated": i, "count": processed_count}); - - let state = CheckpointState { - checkpoint: Checkpoint::Stream { - message_id: format!("update-{i}"), - event_id: None, - }, - processed_count, - last_activity: chrono::Utc::now(), - data: Some(expected_data.clone()), - version: 2, - }; - - checkpoint_manager.save_checkpoint(&state).await.unwrap(); - } - _ => unreachable!(), - } - } + // Verify all updates succeeded (or failed gracefully) + let successful_updates = results.iter().filter(|r| r.is_ok()).count(); + prop_assert!(successful_updates > 0, "At least one update should succeed"); - // Final verification - let final_state = checkpoint_manager.get_checkpoint_stats().await.unwrap(); - prop_assert_eq!(final_state.max_processed, processed_count); - prop_assert!(final_state.last_update.is_some()); + // Verify final state is consistent + let final_state = checkpoint_manager.get_checkpoint_stats().await?; + prop_assert!(final_state.last_update.is_some()); + Ok(()) +} - Ok::<(), proptest::test_runner::TestCaseError>(()) - })?; +#[sinex_prop] +async fn checkpoint_state_transitions_are_valid( + #[strategy(processor_names())] processor_name: String, + #[strategy(0u64..100u64)] initial_count: u64, + #[strategy(proptest::collection::vec(1u64..100u64, 1..=10))] increments: Vec, +) -> TestResult<()> { + let ctx = TestContext::new().await?; + let pool = ctx.pool.clone(); + + let checkpoint_manager = CheckpointManager::new( + pool.clone(), + processor_name.clone(), + format!("{processor_name}-group"), + format!("{processor_name}-consumer"), + ); + + // Initialize with starting count + let mut current_count = initial_count; + let mut state = CheckpointState { + checkpoint: Checkpoint::Stream { + message_id: "initial".to_string(), + event_id: None, + }, + processed_count: current_count, + last_activity: chrono::Utc::now(), + data: Some(serde_json::json!({ "sequence": 0 })), + version: 2, + }; + + checkpoint_manager.save_checkpoint(&state).await?; + + // Apply increments sequentially + for (i, increment) in increments.iter().enumerate() { + current_count += increment; + state.processed_count = current_count; + state.set_last_processed_id(Some(format!("step-{i}"))); + state.version += 1; + state.data = Some(serde_json::json!({ "sequence": i + 1 })); + + checkpoint_manager.save_checkpoint(&state).await?; + + // Verify the state was updated correctly + let retrieved_state = checkpoint_manager.get_checkpoint_stats().await?; + prop_assert_eq!(retrieved_state.max_processed, current_count); + prop_assert!(retrieved_state.last_update.is_some()); } + Ok(()) } -// Test checkpoint cleanup behavior -proptest! { - fn checkpoint_cleanup_maintains_consistency( - processor_names in proptest::collection::vec(processor_names(), 1..=10), - cleanup_threshold in 1u64..100u64, - ) { - run_async(async { - let ctx = TestContext::new().await.unwrap(); - let pool = ctx.pool.clone(); - - // Create multiple automata with checkpoints - let mut managers = Vec::new(); - for processor_name in processor_names.iter() { - let manager = CheckpointManager::new( - pool.clone(), - processor_name.clone(), - format!("{processor_name}-group"), - format!("{processor_name}-consumer"), - ); - - // Create checkpoint +#[sinex_prop] +async fn checkpoint_data_integrity_is_preserved( + #[strategy(processor_names())] processor_name: String, + #[strategy(checkpoint_data())] test_data: serde_json::Value, + #[strategy(proptest::collection::vec( + prop_oneof![ + Just("save".to_string()), + Just("load".to_string()), + Just("update".to_string()), + ], + 1..=50 + ))] operations: Vec, +) -> TestResult<()> { + let ctx = TestContext::new().await?; + let pool = ctx.pool.clone(); + + let checkpoint_manager = CheckpointManager::new( + pool.clone(), + processor_name.clone(), + format!("{processor_name}-group"), + format!("{processor_name}-consumer"), + ); + + let mut expected_data = test_data.clone(); + let mut processed_count = 0u64; + + // Execute operations sequence + for (i, operation) in operations.iter().enumerate() { + match operation.as_str() { + "save" => { let state = CheckpointState { checkpoint: Checkpoint::Stream { - message_id: format!("checkpoint-{processor_name}"), + message_id: format!("op-{i}"), event_id: None, }, - processed_count: cleanup_threshold, + processed_count, last_activity: chrono::Utc::now(), - data: Some(serde_json::json!({"automaton": processor_name})), + data: Some(expected_data.clone()), version: 2, }; - manager.save_checkpoint(&state).await.unwrap(); - managers.push(manager); + checkpoint_manager.save_checkpoint(&state).await?; } - - // Verify all checkpoints exist - for manager in &managers { - let stats = manager.get_checkpoint_stats().await.unwrap(); - prop_assert_eq!(stats.max_processed, cleanup_threshold); + "load" => { + let stats = checkpoint_manager.get_checkpoint_stats().await?; + prop_assert_eq!(stats.max_processed, processed_count); prop_assert!(stats.last_update.is_some()); } + "update" => { + processed_count += 1; + expected_data = serde_json::json!({ "updated": i, "count": processed_count }); - // Test cleanup doesn't affect other automata - let first_automaton = &processor_names[0]; - let cleaned_manager = CheckpointManager::new( - pool.clone(), - first_automaton.clone(), - format!("{first_automaton}-group"), - format!("{first_automaton}-consumer"), - ); - - // Verify cleanup maintains isolation - let remaining_checkpoint = cleaned_manager.get_checkpoint_stats().await.unwrap(); - prop_assert!(remaining_checkpoint.last_update.is_some()); - - Ok::<(), proptest::test_runner::TestCaseError>(()) - })?; + let state = CheckpointState { + checkpoint: Checkpoint::Stream { + message_id: format!("update-{i}"), + event_id: None, + }, + processed_count, + last_activity: chrono::Utc::now(), + data: Some(expected_data.clone()), + version: 2, + }; + + checkpoint_manager.save_checkpoint(&state).await?; + } + _ => unreachable!(), + } } + + // Final verification + let final_state = checkpoint_manager.get_checkpoint_stats().await?; + prop_assert_eq!(final_state.max_processed, processed_count); + prop_assert!(final_state.last_update.is_some()); + Ok(()) +} + +#[sinex_prop] +async fn checkpoint_cleanup_maintains_consistency( + #[strategy(proptest::collection::vec(processor_names(), 1..=10))] processor_names: Vec, + #[strategy(1u64..100u64)] cleanup_threshold: u64, +) -> TestResult<()> { + let ctx = TestContext::new().await?; + let pool = ctx.pool.clone(); + + // Create multiple automata with checkpoints + let mut managers = Vec::new(); + for processor_name in processor_names.iter() { + let manager = CheckpointManager::new( + pool.clone(), + processor_name.clone(), + format!("{processor_name}-group"), + format!("{processor_name}-consumer"), + ); + + // Create checkpoint + let state = CheckpointState { + checkpoint: Checkpoint::Stream { + message_id: format!("checkpoint-{processor_name}"), + event_id: None, + }, + processed_count: cleanup_threshold, + last_activity: chrono::Utc::now(), + data: Some(serde_json::json!({ "automaton": processor_name })), + version: 2, + }; + + manager.save_checkpoint(&state).await?; + managers.push(manager); + } + + // Verify all checkpoints exist + for manager in &managers { + let stats = manager.get_checkpoint_stats().await?; + prop_assert_eq!(stats.max_processed, cleanup_threshold); + prop_assert!(stats.last_update.is_some()); + } + + // Test cleanup doesn't affect other automata + let first_automaton = &processor_names[0]; + let cleaned_manager = CheckpointManager::new( + pool.clone(), + first_automaton.clone(), + format!("{first_automaton}-group"), + format!("{first_automaton}-consumer"), + ); + + // Verify cleanup maintains isolation + let remaining_checkpoint = cleaned_manager.get_checkpoint_stats().await?; + prop_assert!(remaining_checkpoint.last_update.is_some()); + Ok(()) } // ============================================================================= diff --git a/crate/lib/sinex-satellite-sdk/tests/property/error_handling_property_test.rs b/crate/lib/sinex-satellite-sdk/tests/property/error_handling_property_test.rs index 30e3b7da1d..1e97a7ec1a 100644 --- a/crate/lib/sinex-satellite-sdk/tests/property/error_handling_property_test.rs +++ b/crate/lib/sinex-satellite-sdk/tests/property/error_handling_property_test.rs @@ -2,7 +2,7 @@ use proptest::prelude::*; use sinex_satellite_sdk::error_helpers::*; -use sinex_test_utils::sinex_test; +use sinex_test_utils::sinex_proptest; use std::io::ErrorKind; fn arbitrary_error_message() -> impl Strategy { @@ -34,9 +34,12 @@ fn arbitrary_error_kind() -> impl Strategy { ] } -#[sinex_test] -fn io_error_with_context_preserves_message() -> color_eyre::eyre::Result<()> { - proptest!(|(kind in arbitrary_error_kind(), msg in arbitrary_error_message(), ctx in arbitrary_context())| { +sinex_proptest! { + fn io_error_with_context_preserves_message( + kind in arbitrary_error_kind(), + msg in arbitrary_error_message(), + ctx in arbitrary_context() + ) -> color_eyre::eyre::Result<()> { let io_error = std::io::Error::new(kind, msg.clone()); let satellite_error = io_error_with_context(io_error, &ctx); @@ -49,33 +52,32 @@ fn io_error_with_context_preserves_message() -> color_eyre::eyre::Result<()> { } else { prop_assert!(false, "expected processing error variant"); } - }); - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn utf8_error_context_is_preserved() -> color_eyre::eyre::Result<()> { - proptest!(|(bytes in proptest::collection::vec(any::(), 1..32), ctx in arbitrary_context())| { + fn utf8_error_context_is_preserved( + bytes in proptest::collection::vec(any::(), 1..32), + ctx in arbitrary_context() + ) -> color_eyre::eyre::Result<()> { match String::from_utf8(bytes) { Ok(_) => prop_assume!(false), Err(err) => { let satellite_error = utf8_error_with_context(err, &ctx); - if let sinex_satellite_sdk::SatelliteError::Processing(rendered) = satellite_error { - if !ctx.is_empty() { - prop_assert!(rendered.contains(&ctx)); - } - } else { - prop_assert!(false, "expected processing error variant"); - } + if let sinex_satellite_sdk::SatelliteError::Processing(rendered) = satellite_error { + if !ctx.is_empty() { + prop_assert!(rendered.contains(&ctx)); + } + } else { + prop_assert!(false, "expected processing error variant"); + } } } - }); - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn json_error_with_context_marked() -> color_eyre::eyre::Result<()> { - proptest!(|(ctx in arbitrary_context())| { + fn json_error_with_context_marked( + ctx in arbitrary_context() + ) -> color_eyre::eyre::Result<()> { let malformed = "{\"key\":}"; let err = serde_json::from_str::(malformed).unwrap_err(); let satellite_error = json_error_with_context(err, &ctx); @@ -87,6 +89,6 @@ fn json_error_with_context_marked() -> color_eyre::eyre::Result<()> { } else { prop_assert!(false, "expected processing error variant"); } - }); - Ok(()) + Ok(()) + } } diff --git a/crate/lib/sinex-satellite-sdk/tests/property/queue_property_test.rs b/crate/lib/sinex-satellite-sdk/tests/property/queue_property_test.rs index d6b9160654..97db81d61e 100644 --- a/crate/lib/sinex-satellite-sdk/tests/property/queue_property_test.rs +++ b/crate/lib/sinex-satellite-sdk/tests/property/queue_property_test.rs @@ -95,9 +95,10 @@ fn checkpoint_progress_is_monotonic() -> color_eyre::eyre::Result<()> { Ok(()) } -#[sinex_test] -fn checkpoint_state_roundtrips() -> color_eyre::eyre::Result<()> { - proptest!(|(payload in proptest::collection::vec("[a-z0-9]{1,8}", 0..5))| { +sinex_proptest! { + fn checkpoint_state_roundtrips( + payload in proptest::collection::vec("[a-z0-9]{1,8}", 0..5) + ) -> color_eyre::eyre::Result<()> { let json_payload = serde_json::json!({"tags": payload}); let state = CheckpointState { checkpoint: Checkpoint::None, @@ -110,47 +111,40 @@ fn checkpoint_state_roundtrips() -> color_eyre::eyre::Result<()> { let encoded = serde_json::to_string(&state)?; let decoded: CheckpointState = serde_json::from_str(&encoded)?; prop_assert_eq!(decoded.data, Some(json_payload)); - }); - Ok(()) + Ok(()) + } } -#[sinex_test] -fn queue_event_insertion_preserves_order() -> color_eyre::eyre::Result<()> { - proptest!(|(batch_count in 1usize..5, batch_size in 1usize..20)| { - run_async(async move { - let ctx = TestContext::new() - .await - .map_err(|e| proptest::test_runner::TestCaseError::fail(e.to_string()))?; - let baseline = ctx - .pool - .events() - .count_by_source(&EventSource::from("queue.test")) - .await - .map_err(|e| proptest::test_runner::TestCaseError::fail(e.to_string()))?; - for batch in 0..batch_count { - for index in 0..batch_size { - ctx.create_test_event( - "queue.test", - "batch.event", - json!({ "batch": batch, "index": index }), - ) - .await - .map_err(|e| proptest::test_runner::TestCaseError::fail(e.to_string()))?; - } - } - - let total_expected = (batch_count * batch_size) as i64; - let total = ctx - .pool - .events() - .count_by_source(&EventSource::from("queue.test")) - .await - .map_err(|e| proptest::test_runner::TestCaseError::fail(e.to_string()))?; - prop_assert_eq!(total - baseline, total_expected); +#[sinex_prop] +async fn queue_event_insertion_preserves_order( + ctx: &TestContext, + #[strategy(1usize..5)] batch_count: usize, + #[strategy(1usize..20)] batch_size: usize, +) -> TestResult<()> { + let baseline = ctx + .pool + .events() + .count_by_source(&EventSource::from("queue.test")) + .await?; + + for batch in 0..batch_count { + for index in 0..batch_size { + ctx.create_test_event( + "queue.test", + "batch.event", + json!({ "batch": batch, "index": index }), + ) + .await?; + } + } - Ok::<_, proptest::test_runner::TestCaseError>(()) - })?; - }); + let total_expected = (batch_count * batch_size) as i64; + let total = ctx + .pool + .events() + .count_by_source(&EventSource::from("queue.test")) + .await?; + prop_assert_eq!(total - baseline, total_expected); Ok(()) } diff --git a/crate/lib/sinex-satellite-sdk/tests/property/satellite_property_test.rs b/crate/lib/sinex-satellite-sdk/tests/property/satellite_property_test.rs index 895e17abfb..6926b820f4 100644 --- a/crate/lib/sinex-satellite-sdk/tests/property/satellite_property_test.rs +++ b/crate/lib/sinex-satellite-sdk/tests/property/satellite_property_test.rs @@ -5,34 +5,14 @@ #![allow(dead_code)] -use once_cell::sync::Lazy; use proptest::prelude::*; use serde_json::json; use sinex_core::db::repositories::DbPoolExt; use sinex_core::types::domain::{EventSource, EventType}; use sinex_core::{Event, JsonValue}; -use sinex_test_utils::prelude::*; -use std::future::Future; -use std::sync::Mutex; +use sinex_test_utils::{prelude::*, sinex_prop, sinex_proptest}; use std::time::Duration; -static TEST_RUNTIME: Lazy> = Lazy::new(|| { - Mutex::new( - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .expect("failed to build tokio runtime for satellite property tests"), - ) -}); - -fn run_async(future: F) -> T -where - F: Future, -{ - let runtime = TEST_RUNTIME.lock().expect("tokio runtime mutex poisoned"); - runtime.block_on(future) -} - /// Property test strategies for event data mod strategies { use super::*; @@ -104,183 +84,175 @@ mod strategies { use strategies::*; -// Test event processing preserves order -proptest! { - fn satellite_event_processing_preserves_order( - events in event_sequences(), - batch_size in 1usize..100usize, - ) { - run_async(async { - let ctx = TestContext::new().await.unwrap(); - - // Skip if no events to test - if events.is_empty() { - return Ok::<(), proptest::test_runner::TestCaseError>(()); - } - - // Process events in batches - let mut processed_events = Vec::new(); - for chunk in events.chunks(batch_size) { - for event in chunk { - let inserted_event = ctx.pool.events().insert(event.clone()).await.unwrap(); - processed_events.push(inserted_event); - } - - // Wait for batch processing - tokio::time::sleep(Duration::from_millis(10)).await; - } +#[sinex_prop] +async fn satellite_event_processing_preserves_order( + #[strategy(event_sequences())] events: Vec>, + #[strategy(1usize..100usize)] batch_size: usize, +) -> TestResult<()> { + if events.is_empty() { + return Ok(()); + } - // Wait for all events to be processed - tokio::time::sleep(Duration::from_millis(100)).await; + let ctx = TestContext::new().await?; - // Check that we have the expected count - let actual_count = ctx.pool.events().count_all().await.unwrap_or(0); - assert_eq!(actual_count, processed_events.len() as i64); + // Process events in batches + let mut processed_events = Vec::new(); + for chunk in events.chunks(batch_size) { + for event in chunk { + let inserted_event = ctx.pool.events().insert(event.clone()).await?; + processed_events.push(inserted_event); + } - let db_events = ctx.pool.events().get_recent(processed_events.len() as i64).await.unwrap(); - assert_eq!(db_events.len(), processed_events.len()); + // Wait for batch processing + tokio::time::sleep(Duration::from_millis(10)).await; + } - // Verify ULID ordering is preserved (ULIDs are time-ordered) - for i in 1..db_events.len() { - if let (Some(prev_id), Some(curr_id)) = (&db_events[i-1].id, &db_events[i].id) { - assert!(prev_id.timestamp() <= curr_id.timestamp()); - } - } - Ok::<(), proptest::test_runner::TestCaseError>(()) - })?; + // Wait for all events to be processed + tokio::time::sleep(Duration::from_millis(100)).await; + + // Check that we have the expected count + let actual_count = ctx.pool.events().count_all().await?; + assert_eq!(actual_count, processed_events.len() as i64); + + let db_events = ctx + .pool + .events() + .get_recent(processed_events.len() as i64) + .await?; + assert_eq!(db_events.len(), processed_events.len()); + + // Verify ULID ordering is preserved (ULIDs are time-ordered) + for i in 1..db_events.len() { + if let (Some(prev_id), Some(curr_id)) = (&db_events[i - 1].id, &db_events[i].id) { + assert!(prev_id.timestamp() <= curr_id.timestamp()); + } } + Ok(()) } -// Test satellite fault tolerance with intermittent failures -proptest! { - fn satellite_handles_intermittent_failures( - failure_rate in 0.0..0.3f64, // Up to 30% failure rate - events in proptest::collection::vec( - (event_sources(), event_types(), event_payloads()), - 1..=50 - ), - recovery_delay in 1u64..100u64, - ) { - run_async(async { - let ctx = TestContext::new().await.unwrap(); - - // Skip if no events to test - if events.is_empty() { - return Ok::<(), proptest::test_runner::TestCaseError>(()); - } +#[sinex_prop] +async fn satellite_handles_intermittent_failures( + #[strategy(0.0..0.3f64)] failure_rate: f64, // Up to 30% failure rate + #[strategy(proptest::collection::vec( + (event_sources(), event_types(), event_payloads()), + 1..=50 + ))] events: Vec<(String, String, serde_json::Value)>, + #[strategy(1u64..100u64)] recovery_delay: u64, +) -> TestResult<()> { + if events.is_empty() { + return Ok(()); + } - let mut successful_events = 0; - let mut _failed_events = 0; - - for (i, (source, event_type, payload)) in events.iter().enumerate() { - // Simulate intermittent failures - let should_fail = (i as f64 * failure_rate) % 1.0 < failure_rate; - - if should_fail { - // Simulate failure by creating invalid event (empty source) - let invalid_event = Event::test_event( - EventSource::new(""), // Invalid empty source - EventType::new(event_type), - payload.clone(), - ); - - let result = ctx.pool.events().insert(invalid_event).await; - if result.is_err() { - _failed_events += 1; - } - } else { - // Process normal event - let event = Event::test_event( - EventSource::new(source), - EventType::new(event_type), - payload.clone(), - ); - - ctx.pool.events().insert(event).await.unwrap(); - successful_events += 1; - } - - // Add small delay to simulate processing time - tokio::time::sleep(Duration::from_millis(recovery_delay / 10)).await; - } + let ctx = TestContext::new().await?; + + let mut successful_events = 0; + + for (i, (source, event_type, payload)) in events.iter().enumerate() { + // Simulate intermittent failures + let should_fail = (i as f64 * failure_rate) % 1.0 < failure_rate; + + if should_fail { + // Simulate failure by creating invalid event (empty source) + let invalid_event = Event::test_event( + EventSource::new(""), + EventType::new(event_type), + payload.clone(), + ); + + let _ = ctx.pool.events().insert(invalid_event).await; + } else { + // Process normal event + let event = Event::test_event( + EventSource::new(source), + EventType::new(event_type), + payload.clone(), + ); + + ctx.pool.events().insert(event).await?; + successful_events += 1; + } + + // Add small delay to simulate processing time + tokio::time::sleep(Duration::from_millis(recovery_delay / 10)).await; + } - // Wait for processing to complete - tokio::time::sleep(Duration::from_millis(100)).await; + // Wait for processing to complete + tokio::time::sleep(Duration::from_millis(100)).await; - let final_count = ctx.pool.events().count_all().await.unwrap_or(0); - assert_eq!(final_count, successful_events as i64); + let final_count = ctx.pool.events().count_all().await?; + assert_eq!(final_count, successful_events as i64); - // Verify system recovered from failures - assert!(successful_events > 0, "At least some events should succeed"); - Ok::<(), proptest::test_runner::TestCaseError>(()) - })?; - } + // Verify system recovered from failures + assert!(successful_events > 0, "At least some events should succeed"); + Ok(()) } -// Test satellite resource management with concurrent processing -proptest! { - fn satellite_manages_resources_efficiently( - concurrent_operations in 1usize..5usize, - events_per_operation in 1usize..50usize, - processing_delay in 1u64..50u64, - ) { - run_async(async { - let ctx = TestContext::new().await.unwrap(); +#[sinex_prop] +async fn satellite_manages_resources_efficiently( + #[strategy(1usize..5usize)] concurrent_operations: usize, + #[strategy(1usize..50usize)] events_per_operation: usize, + #[strategy(1u64..50u64)] processing_delay: u64, +) -> TestResult<()> { + let ctx = TestContext::new().await?; - // Generate events for concurrent processing - let mut total_events = 0; - let mut handles = Vec::new(); + // Generate events for concurrent processing + let mut total_events = 0; + let mut handles = Vec::new(); - for i in 0..concurrent_operations { - let ctx_clone = TestContext::new().await.unwrap(); - let source = format!("concurrent-{i}"); + for i in 0..concurrent_operations { + let source = format!("concurrent-{i}"); + let per_op = events_per_operation; + let delay = processing_delay; - let handle = tokio::spawn(async move { - let mut operation_events = 0; + let handle = tokio::spawn(async move { + let ctx_clone = TestContext::new().await.expect("test context"); + let mut operation_events = 0; - for j in 0..events_per_operation { - let event = Event::test_event( - EventSource::new(&source), + for j in 0..per_op { + let event = Event::test_event( + EventSource::new(&source), EventType::new(format!("test.event.{j}")), - json!({"operation": i, "event": j}), - ); - - ctx_clone.pool.events().insert(event).await.unwrap(); - operation_events += 1; - - // Small processing delay - tokio::time::sleep(Duration::from_millis(processing_delay)).await; - } + json!({ "operation": i, "event": j }), + ); - operation_events - }); + ctx_clone + .pool + .events() + .insert(event) + .await + .expect("insert event"); + operation_events += 1; - handles.push(handle); - total_events += events_per_operation; + // Small processing delay + tokio::time::sleep(Duration::from_millis(delay)).await; } - // Wait for all operations to complete - let mut completed_events = 0; - for handle in handles { - completed_events += handle.await.unwrap(); - } + operation_events + }); - // Verify all events were processed - assert_eq!(completed_events, total_events); + handles.push(handle); + total_events += events_per_operation; + } - // Wait for final consistency - tokio::time::sleep(Duration::from_millis(200)).await; + // Wait for all operations to complete + let mut completed_events = 0; + for handle in handles { + completed_events += handle.await.expect("task join"); + } - let final_count = ctx.pool.events().count_all().await.unwrap_or(0); - assert_eq!(final_count, total_events as i64); + // Verify all events were processed + assert_eq!(completed_events, total_events); - Ok::<(), proptest::test_runner::TestCaseError>(()) - })?; - } + // Wait for final consistency + tokio::time::sleep(Duration::from_millis(200)).await; + + let final_count = ctx.pool.events().count_all().await?; + assert_eq!(final_count, total_events as i64); + Ok(()) } -// Test satellite configuration validation properties -proptest! { +sinex_proptest! { + // Test satellite configuration validation properties fn satellite_config_validation_is_robust( service_name in "[a-zA-Z0-9_-]+", _batch_size in 1usize..10000usize, @@ -305,214 +277,207 @@ proptest! { } } -// Test event processing with varying batch configurations -proptest! { - fn satellite_batch_processing_is_consistent( - _initial_batch_size in 1usize..100usize, - _updated_batch_size in 1usize..100usize, - events in proptest::collection::vec( - (event_sources(), event_types(), event_payloads()), - 1..=50 - ), - ) { - run_async(async { - let ctx = TestContext::new().await.unwrap(); +#[sinex_prop] +async fn satellite_batch_processing_is_consistent( + #[strategy(1usize..100usize)] _initial_batch_size: usize, + #[strategy(1usize..100usize)] _updated_batch_size: usize, + #[strategy(proptest::collection::vec( + (event_sources(), event_types(), event_payloads()), + 1..=50 + ))] events: Vec<(String, String, serde_json::Value)>, +) -> TestResult<()> { + if events.is_empty() { + return Ok(()); + } - // Skip if no events to test - if events.is_empty() { - return Ok::<(), proptest::test_runner::TestCaseError>(()); - } + let ctx = TestContext::new().await?; - // Process events in first batch configuration - let half_point = events.len() / 2; - for (source, event_type, payload) in events.iter().take(half_point) { - let event = Event::test_event( - EventSource::new(source), - EventType::new(event_type), - payload.clone(), - ); + // Process events in first batch configuration + let half_point = events.len() / 2; + for (source, event_type, payload) in events.iter().take(half_point) { + let event = Event::test_event( + EventSource::new(source), + EventType::new(event_type), + payload.clone(), + ); - ctx.pool.events().insert(event).await.unwrap(); - } + ctx.pool.events().insert(event).await?; + } - // Wait for initial processing - tokio::time::sleep(Duration::from_millis(50)).await; + // Wait for initial processing + tokio::time::sleep(Duration::from_millis(50)).await; - // Process remaining events (simulating batch size change) - for (source, event_type, payload) in events.iter().skip(half_point) { - let event = Event::test_event( - EventSource::new(source), - EventType::new(event_type), - payload.clone(), - ); + // Process remaining events (simulating batch size change) + for (source, event_type, payload) in events.iter().skip(half_point) { + let event = Event::test_event( + EventSource::new(source), + EventType::new(event_type), + payload.clone(), + ); - ctx.pool.events().insert(event).await.unwrap(); - } + ctx.pool.events().insert(event).await?; + } - // Wait for all events to be processed - tokio::time::sleep(Duration::from_millis(100)).await; + // Wait for all events to be processed + tokio::time::sleep(Duration::from_millis(100)).await; - // Verify no events were lost during configuration changes - let final_count = ctx.pool.events().count_all().await.unwrap_or(0); - assert_eq!(final_count, events.len() as i64); + // Verify no events were lost during configuration changes + let final_count = ctx.pool.events().count_all().await?; + assert_eq!(final_count, events.len() as i64); + Ok(()) +} - Ok::<(), proptest::test_runner::TestCaseError>(()) - })?; +#[sinex_prop] +async fn satellite_survives_processing_interruptions( + #[strategy(1u64..100u64)] interruption_duration: u64, + #[strategy(1usize..20usize)] events_before_interruption: usize, + #[strategy(1usize..20usize)] events_during_interruption: usize, + #[strategy(1usize..20usize)] events_after_interruption: usize, +) -> TestResult<()> { + let ctx = TestContext::new().await?; + + // Phase 1: Normal operation + for i in 0..events_before_interruption { + let event = Event::test_event( + EventSource::new("interruption_test"), + EventType::new(format!("before.{i}")), + json!({ "phase": "before", "index": i }), + ); + + ctx.pool.events().insert(event).await?; } -} -// Test satellite resilience to processing interruptions -proptest! { - fn satellite_survives_processing_interruptions( - interruption_duration in 1u64..100u64, - events_before_interruption in 1usize..20usize, - events_during_interruption in 1usize..20usize, - events_after_interruption in 1usize..20usize, - ) { - run_async(async { - let ctx = TestContext::new().await.unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; - // Phase 1: Normal operation - for i in 0..events_before_interruption { - let event = Event::test_event( - EventSource::new("interruption_test"), - EventType::new(format!("before.{i}")), - json!({"phase": "before", "index": i}), - ); + // Phase 2: Simulate interruption by creating events that might be delayed + for i in 0..events_during_interruption { + let event = Event::test_event( + EventSource::new("interruption_test"), + EventType::new(format!("during.{i}")), + json!({ "phase": "during", "index": i }), + ); - ctx.pool.events().insert(event).await.unwrap(); - } + // Try to insert with timeout to simulate network issues + let _ = tokio::time::timeout(Duration::from_millis(50), ctx.pool.events().insert(event)).await; + } - tokio::time::sleep(Duration::from_millis(50)).await; + // Wait for interruption duration + tokio::time::sleep(Duration::from_millis(interruption_duration)).await; - // Phase 2: Simulate interruption by creating events that might be delayed - let _interruption_start = tokio::time::Instant::now(); + // Phase 3: Recovery + for i in 0..events_after_interruption { + let event = Event::test_event( + EventSource::new("interruption_test"), + EventType::new(format!("after.{i}")), + json!({ "phase": "after", "index": i }), + ); - for i in 0..events_during_interruption { - let event = Event::test_event( - EventSource::new("interruption_test"), - EventType::new(format!("during.{i}")), - json!({"phase": "during", "index": i}), - ); + ctx.pool.events().insert(event).await?; + } - // Try to insert with timeout to simulate network issues - let _ = tokio::time::timeout( - Duration::from_millis(50), - ctx.pool.events().insert(event) - ).await; - } + // Wait for recovery and verify minimum events + let expected_minimum = events_before_interruption + events_after_interruption; + tokio::time::sleep(Duration::from_millis(150)).await; - // Wait for interruption duration - tokio::time::sleep(Duration::from_millis(interruption_duration)).await; + let final_count = ctx.pool.events().count_all().await?; + assert!(final_count >= expected_minimum as i64); + Ok(()) +} - // Phase 3: Recovery - for i in 0..events_after_interruption { +#[sinex_prop] +async fn satellite_maintains_event_ordering_under_load( + #[strategy(1usize..5usize)] concurrent_sources: usize, + #[strategy(1usize..20usize)] events_per_source: usize, + #[strategy(1u64..20u64)] processing_jitter: u64, +) -> TestResult<()> { + let ctx = TestContext::new().await?; + + let mut handles = Vec::new(); + + // Create concurrent event producers + for source_id in 0..concurrent_sources { + let source_name = format!("ordering-test-{source_id}"); + let per_source = events_per_source; + let jitter = processing_jitter; + + let handle = tokio::spawn(async move { + let ctx_clone = TestContext::new().await.expect("test context"); + for event_id in 0..per_source { let event = Event::test_event( - EventSource::new("interruption_test"), - EventType::new(format!("after.{i}")), - json!({"phase": "after", "index": i}), + EventSource::new(source_name.clone()), + EventType::new("ordering.test"), + json!({ + "source_id": source_id, + "event_id": event_id, + "timestamp": chrono::Utc::now().timestamp_millis() + }), ); - ctx.pool.events().insert(event).await.unwrap(); - } - - // Wait for recovery and verify minimum events - let expected_minimum = events_before_interruption + events_after_interruption; - tokio::time::sleep(Duration::from_millis(150)).await; - - let final_count = ctx.pool.events().count_all().await.unwrap_or(0); - assert!(final_count >= expected_minimum as i64); - - Ok::<(), proptest::test_runner::TestCaseError>(()) - })?; - } -} - -// Test event ordering properties under concurrent load -proptest! { - fn satellite_maintains_event_ordering_under_load( - concurrent_sources in 1usize..5usize, - events_per_source in 1usize..20usize, - processing_jitter in 1u64..20u64, - ) { - run_async(async { - let ctx = TestContext::new().await.unwrap(); - - let mut handles = Vec::new(); - - // Create concurrent event producers - for source_id in 0..concurrent_sources { - let ctx_clone = TestContext::new().await.unwrap(); - let source_name = format!("ordering-test-{source_id}"); - - let handle = tokio::spawn(async move { - for event_id in 0..events_per_source { - let event = Event::test_event( - EventSource::new(source_name.clone()), - EventType::new("ordering.test"), - json!({ - "source_id": source_id, - "event_id": event_id, - "timestamp": chrono::Utc::now().timestamp_millis() - }), - ); - - ctx_clone.pool.events().insert(event).await.unwrap(); - - // Add jitter to simulate real-world timing variations - tokio::time::sleep(Duration::from_millis(processing_jitter)).await; - } - }); - - handles.push(handle); - } + ctx_clone + .pool + .events() + .insert(event) + .await + .expect("insert event"); - // Wait for all producers to complete - for handle in handles { - handle.await.unwrap(); + // Add jitter to simulate real-world timing variations + tokio::time::sleep(Duration::from_millis(jitter)).await; } + }); - // Wait for all events to be processed - let total_events = concurrent_sources * events_per_source; - tokio::time::sleep(Duration::from_millis(200)).await; - - // Verify event ordering within each source - let all_events = ctx.pool.events().get_recent((total_events * 2) as i64).await.unwrap(); - assert_eq!(all_events.len(), total_events); + handles.push(handle); + } - // Group events by source and verify ordering within each source - let mut events_by_source: std::collections::HashMap> = std::collections::HashMap::new(); + // Wait for all producers to complete + for handle in handles { + handle.await.expect("task join"); + } - for event in all_events { - let source = event.source.to_string(); - events_by_source.entry(source).or_default().push(event); - } + // Wait for all events to be processed + let total_events = concurrent_sources * events_per_source; + tokio::time::sleep(Duration::from_millis(200)).await; + + // Verify event ordering within each source + let all_events = ctx + .pool + .events() + .get_recent((total_events * 2) as i64) + .await?; + assert_eq!(all_events.len(), total_events); + + // Group events by source and verify ordering within each source + let mut events_by_source: std::collections::HashMap> = + std::collections::HashMap::new(); + + for event in all_events { + let source = event.source.to_string(); + events_by_source.entry(source).or_default().push(event); + } - // Verify each source maintained ordering - for (_source, mut source_events) in events_by_source { - // Sort by ID timestamp to get creation order (handle Option) - source_events.sort_by(|a, b| { - match (a.id.as_ref(), b.id.as_ref()) { - (Some(id_a), Some(id_b)) => id_a.timestamp().cmp(&id_b.timestamp()), - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => std::cmp::Ordering::Equal, - } - }); - - // Verify sequential event_ids within payload - for window in source_events.windows(2) { - let (payload1, payload2) = (&window[0].payload, &window[1].payload); - if let (Some(id1), Some(id2)) = ( - payload1.get("event_id").and_then(|v| v.as_u64()), - payload2.get("event_id").and_then(|v| v.as_u64()) - ) { - // Within a source, event_ids should be sequential - assert!(id1 < id2 || id1 == 0, "Events within source should maintain ordering"); - } - } + // Verify each source maintained ordering + for (_source, mut source_events) in events_by_source { + // Sort by ID timestamp to get creation order (handle Option) + source_events.sort_by(|a, b| match (a.id.as_ref(), b.id.as_ref()) { + (Some(id_a), Some(id_b)) => id_a.timestamp().cmp(&id_b.timestamp()), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + }); + + // Verify sequential event_ids within payload + for window in source_events.windows(2) { + let (payload1, payload2) = (&window[0].payload, &window[1].payload); + if let (Some(id1), Some(id2)) = ( + payload1.get("event_id").and_then(|v| v.as_u64()), + payload2.get("event_id").and_then(|v| v.as_u64()), + ) { + // Within a source, event_ids should be sequential + assert!( + id1 < id2 || id1 == 0, + "Events within source should maintain ordering" + ); } - Ok::<(), proptest::test_runner::TestCaseError>(()) - })?; + } } + Ok(()) } diff --git a/crate/lib/sinex-satellite-sdk/tests/property/validation_invariants_property_test.rs b/crate/lib/sinex-satellite-sdk/tests/property/validation_invariants_property_test.rs index a467328f7e..958c8d0d6b 100644 --- a/crate/lib/sinex-satellite-sdk/tests/property/validation_invariants_property_test.rs +++ b/crate/lib/sinex-satellite-sdk/tests/property/validation_invariants_property_test.rs @@ -1,32 +1,12 @@ //! Cross-crate validation invariants that ensure sanitized payloads remain //! usable inside satellite checkpoint state structures. -use once_cell::sync::Lazy; use proptest::prelude::*; use sinex_core::db::sanitization::EventSanitizer; use sinex_core::types::domain::{EventSource, EventType}; use sinex_core::Event; use sinex_satellite_sdk::CheckpointState; use sinex_test_utils::prelude::*; -use std::future::Future; -use std::sync::Mutex; - -static TEST_RUNTIME: Lazy> = Lazy::new(|| { - Mutex::new( - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - .expect("failed to build tokio runtime for validation property tests"), - ) -}); - -fn run_async(future: F) -> T -where - F: Future, -{ - let runtime = TEST_RUNTIME.lock().expect("tokio runtime mutex poisoned"); - runtime.block_on(future) -} fn arb_event_payload() -> impl Strategy { prop_oneof![ @@ -39,31 +19,28 @@ fn arb_event_payload() -> impl Strategy { ] } -#[sinex_test] -fn sanitized_payload_fits_checkpoint_state() -> color_eyre::eyre::Result<()> { - proptest!(|(payload in arb_event_payload())| { - run_async(async move { - let mut event = Event::test_event( - EventSource::new("validation.checkpoint"), - EventType::new("compat"), - payload, - ); - EventSanitizer::sanitize_event(&mut event).expect("sanitization should succeed"); - - let state = CheckpointState { - checkpoint: sinex_satellite_sdk::Checkpoint::None, - processed_count: 0, - last_activity: chrono::Utc::now(), - data: Some(event.payload.clone()), - version: 1, - }; - - let encoded = serde_json::to_string(&state)?; - let decoded: CheckpointState = serde_json::from_str(&encoded)?; - prop_assert_eq!(decoded.data, state.data); - - Ok::<_, proptest::test_runner::TestCaseError>(()) - })?; - }); - Ok(()) +sinex_proptest! { + fn sanitized_payload_fits_checkpoint_state( + payload in arb_event_payload() + ) -> color_eyre::eyre::Result<()> { + let mut event = Event::test_event( + EventSource::new("validation.checkpoint"), + EventType::new("compat"), + payload, + ); + EventSanitizer::sanitize_event(&mut event).expect("sanitization should succeed"); + + let state = CheckpointState { + checkpoint: sinex_satellite_sdk::Checkpoint::None, + processed_count: 0, + last_activity: chrono::Utc::now(), + data: Some(event.payload.clone()), + version: 1, + }; + + let encoded = serde_json::to_string(&state)?; + let decoded: CheckpointState = serde_json::from_str(&encoded)?; + prop_assert_eq!(decoded.data, state.data); + Ok(()) + } } diff --git a/crate/lib/sinex-satellite-sdk/tests/property_checkpoint_state.rs b/crate/lib/sinex-satellite-sdk/tests/property_checkpoint_state.rs index a8ca36875f..164dba77bc 100644 --- a/crate/lib/sinex-satellite-sdk/tests/property_checkpoint_state.rs +++ b/crate/lib/sinex-satellite-sdk/tests/property_checkpoint_state.rs @@ -27,12 +27,11 @@ fn build_state(processed_count: u64) -> CheckpointState { } } -#[sinex_test] -fn property_ulid_inputs_create_internal_checkpoint() -> color_eyre::eyre::Result<()> { - proptest!(|( +sinex_proptest! { + fn property_ulid_inputs_create_internal_checkpoint( processed in 0u64..10_000, ulid in arb_ulid() - )| { + ) -> TestResult<()> { let ulid_str = ulid.to_string(); let mut state = build_state(processed); @@ -48,17 +47,13 @@ fn property_ulid_inputs_create_internal_checkpoint() -> color_eyre::eyre::Result prop_assert_eq!(state.last_processed_id(), Some(ulid_str)); prop_assert_eq!(state.processed_count, processed); - }); - - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn property_non_ulid_inputs_create_stream_checkpoint() -> color_eyre::eyre::Result<()> { - proptest!(|( + fn property_non_ulid_inputs_create_stream_checkpoint( processed in 0u64..10_000, message_id in arb_non_ulid_string() - )| { + ) -> TestResult<()> { let mut state = build_state(processed); state.set_last_processed_id(Some(message_id.clone())); @@ -73,23 +68,16 @@ fn property_non_ulid_inputs_create_stream_checkpoint() -> color_eyre::eyre::Resu prop_assert_eq!(state.last_processed_id(), Some(message_id)); prop_assert_eq!(state.processed_count, processed); - }); - - Ok(()) -} + Ok(()) + } -#[sinex_test] -fn property_none_resets_checkpoint() -> color_eyre::eyre::Result<()> { - proptest!(|( - processed in 0u64..10_000 - )| { + fn property_none_resets_checkpoint(processed in 0u64..10_000) -> TestResult<()> { let mut state = build_state(processed); - state.set_last_processed_id(None); prop_assert!(matches!(state.checkpoint, Checkpoint::None)); prop_assert_eq!(state.last_processed_id(), None); prop_assert_eq!(state.processed_count, processed); - }); - Ok(()) + Ok(()) + } } diff --git a/crate/lib/sinex-satellite-sdk/tests/security/path_validation_test.rs b/crate/lib/sinex-satellite-sdk/tests/security/path_validation_test.rs index 3fbf9f1c0e..2bd0a5293e 100644 --- a/crate/lib/sinex-satellite-sdk/tests/security/path_validation_test.rs +++ b/crate/lib/sinex-satellite-sdk/tests/security/path_validation_test.rs @@ -119,7 +119,7 @@ async fn test_blob_manager_path_validation(ctx: TestContext) -> color_eyre::eyre while event_rx.recv().await.is_some() {} }); - let blob_manager = BlobManager::new(annex_config, ctx.pool().clone(), event_tx)?; + let blob_manager = BlobManager::new(annex_config, ctx.pool().clone(), Some(event_tx))?; // Test with safe path - should work if file exists let safe_path = camino::Utf8PathBuf::try_from(temp_file)?; @@ -163,7 +163,7 @@ async fn blob_manager_rejects_percent_encoded_traversal( large_files: None, }; - let blob_manager = BlobManager::new(annex_config, ctx.pool().clone(), event_tx)?; + let blob_manager = BlobManager::new(annex_config, ctx.pool().clone(), Some(event_tx))?; let encoded_path = Utf8PathBuf::from("%2e%2e%2fetc%2fpasswd"); diff --git a/crate/lib/sinex-schema/docs/architecture.md b/crate/lib/sinex-schema/docs/architecture.md index 18c10a7bce..69887b6d06 100644 --- a/crate/lib/sinex-schema/docs/architecture.md +++ b/crate/lib/sinex-schema/docs/architecture.md @@ -249,8 +249,7 @@ let pool = ctx.db().pool(); // Isolated connection pool Critical for ULID generation under stress: ```rust -proptest! { - #[test] +sinex_proptest! { fn concurrent_ulids_are_unique_and_ordered( num_threads in 2usize..=8, ulids_per_thread in 10usize..=100 @@ -329,4 +328,4 @@ The sinex-schema crate provides a robust foundation for Sinex's data layer throu - **Comprehensive testing** that validates behavior under stress - **Clear architectural patterns** that support long-term maintenance -This design enables Sinex to handle high-volume, time-ordered data efficiently while maintaining strong consistency guarantees and developer productivity. \ No newline at end of file +This design enables Sinex to handle high-volume, time-ordered data efficiently while maintaining strong consistency guarantees and developer productivity. diff --git a/crate/lib/sinex-schema/tests/ulid_property_from_main_tests.rs b/crate/lib/sinex-schema/tests/ulid_property_from_main_tests.rs index 56cd4d4fad..a37470c0a7 100644 --- a/crate/lib/sinex-schema/tests/ulid_property_from_main_tests.rs +++ b/crate/lib/sinex-schema/tests/ulid_property_from_main_tests.rs @@ -5,20 +5,17 @@ use chrono::{DateTime, Utc}; use proptest::prelude::*; use sinex_schema::ulid::Ulid; -use sinex_test_utils::{sinex_test, TestResult}; +use sinex_test_utils::{sinex_proptest, sinex_test, TestResult}; use std::collections::HashSet; use std::sync::{Arc, Barrier}; use std::thread; -// Test that ULIDs maintain chronological ordering -#[sinex_test] -fn test_ulid_chronological_ordering() -> TestResult<()> { - proptest::proptest!(|( +sinex_proptest! { + fn test_ulid_chronological_ordering( count in 2usize..10, delay_micros in 100u64..1000 - )| { + ) -> TestResult<()> { let mut ulids = Vec::new(); - for i in 0..count { if i > 0 { for _ in 0..delay_micros { @@ -27,17 +24,11 @@ fn test_ulid_chronological_ordering() -> TestResult<()> { } ulids.push(Ulid::new()); } - - // Verify ordering for window in ulids.windows(2) { - prop_assert!( - window[0] <= window[1], - "ULID ordering violated" - ); + prop_assert!(window[0] <= window[1], "ULID ordering violated"); } - }); - Ok(()) -} + Ok(()) + } // Test ULID uniqueness under concurrent generation #[sinex_test] @@ -100,30 +91,18 @@ fn test_ulid_monotonic_within_ms() -> TestResult<()> { Ok(()) } -// Test ULID string representation properties -#[sinex_test] -fn test_ulid_string_properties() -> TestResult<()> { - proptest::proptest!(|(_dummy in 0u8..1)| { + fn test_ulid_string_properties(_dummy in 0u8..1) -> TestResult<()> { let ulid = Ulid::new(); let s = ulid.to_string(); - - // String should be 26 characters prop_assert_eq!(s.len(), 26); - - // Should be valid Crockford Base32 for c in s.chars() { - prop_assert!( - "0123456789ABCDEFGHJKMNPQRSTVWXYZ".contains(c), - "Invalid character in ULID string: {}", c - ); + prop_assert!("0123456789ABCDEFGHJKMNPQRSTVWXYZ".contains(c), + "Invalid character in ULID string: {}", c); } - - // Should round-trip let parsed = s.parse::().unwrap(); prop_assert_eq!(ulid, parsed); - }); - Ok(()) -} + Ok(()) + } // Test ULID timestamp extraction #[sinex_test] @@ -157,25 +136,18 @@ fn test_ulid_nil() -> TestResult<()> { Ok(()) } -// Test ULID from_bytes and to_bytes -#[sinex_test] -fn test_ulid_bytes_roundtrip() -> TestResult<()> { - proptest::proptest!(|(_dummy in 0u8..1)| { + fn test_ulid_bytes_roundtrip(_dummy in 0u8..1) -> TestResult<()> { let original = Ulid::new(); let bytes = original.to_bytes(); let restored = Ulid::from_bytes(bytes).unwrap(); prop_assert_eq!(original, restored); - }); - Ok(()) -} + Ok(()) + } -// Test ULID ordering matches string ordering -#[sinex_test] -fn test_ulid_string_ordering() -> TestResult<()> { - proptest::proptest!(|( + fn test_ulid_string_ordering( count in 2usize..5, delay_micros in 100u64..1000 - )| { + ) -> TestResult<()> { let mut ulids = Vec::new(); let mut strings = Vec::new(); @@ -190,14 +162,12 @@ fn test_ulid_string_ordering() -> TestResult<()> { strings.push(ulid.to_string()); } - // Sort both ulids.sort(); strings.sort(); - // Verify they match for (ulid, string) in ulids.iter().zip(strings.iter()) { prop_assert_eq!(ulid.to_string(), string.as_str()); } - }); - Ok(()) + Ok(()) + } } diff --git a/crate/lib/sinex-schema/tests/ulid_property_tests.rs b/crate/lib/sinex-schema/tests/ulid_property_tests.rs index a7bb93a845..e365facf95 100644 --- a/crate/lib/sinex-schema/tests/ulid_property_tests.rs +++ b/crate/lib/sinex-schema/tests/ulid_property_tests.rs @@ -25,8 +25,7 @@ mod ulid_property_tests { use super::*; use sinex_test_utils::sinex_proptest; - proptest! { - #[test] + sinex_proptest! { fn prop_ulid_string_representation_always_26_chars(ulid in ulid_strategy()) { let s = ulid.to_string(); prop_assert_eq!(s.len(), 26); @@ -39,28 +38,24 @@ mod ulid_property_tests { } } - #[test] fn prop_ulid_parsing_roundtrip(ulid in ulid_strategy()) { let s = ulid.to_string(); let parsed = s.parse::().unwrap(); prop_assert_eq!(ulid, parsed); } - #[test] fn prop_ulid_bytes_roundtrip(ulid in ulid_strategy()) { let bytes = ulid.to_bytes(); let restored = Ulid::from_bytes(bytes).unwrap(); prop_assert_eq!(ulid, restored); } - #[test] fn prop_ulid_uuid_roundtrip(ulid in ulid_strategy()) { let uuid = ulid.to_uuid(); let restored = Ulid::from_uuid(uuid); prop_assert_eq!(ulid, restored); } - #[test] fn prop_ulid_ordering_is_consistent(ulid1 in ulid_strategy(), ulid2 in ulid_strategy()) { let ord1 = ulid1.cmp(&ulid2); let ord2 = ulid1.to_string().cmp(&ulid2.to_string()); @@ -70,7 +65,6 @@ mod ulid_property_tests { prop_assert_eq!(ord1, ord3); } - #[test] fn prop_timestamp_extraction_is_reasonable(ulid in ulid_strategy()) { let timestamp = ulid.timestamp(); @@ -83,7 +77,6 @@ mod ulid_property_tests { prop_assert!(timestamp <= max_time); } - #[test] fn prop_nil_ulid_behavior( bytes_prefix in proptest::collection::vec(any::(), 0..16) ) { @@ -106,7 +99,6 @@ mod ulid_property_tests { } } - #[test] fn prop_concurrent_generation_produces_unique_ulids( num_threads in 1usize..=8, ulids_per_thread in 1usize..=100 @@ -130,7 +122,6 @@ mod ulid_property_tests { prop_assert_eq!(unique_ulids.len(), all_ulids.len()); } - #[test] fn prop_rapid_generation_maintains_monotonicity(count in 1usize..=1000) { let ulids: Vec<_> = (0..count).map(|_| Ulid::new()).collect(); @@ -141,7 +132,6 @@ mod ulid_property_tests { // Note: We don't assert strict monotonicity across large bursts; generation may span clock jitter } - #[test] fn prop_ulid_with_specific_timestamp_behavior( timestamp_ms in 1577836800000u64..1893456000000u64 // 2020-2030 ) { @@ -155,7 +145,6 @@ mod ulid_property_tests { prop_assert!(diff <= 1000); // Within 1 second } - #[test] fn prop_ulid_hash_stability(ulid in ulid_strategy()) { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; @@ -188,7 +177,7 @@ mod ulid_property_tests { mod stress_tests { use super::*; - proptest! { + sinex_proptest! { #[test] fn prop_high_frequency_generation_stress_test( burst_size in 100usize..=1000, @@ -273,7 +262,7 @@ mod stress_tests { mod edge_case_properties { use super::*; - proptest! { + sinex_proptest! { #[test] fn prop_ulid_comparison_transitivity( ulid1 in ulid_strategy(), @@ -377,7 +366,7 @@ mod concurrent_property_tests { use super::*; use std::sync::{Arc, Barrier, Mutex}; - proptest! { + sinex_proptest! { #[test] fn prop_concurrent_ulid_generation_ordering( num_threads in 2usize..=8, diff --git a/crate/lib/sinex-schema/tests/ulid_tests.rs b/crate/lib/sinex-schema/tests/ulid_tests.rs index cd9ab03958..0de6961653 100644 --- a/crate/lib/sinex-schema/tests/ulid_tests.rs +++ b/crate/lib/sinex-schema/tests/ulid_tests.rs @@ -342,31 +342,31 @@ mod conversion_tests { #[cfg(test)] mod property_tests { use super::*; + use sinex_test_utils::sinex_proptest; - proptest! { - #[test] + sinex_proptest! { fn test_ulid_string_roundtrip(ulid in ulid_strategy()) { let ulid_str = ulid.to_string(); let parsed = ulid_str.parse::().unwrap(); prop_assert_eq!(ulid, parsed); } - #[test] fn test_ulid_bytes_roundtrip(ulid in ulid_strategy()) { let bytes = ulid.to_bytes(); let restored = Ulid::from_bytes(bytes).unwrap(); prop_assert_eq!(ulid, restored); } - #[test] fn test_ulid_uuid_roundtrip(ulid in ulid_strategy()) { let uuid = ulid.to_uuid(); let restored = Ulid::from_uuid(uuid); prop_assert_eq!(ulid, restored); } - #[test] - fn test_ulid_ordering_consistency(ulid1 in ulid_strategy(), ulid2 in ulid_strategy()) { + fn test_ulid_ordering_consistency( + ulid1 in ulid_strategy(), + ulid2 in ulid_strategy() + ) { // Compare ULIDs let ulid_cmp = ulid1.cmp(&ulid2); @@ -380,7 +380,6 @@ mod property_tests { prop_assert_eq!(ulid_cmp, uuid_cmp); } - #[test] fn test_timestamp_extraction_reasonable(ulid in ulid_strategy()) { let timestamp = ulid.timestamp(); diff --git a/crate/lib/sinex-test-utils/macros/src/lib.rs b/crate/lib/sinex-test-utils/macros/src/lib.rs index 48b27c4666..656c184093 100644 --- a/crate/lib/sinex-test-utils/macros/src/lib.rs +++ b/crate/lib/sinex-test-utils/macros/src/lib.rs @@ -8,8 +8,17 @@ //! - Rich error reporting with timing information use proc_macro::TokenStream; -use quote::quote; -use syn::{parse_macro_input, visit::Visit, Expr, ItemFn, Lit, Meta}; +use quote::{quote, ToTokens}; +use syn::{ + braced, parenthesized, + parse::{Parse, ParseStream, Parser}, + parse_macro_input, parse_quote, + punctuated::Punctuated, + spanned::Spanned, + token::Comma, + Attribute, Block, Expr, FnArg, Ident, ItemFn, Lit, Meta, Pat, PatIdent, ReturnType, Token, + Type, Visibility, +}; /// Configuration parsed from sinex_test attributes struct SinexTestConfig { @@ -77,59 +86,740 @@ fn parse_sinex_test_attrs(attr: TokenStream) -> SinexTestConfig { config } -/// Visitor to detect proptest usage in function body -struct ProptestDetector { - has_proptest: bool, +#[derive(Clone, Default)] +struct SinexPropConfig { + timeout: Option, + trace: bool, + cases: Option, + seed: Option, + max_shrink_time_ms: Option, +} + +fn parse_sinex_prop_attrs(attr: TokenStream) -> syn::Result { + if attr.is_empty() { + return Ok(SinexPropConfig::default()); + } + + let metas = Punctuated::::parse_terminated.parse(attr)?; + let mut config = SinexPropConfig::default(); + + for meta in metas { + match meta { + Meta::NameValue(nv) if nv.path.is_ident("timeout") => { + if let Expr::Lit(expr_lit) = nv.value { + config.timeout = Some(parse_timeout_literal(&expr_lit.lit)?); + } else { + return Err(syn::Error::new( + nv.value.span(), + "timeout expects an integer literal or \"30s\"", + )); + } + } + Meta::NameValue(nv) if nv.path.is_ident("cases") => { + if let Expr::Lit(expr_lit) = nv.value { + config.cases = Some(parse_u32_literal(&expr_lit.lit)?); + } else { + return Err(syn::Error::new( + nv.value.span(), + "cases expects an integer literal", + )); + } + } + Meta::NameValue(nv) if nv.path.is_ident("seed") => { + if let Expr::Lit(expr_lit) = nv.value { + config.seed = Some(parse_u64_literal(&expr_lit.lit)?); + } else { + return Err(syn::Error::new( + nv.value.span(), + "seed expects an integer literal", + )); + } + } + Meta::NameValue(nv) if nv.path.is_ident("max_shrink_time_ms") => { + if let Expr::Lit(expr_lit) = nv.value { + config.max_shrink_time_ms = Some(parse_u64_literal(&expr_lit.lit)?); + } else { + return Err(syn::Error::new( + nv.value.span(), + "max_shrink_time_ms expects an integer literal", + )); + } + } + Meta::Path(path) if path.is_ident("trace") => { + config.trace = true; + } + other => { + return Err(syn::Error::new( + other.span(), + "supported options: timeout=.., trace, cases=.., seed=.., max_shrink_time_ms=..", + )); + } + } + } + + Ok(config) +} + +fn merge_prop_configs(base: &SinexPropConfig, overrides: &SinexPropConfig) -> SinexPropConfig { + SinexPropConfig { + timeout: overrides.timeout.or(base.timeout), + trace: overrides.trace || base.trace, + cases: overrides.cases.or(base.cases), + seed: overrides.seed.or(base.seed), + max_shrink_time_ms: overrides.max_shrink_time_ms.or(base.max_shrink_time_ms), + } +} + +fn config_to_attr_tokens(config: &SinexPropConfig) -> proc_macro2::TokenStream { + let mut entries = Vec::new(); + if let Some(timeout) = config.timeout { + entries.push(quote! { timeout = #timeout }); + } + if config.trace { + entries.push(quote! { trace }); + } + if let Some(cases) = config.cases { + entries.push(quote! { cases = #cases }); + } + if let Some(seed) = config.seed { + entries.push(quote! { seed = #seed }); + } + if let Some(ms) = config.max_shrink_time_ms { + entries.push(quote! { max_shrink_time_ms = #ms }); + } + + if entries.is_empty() { + quote! {} + } else { + quote! { (#(#entries),*) } + } +} + +fn parse_u32_literal(lit: &Lit) -> syn::Result { + match lit { + Lit::Int(int) => int + .base10_parse::() + .map_err(|e| syn::Error::new(int.span(), e)), + _ => Err(syn::Error::new(lit.span(), "expected integer literal")), + } +} + +fn type_is_test_context(ty: &Type) -> bool { + if let Type::Path(type_path) = ty { + return type_path + .path + .segments + .last() + .map(|seg| seg.ident == "TestContext") + .unwrap_or(false); + } + false +} + +fn is_test_context_reference(ty: &Type) -> bool { + if let Type::Reference(reference) = ty { + return type_is_test_context(&reference.elem); + } + false } -impl ProptestDetector { - fn new() -> Self { - Self { - has_proptest: false, +#[proc_macro_attribute] +pub fn sinex_prop(attr: TokenStream, item: TokenStream) -> TokenStream { + match expand_sinex_prop(attr, item) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +struct PropParam { + ident: Ident, + strategy: proc_macro2::TokenStream, +} + +fn expand_sinex_prop( + attr: TokenStream, + item: TokenStream, +) -> syn::Result { + let input: ItemFn = syn::parse(item)?; + let config = parse_sinex_prop_attrs(attr)?; + + let fn_name = input.sig.ident.clone(); + let fn_vis = input.vis.clone(); + let is_async = input.sig.asyncness.is_some(); + let attrs = input.attrs.clone(); + + let has_result_return = match &input.sig.output { + ReturnType::Type(_, ty) => { + if let Type::Path(type_path) = ty.as_ref() { + type_path + .path + .segments + .last() + .map(|seg| { + let ident = &seg.ident; + ident == "Result" || ident == "TestResult" + }) + .unwrap_or(false) + } else { + false + } + } + ReturnType::Default => false, + }; + + if !has_result_return { + return Err(syn::Error::new_spanned( + &input.sig.output, + "sinex_prop functions must return Result<()> or Result", + )); + } + + let mut ctx_param: Option = None; + let mut prop_params = Vec::::new(); + + let mut inner_inputs = input.sig.inputs.clone(); + for arg in inner_inputs.iter_mut() { + match arg { + FnArg::Receiver(_) => { + return Err(syn::Error::new_spanned( + arg, + "sinex_prop does not support self receivers", + )); + } + FnArg::Typed(pat_ty) => { + if is_test_context_reference(&pat_ty.ty) { + if ctx_param.is_some() { + return Err(syn::Error::new_spanned( + &pat_ty.ty, + "multiple TestContext parameters are not supported", + )); + } + if let Pat::Ident(PatIdent { ident, .. }) = &*pat_ty.pat { + ctx_param = Some(ident.clone()); + } else { + return Err(syn::Error::new_spanned( + &pat_ty.pat, + "TestContext parameter must be an identifier", + )); + } + continue; + } + + let mut strategy = None; + let mut cleaned_attrs = Vec::new(); + for attr in &pat_ty.attrs { + if attr.path().is_ident("strategy") { + strategy = Some(attr.parse_args::()?); + } else { + cleaned_attrs.push(attr.clone()); + } + } + if strategy.is_none() { + return Err(syn::Error::new_spanned( + &pat_ty.pat, + "each parameter must have #[strategy(...)]", + )); + } + if let Pat::Ident(PatIdent { ident, .. }) = &*pat_ty.pat { + prop_params.push(PropParam { + ident: ident.clone(), + strategy: strategy.unwrap(), + }); + } else { + return Err(syn::Error::new_spanned( + &pat_ty.pat, + "parameter pattern must be an identifier", + )); + } + pat_ty.attrs = cleaned_attrs; + } + } + } + + if prop_params.is_empty() { + return Err(syn::Error::new( + fn_name.span(), + "sinex_prop requires at least one #[strategy] parameter", + )); + } + + let mut inner_fn = input.clone(); + inner_fn.attrs.clear(); + inner_fn.vis = syn::Visibility::Inherited; + let inner_ident = Ident::new(&format!("__sinex_prop_impl_{}", fn_name), fn_name.span()); + inner_fn.sig.ident = inner_ident.clone(); + inner_fn.sig.inputs = inner_inputs; + + let timeout_secs = config.timeout.unwrap_or(30); + let cases_expr = config + .cases + .map(|v| quote! { Some(#v) }) + .unwrap_or_else(|| quote! { None }); + let seed_expr = config + .seed + .map(|v| quote! { Some(#v) }) + .unwrap_or_else(|| quote! { None }); + let shrink_expr = config + .max_shrink_time_ms + .map(|v| quote! { Some(#v) }) + .unwrap_or_else(|| quote! { None }); + + let tracing_block = if config.trace { + quote! { sinex_test_utils::TestContext::init_tracing("debug"); } + } else { + quote! {} + }; + + let strategy_expr = if prop_params.len() == 1 { + let strat = &prop_params[0].strategy; + quote! { #strat } + } else { + let pieces = prop_params.iter().map(|p| &p.strategy); + quote! { ( #(#pieces),* ) } + }; + + let destructure = if prop_params.len() == 1 { + let ident = &prop_params[0].ident; + quote! { let #ident = value; } + } else { + let idents = prop_params.iter().map(|p| &p.ident); + quote! { let (#(#idents),*) = value; } + }; + + let has_ctx = ctx_param.is_some(); + + let mut call_args = Vec::new(); + if ctx_param.is_some() { + call_args.push(quote! { ctx_ref.expect("TestContext available") }); + } + for param in &prop_params { + let ident = ¶m.ident; + call_args.push(quote!(#ident)); + } + + let mut call_expr = quote! { #inner_ident( #(#call_args),* ) }; + if is_async { + call_expr = quote! { #call_expr.await }; + } + + let ctx_ref_stmt = if has_ctx { + quote! { let ctx_ref = ctx_holder.as_ref(); } + } else { + quote! {} + }; + + let harness = quote! { + #(#attrs)* + #[tokio::test(flavor = "multi_thread")] + #fn_vis async fn #fn_name() -> color_eyre::eyre::Result<()> { + #tracing_block + let test_name = stringify!(#fn_name); + let start = std::time::Instant::now(); + eprintln!("πŸ”„ {} [property, timeout: {}s]", test_name.replace('_', " "), #timeout_secs); + + let timeout = std::time::Duration::from_secs(#timeout_secs); + + let (run_result, telemetry) = tokio::time::timeout( + timeout, + async { + let mut ctx_holder = if #has_ctx { + Some(sinex_test_utils::TestContext::with_name(test_name).await?) + } else { + None + }; + + let telemetry = ctx_holder + .as_ref() + .map(|ctx| sinex_test_utils::runlog::TelemetryHandle::from(ctx.telemetry_handle())); + #ctx_ref_stmt + + let overrides = sinex_test_utils::property_testing::RunnerOverrides { + cases: #cases_expr, + seed: #seed_expr, + max_shrink_time_ms: #shrink_expr, + }; + let mut runner = sinex_test_utils::property_testing::make_runner(overrides); + let strategy = #strategy_expr; + + let run = runner.run(&strategy, |value| { + #destructure + let fut = async { #call_expr }; + futures::executor::block_on(fut) + .map_err(|err| proptest::test_runner::TestCaseError::fail(format!("{err:?}"))) + }); + + drop(ctx_holder); + Ok::<_, color_eyre::eyre::Report>((run, telemetry)) + }, + ) + .await + .map_err(|_| color_eyre::eyre::eyre!(format!("Test timed out after {} seconds", #timeout_secs)))??; + + let result = run_result + .map_err(|err| color_eyre::eyre::eyre!(format!("Property failure: {err}"))); + + let elapsed = start.elapsed(); + if result.is_ok() { + eprintln!("βœ… {} ({:.1?})", test_name.replace('_', " "), elapsed); + } else { + eprintln!("❌ {} ({:.1?})", test_name.replace('_', " "), elapsed); + } + + sinex_test_utils::runlog::record_async( + test_name, + elapsed, + telemetry.as_ref(), + &result, + ) + .await; + + result } + }; + + Ok(quote! { + #inner_fn + #harness + }) +} + +fn parse_u64_literal(lit: &Lit) -> syn::Result { + match lit { + Lit::Int(int) => int + .base10_parse::() + .map_err(|e| syn::Error::new(int.span(), e)), + _ => Err(syn::Error::new(lit.span(), "expected integer literal")), } } -impl<'ast> Visit<'ast> for ProptestDetector { - fn visit_macro(&mut self, node: &'ast syn::Macro) { - if let Some(ident) = node.path.get_ident() { - if ident == "proptest" { - self.has_proptest = true; +fn parse_timeout_literal(lit: &Lit) -> syn::Result { + match lit { + Lit::Int(int) => int + .base10_parse::() + .map_err(|e| syn::Error::new(int.span(), e)), + Lit::Str(s) => { + let value = s.value(); + if let Some(stripped) = value.trim().strip_suffix('s') { + stripped + .parse::() + .map_err(|e| syn::Error::new(s.span(), e)) + } else { + value + .trim() + .parse::() + .map_err(|e| syn::Error::new(s.span(), e)) } } - syn::visit::visit_macro(self, node); + _ => Err(syn::Error::new( + lit.span(), + r#"expected integer seconds or string literal like "30s""#, + )), } } -/// Detect if the function body contains proptest! macro calls -fn has_proptest_usage(block: &syn::Block) -> bool { - let mut detector = ProptestDetector::new(); - detector.visit_block(block); - detector.has_proptest +struct ProptestBlock { + defaults: SinexPropConfig, + cases: Vec, } -/// Transform proptest! calls to work with async runtime -fn transform_proptest_calls(block: &syn::Block) -> syn::Block { - use syn::parse_quote; - - // For now, we'll wrap the entire block in a runtime bridge - // In a more sophisticated implementation, we'd traverse and transform specific proptest! calls - parse_quote! { - { - // Create a runtime handle if not already in async context - let rt_handle = match tokio::runtime::Handle::try_current() { - Ok(handle) => handle, - Err(_) => { - // Create a new runtime for proptest execution - let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime for proptest"); - rt.handle().clone() +struct ProptestCase { + attrs: Vec, + overrides: SinexPropConfig, + vis: Visibility, + async_token: Option, + name: Ident, + ctx_param: Option, + prop_params: Vec, + output: ReturnType, + body: Block, +} + +struct BlockCtxParam { + ident: Ident, + ty: Type, +} + +struct BlockPropParamSpec { + ident: Ident, + ty: Type, + strategy: Expr, +} + +impl Parse for ProptestBlock { + fn parse(input: ParseStream) -> syn::Result { + let content; + braced!(content in input); + + let mut defaults = SinexPropConfig::default(); + let mut cases = Vec::new(); + + while !content.is_empty() { + if content.peek(Token![#]) && content.peek2(Token![!]) { + let attrs = content.call(Attribute::parse_inner)?; + for attr in attrs { + if !apply_prop_control_attr(&attr, &mut defaults)? { + return Err(syn::Error::new( + attr.span(), + "unsupported block attribute; allowed: cases, timeout, trace, seed, max_shrink_time_ms", + )); + } } - }; + continue; + } + + cases.push(content.parse::()?); + } + + Ok(Self { defaults, cases }) + } +} + +impl Parse for ProptestCase { + fn parse(input: ParseStream) -> syn::Result { + let attrs = input.call(Attribute::parse_outer)?; + let (overrides, passthrough) = split_control_attrs(attrs)?; + + let vis: Visibility = input.parse()?; + let async_token = if input.peek(Token![async]) { + Some(input.parse()?) + } else { + None + }; + + let _fn_token: Token![fn] = input.parse()?; + let name: Ident = input.parse()?; + + if input.peek(Token![<]) { + return Err(syn::Error::new( + input.span(), + "generics are not supported in sinex_proptest! declarations", + )); + } + + let content; + parenthesized!(content in input); + let (ctx_param, prop_params) = parse_block_params(&content)?; + + if prop_params.is_empty() { + return Err(syn::Error::new( + name.span(), + "sinex_proptest! cases require at least one parameter with `in `", + )); + } + + if input.peek(Token![where]) { + return Err(syn::Error::new( + input.span(), + "where clauses are not supported in sinex_proptest! declarations", + )); + } + + let output = if input.peek(Token![->]) { + let arrow: Token![->] = input.parse()?; + let ty: Type = input.parse()?; + ReturnType::Type(arrow, Box::new(ty)) + } else { + ReturnType::Default + }; + + let body: Block = input.parse()?; + + Ok(Self { + attrs: passthrough, + overrides, + vis, + async_token, + name, + ctx_param, + prop_params, + output, + body, + }) + } +} - // Execute the original block within the runtime context - #block +fn parse_block_params( + input: ParseStream, +) -> syn::Result<(Option, Vec)> { + let mut ctx_param = None; + let mut props = Vec::new(); + + while !input.is_empty() { + let ident: Ident = input.parse()?; + + input.parse::()?; + let ty: Type = input.parse()?; + + if input.peek(Token![in]) { + input.parse::()?; + let strategy: Expr = input.parse()?; + props.push(BlockPropParamSpec { + ident, + ty, + strategy, + }); + } else { + if ctx_param.is_some() { + return Err(syn::Error::new( + ident.span(), + "only one non-strategy parameter is allowed (use it for &TestContext)", + )); + } + if !is_test_context_reference(&ty) { + return Err(syn::Error::new( + ty.span(), + "parameters must use `name: Type in ` unless they are &TestContext", + )); + } + ctx_param = Some(BlockCtxParam { ident, ty }); + } + + if input.peek(Token![,]) { + input.parse::()?; + if input.is_empty() { + break; + } } } + + Ok((ctx_param, props)) +} + +fn split_control_attrs(attrs: Vec) -> syn::Result<(SinexPropConfig, Vec)> { + let mut overrides = SinexPropConfig::default(); + let mut passthrough = Vec::new(); + + for attr in attrs { + if apply_prop_control_attr(&attr, &mut overrides)? { + continue; + } + passthrough.push(attr); + } + + Ok((overrides, passthrough)) +} + +fn apply_prop_control_attr(attr: &Attribute, cfg: &mut SinexPropConfig) -> syn::Result { + match attr.meta.clone() { + Meta::NameValue(nv) if nv.path.is_ident("cases") => { + if let Expr::Lit(expr_lit) = nv.value { + cfg.cases = Some(parse_u32_literal(&expr_lit.lit)?); + return Ok(true); + } + return Err(syn::Error::new( + nv.value.span(), + "cases attribute expects an integer literal", + )); + } + Meta::NameValue(nv) if nv.path.is_ident("timeout") => { + if let Expr::Lit(expr_lit) = nv.value { + cfg.timeout = Some(parse_timeout_literal(&expr_lit.lit)?); + return Ok(true); + } + return Err(syn::Error::new( + nv.value.span(), + "timeout attribute expects an integer or \"30s\" literal", + )); + } + Meta::NameValue(nv) if nv.path.is_ident("seed") => { + if let Expr::Lit(expr_lit) = nv.value { + cfg.seed = Some(parse_u64_literal(&expr_lit.lit)?); + return Ok(true); + } + return Err(syn::Error::new( + nv.value.span(), + "seed attribute expects an integer literal", + )); + } + Meta::NameValue(nv) if nv.path.is_ident("max_shrink_time_ms") => { + if let Expr::Lit(expr_lit) = nv.value { + cfg.max_shrink_time_ms = Some(parse_u64_literal(&expr_lit.lit)?); + return Ok(true); + } + return Err(syn::Error::new( + nv.value.span(), + "max_shrink_time_ms expects an integer literal", + )); + } + Meta::Path(path) if path.is_ident("trace") => { + cfg.trace = true; + Ok(true) + } + _ => Ok(false), + } +} + +#[proc_macro] +pub fn sinex_proptest(input: TokenStream) -> TokenStream { + let block = parse_macro_input!(input as ProptestBlock); + match expand_sinex_proptest(block) { + Ok(tokens) => tokens.into(), + Err(err) => err.to_compile_error().into(), + } +} + +fn expand_sinex_proptest(block: ProptestBlock) -> syn::Result { + if block.cases.is_empty() { + return Err(syn::Error::new( + proc_macro2::Span::call_site(), + "sinex_proptest! requires at least one test case", + )); + } + + let mut expanded = Vec::new(); + for case in block.cases { + let config = merge_prop_configs(&block.defaults, &case.overrides); + let attr_tokens = config_to_attr_tokens(&config); + let prop_attr = if attr_tokens.is_empty() { + quote! { #[sinex_test_utils::sinex_prop] } + } else { + quote! { #[sinex_test_utils::sinex_prop #attr_tokens] } + }; + + let async_token = case.async_token; + let name = case.name; + let vis = case.vis; + let attrs = case.attrs; + let body = case.body; + + let ctx_param = case.ctx_param.map(|param| { + let ident = param.ident; + let ty = param.ty; + quote! { #ident: #ty } + }); + + let prop_params: Vec<_> = case + .prop_params + .into_iter() + .map(|param| { + let ident = param.ident; + let ty = param.ty; + let strategy = param.strategy; + quote! { #[strategy(#strategy)] #ident: #ty } + }) + .collect(); + + let output = match case.output { + ReturnType::Default => parse_quote!(-> sinex_test_utils::TestResult<()>), + other => other, + }; + + let params = if let Some(ctx) = ctx_param { + quote! { #ctx, #(#prop_params),* } + } else { + quote! { #(#prop_params),* } + }; + + expanded.push(quote! { + #prop_attr + #(#attrs)* + #vis #async_token fn #name(#params) #output { + #body + } + }); + } + + Ok(quote! { #(#expanded)* }) } #[proc_macro_attribute] @@ -188,16 +878,7 @@ pub fn sinex_test(attr: TokenStream, item: TokenStream) -> TokenStream { }); let enable_tracing = config.trace; - // Detect proptest usage - let has_proptest = has_proptest_usage(&input.block); - - // Process function body based on proptest usage - let fn_body = if has_proptest { - // Transform proptest calls to work with async runtime - transform_proptest_calls(&input.block) - } else { - *input.block.clone() - }; + let fn_body = *input.block.clone(); let fn_vis = &input.vis; @@ -221,16 +902,12 @@ pub fn sinex_test(attr: TokenStream, item: TokenStream) -> TokenStream { }; if !has_result_return { - if has_proptest { - // We'll inject a default Result<()> return type downstream - } else { return syn::Error::new_spanned( &input.sig.output, "sinex_test functions must return Result<()> or Result", ) .to_compile_error() .into(); - } } // Check if function takes TestContext parameter @@ -286,24 +963,28 @@ pub fn sinex_test(attr: TokenStream, item: TokenStream) -> TokenStream { let mut new_sig = input.sig.clone(); new_sig.inputs = filtered_inputs.into_iter().collect(); - // Build test body with optional TestContext creation and tracing - let test_body = match (has_ctx_param, enable_tracing) { - (true, true) => quote! { - let _tracing_guard = TestContext::with_name(stringify!(#fn_name)) - .await? - .with_tracing("debug"); - let ctx = TestContext::with_name(stringify!(#fn_name)).await?; - #fn_body - }, - (true, false) => quote! { - let ctx = TestContext::with_name(stringify!(#fn_name)).await?; - #fn_body - }, - (false, true) => quote! { - let _tracing_guard = sinex_test_utils::test_context::TestContext::init_tracing("debug"); - #fn_body - }, - (false, false) => quote! { #fn_body }, + let tracing_block = if enable_tracing { + quote! { + TestContext::init_tracing("debug"); + } + } else { + quote! {} + }; + + let future_body = if has_ctx_param { + quote! { + #tracing_block + let ctx = TestContext::with_name(test_name).await?; + let telemetry = sinex_test_utils::runlog::TelemetryHandle::from(ctx.telemetry_handle()); + let inner = async { #fn_body }.await; + (inner, Some(telemetry)) + } + } else { + quote! { + #tracing_block + let inner = async { #fn_body }.await; + (inner, None::) + } }; return quote! { @@ -318,9 +999,9 @@ pub fn sinex_test(attr: TokenStream, item: TokenStream) -> TokenStream { let start = Instant::now(); eprintln!("πŸ”„ {} [rstest case, timeout: {}s]", test_name.replace('_', " "), #timeout_secs); - let result = tokio::time::timeout( + let (result, telemetry) = tokio::time::timeout( std::time::Duration::from_secs(#timeout_secs), - async { #test_body } + async { #future_body } ).await .map_err(|_| color_eyre::eyre::eyre!("Test timed out after {} seconds", #timeout_secs))?; @@ -331,6 +1012,24 @@ pub fn sinex_test(attr: TokenStream, item: TokenStream) -> TokenStream { eprintln!("❌ {} ({:.1?})", test_name.replace('_', " "), elapsed); } + if let Some(handle) = telemetry.as_ref() { + sinex_test_utils::runlog::record_async( + test_name, + elapsed, + Some(handle.as_ref()), + &result, + ) + .await; + } else { + sinex_test_utils::runlog::record_async( + test_name, + elapsed, + None, + &result, + ) + .await; + } + result } }.into(); @@ -393,157 +1092,78 @@ pub fn sinex_test(attr: TokenStream, item: TokenStream) -> TokenStream { test_name.replace('_', " "), #timeout_secs); } + sinex_test_utils::runlog::record_sync( + test_name, + elapsed, + &result, + ); + result } } } else if takes_context { - if has_proptest { - // Database test with proptest support - quote! { - #(#test_attrs)* - #[tokio::test] - #fn_vis async fn #fn_name() -> color_eyre::eyre::Result<()> { - // Note: TestContext must be in scope - - // Wrap the entire test in a timeout - let test_future = async { - // Show test starting (always visible) - let test_name = stringify!(#fn_name); - let start = std::time::Instant::now(); - eprintln!("πŸ”„ {} [proptest+async, timeout: {}s]", test_name.replace('_', " "), #timeout_secs); - - // Create test context with test name - let ctx = TestContext::with_name(test_name).await?; - - // Run the proptest with progress tracking - let result: color_eyre::eyre::Result<()> = { - // For proptest, spawn a progress indicator - let progress_task = tokio::spawn(async { - let mut interval = tokio::time::interval(std::time::Duration::from_secs(10)); - interval.tick().await; // Skip first immediate tick - let mut elapsed_secs = 10; - loop { - interval.tick().await; - eprintln!(" ⏳ {} [proptest] still running... ({}s elapsed)", test_name.replace('_', " "), elapsed_secs); - elapsed_secs += 10; - if elapsed_secs >= #timeout_secs - 10 { - break; - } + // Regular database test using universal pool system with proper cleanup + quote! { + #(#test_attrs)* + #[tokio::test] + #fn_vis async fn #fn_name() -> color_eyre::eyre::Result<()> { + let test_future = async { + let test_name = stringify!(#fn_name); + let start = std::time::Instant::now(); + eprintln!("πŸ”„ {} [timeout: {}s]", test_name.replace('_', " "), #timeout_secs); + + let ctx = TestContext::with_name(test_name).await?; + let telemetry = ctx.telemetry_handle(); + + let result: color_eyre::eyre::Result<()> = if #timeout_secs > 10 { + let progress_task = tokio::spawn(async { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(5)); + interval.tick().await; + let mut elapsed_secs = 5; + loop { + interval.tick().await; + eprintln!(" ⏳ {} still running... ({}s elapsed)", test_name.replace('_', " "), elapsed_secs); + elapsed_secs += 5; + if elapsed_secs >= #timeout_secs - 5 { + break; } - }); - - // Execute the proptest within async context - let proptest_result = tokio::task::spawn_blocking(move || { - // Create a new runtime for proptest execution - let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime for proptest"); - rt.block_on(async { - // Execute the test body within the runtime - #fn_body - }) - }).await; - - // Cancel progress task - if !progress_task.is_finished() { - progress_task.abort(); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; } + }); - match proptest_result { - Ok(result) => match result { - Ok(()) => Ok(()), - Err(e) => { - // Convert the non-Send error to anyhow error - Err(color_eyre::eyre::eyre!("Proptest failed: {}", e)) - } - }, - Err(e) => Err(color_eyre::eyre::eyre!(e)), - }? - }; - - // Show result (always visible) - let elapsed = start.elapsed(); - if result.is_ok() { - eprintln!("βœ… {} [proptest] ({:.1?})", test_name.replace('_', " "), elapsed); - } else { - eprintln!("❌ {} [proptest] ({:.1?})", test_name.replace('_', " "), elapsed); + let test_result = async { #fn_body }.await; + if !progress_task.is_finished() { + progress_task.abort(); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; } - - result + test_result + } else { + async { #fn_body }.await }; - // Apply timeout - tokio::time::timeout( - std::time::Duration::from_secs(#timeout_secs), - test_future - ).await - .map_err(|_| color_eyre::eyre::eyre!("Test timed out after {} seconds", #timeout_secs))? - } - } - } else { - // Regular database test using universal pool system with proper cleanup - quote! { - #(#test_attrs)* - #[tokio::test] - #fn_vis async fn #fn_name() -> color_eyre::eyre::Result<()> { - // Note: TestContext must be in scope - - // Wrap the entire test in a timeout - let test_future = async { - // Show test starting (always visible) - let test_name = stringify!(#fn_name); - let start = std::time::Instant::now(); - eprintln!("πŸ”„ {} [timeout: {}s]", test_name.replace('_', " "), #timeout_secs); - - // Create test context with test name - let ctx = TestContext::with_name(test_name).await?; - - // Run the test with progress tracking for long tests - let result: color_eyre::eyre::Result<()> = if #timeout_secs > 10 { - // For long tests, spawn a progress indicator - let progress_task = tokio::spawn(async { - let mut interval = tokio::time::interval(std::time::Duration::from_secs(5)); - interval.tick().await; // Skip first immediate tick - let mut elapsed_secs = 5; - loop { - interval.tick().await; - eprintln!(" ⏳ {} still running... ({}s elapsed)", test_name.replace('_', " "), elapsed_secs); - elapsed_secs += 5; - if elapsed_secs >= #timeout_secs - 5 { - break; - } - } - }); - - let test_result = async { #fn_body }.await; - // Gracefully cancel progress task to avoid abrupt shutdown - if !progress_task.is_finished() { - progress_task.abort(); - // Give a small grace period for cleanup - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } - test_result - } else { - async { #fn_body }.await - }; + let elapsed = start.elapsed(); + if result.is_ok() { + eprintln!("βœ… {} ({:.1?})", test_name.replace('_', " "), elapsed); + } else { + eprintln!("❌ {} ({:.1?})", test_name.replace('_', " "), elapsed); + } - // Show result (always visible) - let elapsed = start.elapsed(); - if result.is_ok() { - eprintln!("βœ… {} ({:.1?})", test_name.replace('_', " "), elapsed); - } else { - eprintln!("❌ {} ({:.1?})", test_name.replace('_', " "), elapsed); - } + sinex_test_utils::runlog::record_async( + test_name, + elapsed, + Some(&telemetry), + &result, + ) + .await; - result - }; + result + }; - // Apply timeout - tokio::time::timeout( - std::time::Duration::from_secs(#timeout_secs), - test_future - ).await - .map_err(|_| color_eyre::eyre::eyre!("Test timed out after {} seconds", #timeout_secs))? - } + tokio::time::timeout( + std::time::Duration::from_secs(#timeout_secs), + test_future + ) + .await + .map_err(|_| color_eyre::eyre::eyre!("Test timed out after {} seconds", #timeout_secs))? } } } else { @@ -569,6 +1189,14 @@ pub fn sinex_test(attr: TokenStream, item: TokenStream) -> TokenStream { eprintln!("❌ {} ({:.1?})", test_name.replace('_', " "), elapsed); } + sinex_test_utils::runlog::record_async( + test_name, + elapsed, + None, + &result, + ) + .await; + result } } @@ -577,6 +1205,31 @@ pub fn sinex_test(attr: TokenStream, item: TokenStream) -> TokenStream { output.into() } +#[derive(Clone, Copy)] +enum BenchMode { + Auto, + Integration, + Micro, +} + +fn parse_bench_mode(lit: &Lit) -> syn::Result { + if let Lit::Str(mode) = lit { + match mode.value().to_lowercase().as_str() { + "integration" => Ok(BenchMode::Integration), + "micro" => Ok(BenchMode::Micro), + other => Err(syn::Error::new( + lit.span(), + format!("unknown bench mode '{}'", other), + )), + } + } else { + Err(syn::Error::new( + lit.span(), + "mode must be a string literal (\"micro\" or \"integration\")", + )) + } +} + #[proc_macro_attribute] pub fn sinex_bench(attr: TokenStream, item: TokenStream) -> TokenStream { // When building tests (not benchmarks), just remove the function entirely @@ -586,18 +1239,47 @@ pub fn sinex_bench(attr: TokenStream, item: TokenStream) -> TokenStream { let input = parse_macro_input!(item as ItemFn); let fn_name = &input.sig.ident; - // Parse optional args parameter - let args = if !attr.is_empty() { - // Parse args = [values...] syntax - let attr_str = attr.to_string(); - if attr_str.starts_with("args") && attr_str.contains('[') { - Some(proc_macro2::TokenStream::from(attr)) - } else { - None + let attr_tokens = proc_macro2::TokenStream::from(attr); + let mut bench_mode = BenchMode::Auto; + let mut args_tokens: Option = None; + + if !attr_tokens.is_empty() { + let parsed = match Punctuated::::parse_terminated.parse2(attr_tokens) { + Ok(list) => list, + Err(err) => return err.to_compile_error().into(), + }; + + for meta in parsed { + match meta { + Meta::NameValue(nv) if nv.path.is_ident("mode") => { + if let Expr::Lit(expr_lit) = nv.value { + bench_mode = match parse_bench_mode(&expr_lit.lit) { + Ok(mode) => mode, + Err(err) => return err.to_compile_error().into(), + }; + } else { + return syn::Error::new_spanned( + nv.value, + "mode attribute expects a string literal", + ) + .to_compile_error() + .into(); + } + } + Meta::NameValue(nv) if nv.path.is_ident("args") => { + args_tokens = Some(nv.value.into_token_stream()); + } + other => { + return syn::Error::new_spanned( + other, + "supported attributes: mode = \"micro\"|\"integration\", args = [...]", + ) + .to_compile_error() + .into(); + } + } } - } else { - None - }; + } // Remove async validation - benchmarks should be synchronous since the macro handles async internally @@ -625,104 +1307,139 @@ pub fn sinex_bench(attr: TokenStream, item: TokenStream) -> TokenStream { let fn_vis = &input.vis; let fn_body = &input.block; - let output = if takes_context && takes_args { - // Database benchmark with context and args - if let Some(args_tokens) = args { - quote! { - #[cfg(feature = "bench")] - #[divan::bench(#args_tokens)] - #fn_vis fn #fn_name(bencher: divan::Bencher, arg: #arg_type) { - use sinex_test_utils::bench::BENCH_CONTEXT; - let ctx = &*BENCH_CONTEXT; - - bencher.bench_local(|| { - ctx.runtime.block_on(async { - // The function body contains .await calls, so we wrap it in an async block - let result: color_eyre::eyre::Result<()> = async { - let ctx = ctx; - let arg = arg; - #fn_body - }.await; - result.unwrap() - }) - }); - } + let resolved_mode = match bench_mode { + BenchMode::Auto => { + if takes_context { + BenchMode::Integration + } else { + BenchMode::Micro } - } else { - return syn::Error::new_spanned( - fn_name, - "Parameterized benchmarks require args attribute", - ) - .to_compile_error() - .into(); } - } else if takes_context { - // Database benchmark with only context - quote! { - #[cfg(feature = "bench")] - #[divan::bench] - #fn_vis fn #fn_name(bencher: divan::Bencher) { - use sinex_test_utils::bench::BENCH_CONTEXT; - let ctx = &*BENCH_CONTEXT; - - bencher.bench_local(|| { - ctx.runtime.block_on(async { - // The function body contains .await calls, so we wrap it in an async block - let result: color_eyre::eyre::Result<()> = async { - let ctx = ctx; - #fn_body - }.await; - result.unwrap() - }) - }); + other => other, + }; + + let output = match resolved_mode { + BenchMode::Integration => { + if !takes_context { + return syn::Error::new_spanned( + fn_name, + "BenchContext parameter required for integration benchmarks", + ) + .to_compile_error() + .into(); } - } - } else if takes_args { - // Simple benchmark with args - if let Some(args_tokens) = args { - quote! { - #[cfg(feature = "bench")] - #[divan::bench(#args_tokens)] - #fn_vis fn #fn_name(bencher: divan::Bencher, arg: #arg_type) { - let runtime = tokio::runtime::Runtime::new().unwrap(); - - bencher.bench_local(|| { - runtime.block_on(async { - let result: color_eyre::eyre::Result<()> = async { - let arg = arg; - #fn_body - }.await; - result.unwrap() - }) - }); + + if takes_args { + let args_tokens = match args_tokens { + Some(tokens) => tokens, + None => { + return syn::Error::new_spanned( + fn_name, + "integration benchmarks with args require `args = [...]`", + ) + .to_compile_error() + .into(); + } + }; + quote! { + #[cfg(feature = "bench")] + #[divan::bench(#args_tokens)] + #fn_vis fn #fn_name(bencher: divan::Bencher, arg: #arg_type) { + use sinex_test_utils::bench::BENCH_CONTEXT; + let ctx = &*BENCH_CONTEXT; + + bencher.bench_local(|| { + ctx.runtime.block_on(async { + let result: color_eyre::eyre::Result<()> = async { + let ctx = ctx; + let arg = arg; + #fn_body + }.await; + result.unwrap() + }) + }); + } + } + } else { + quote! { + #[cfg(feature = "bench")] + #[divan::bench] + #fn_vis fn #fn_name(bencher: divan::Bencher) { + use sinex_test_utils::bench::BENCH_CONTEXT; + let ctx = &*BENCH_CONTEXT; + + bencher.bench_local(|| { + ctx.runtime.block_on(async { + let result: color_eyre::eyre::Result<()> = async { + let ctx = ctx; + #fn_body + }.await; + result.unwrap() + }) + }); + } } } - } else { - return syn::Error::new_spanned( - fn_name, - "Parameterized benchmarks require args attribute", - ) - .to_compile_error() - .into(); } - } else { - // Simple benchmark without context or args - quote! { - #[cfg(feature = "bench")] - #[divan::bench] - #fn_vis fn #fn_name(bencher: divan::Bencher) { - let runtime = tokio::runtime::Runtime::new().unwrap(); - - bencher.bench_local(|| { - runtime.block_on(async { - let result: color_eyre::eyre::Result<()> = async { - #fn_body - }.await; - result.unwrap() - }) - }); + BenchMode::Micro => { + if takes_context { + return syn::Error::new_spanned( + fn_name, + "BenchContext is not available in micro benchmarks (use `mode = \"integration\"`)", + ) + .to_compile_error() + .into(); + } + + if takes_args { + let args_tokens = match args_tokens { + Some(tokens) => tokens, + None => { + return syn::Error::new_spanned( + fn_name, + "micro benchmarks with args require `args = [...]`", + ) + .to_compile_error() + .into(); + } + }; + quote! { + #[cfg(feature = "bench")] + #[divan::bench(#args_tokens)] + #fn_vis fn #fn_name(bencher: divan::Bencher, arg: #arg_type) { + let runtime = tokio::runtime::Runtime::new().unwrap(); + + bencher.bench_local(|| { + runtime.block_on(async { + let result: color_eyre::eyre::Result<()> = async { + let arg = arg; + #fn_body + }.await; + result.unwrap() + }) + }); + } + } + } else { + quote! { + #[cfg(feature = "bench")] + #[divan::bench] + #fn_vis fn #fn_name(bencher: divan::Bencher) { + let runtime = tokio::runtime::Runtime::new().unwrap(); + + bencher.bench_local(|| { + runtime.block_on(async { + let result: color_eyre::eyre::Result<()> = async { + #fn_body + }.await; + result.unwrap() + }) + }); + } + } } } + BenchMode::Auto => unreachable!("bench mode should be fully resolved"), }; output.into() diff --git a/crate/lib/sinex-test-utils/src/database_pool.rs b/crate/lib/sinex-test-utils/src/database_pool.rs index cb1a203e75..d7565a3b5a 100644 --- a/crate/lib/sinex-test-utils/src/database_pool.rs +++ b/crate/lib/sinex-test-utils/src/database_pool.rs @@ -89,7 +89,7 @@ impl PoolMetrics { } /// Pool statistics for monitoring -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize)] pub struct PoolStats { pub total_acquisitions: usize, pub average_wait_time_ms: u64, diff --git a/crate/lib/sinex-test-utils/src/lib.rs b/crate/lib/sinex-test-utils/src/lib.rs index 0f2cfada0d..31c927d786 100644 --- a/crate/lib/sinex-test-utils/src/lib.rs +++ b/crate/lib/sinex-test-utils/src/lib.rs @@ -13,7 +13,7 @@ extern crate self as sinex_test_utils; // Re-export the procedural macros from internal macros crate #[cfg(feature = "bench")] pub use sinex_test_utils_macros::sinex_bench; -pub use sinex_test_utils_macros::sinex_test; +pub use sinex_test_utils_macros::{sinex_prop, sinex_proptest, sinex_test}; // Re-export anyhow for test ergonomics pub use color_eyre::eyre::{anyhow, bail, ensure, Context}; @@ -62,6 +62,7 @@ mod nats; mod path_validation; mod property_testing; pub mod resources; +pub mod runlog; mod satellite_management_utils; pub mod satellite_runtime; pub mod snapshot_helper; @@ -88,9 +89,9 @@ pub mod static_fixtures; // Create prelude module from common/mod.rs pub mod prelude { // Core test infrastructure - pub use crate::sinex_test; pub use crate::TestContext; pub use crate::TestResult; + pub use crate::{sinex_prop, sinex_proptest, sinex_test}; pub use color_eyre::eyre::{bail, ensure, Context, Result}; // Modern test infrastructure - fully integrated diff --git a/crate/lib/sinex-test-utils/src/property_testing.rs b/crate/lib/sinex-test-utils/src/property_testing.rs index 349af99d82..b21de41bc2 100644 --- a/crate/lib/sinex-test-utils/src/property_testing.rs +++ b/crate/lib/sinex-test-utils/src/property_testing.rs @@ -3,15 +3,16 @@ // Provides property-based testing capabilities that integrate seamlessly // with the unified test infrastructure and event builders. -use crate::prelude::*; -use crate::Result; -use crate::TestResult; +use once_cell::sync::Lazy; use proptest::prelude::*; -use proptest::strategy::ValueTree; use proptest::strategy::{BoxedStrategy, Strategy}; +use proptest::test_runner::{FileFailurePersistence, RngAlgorithm, RngSeed, TestRunner}; use serde_json::{json, Value}; -use sinex_core::types::error::SinexError; -use sinex_core::DbPoolExt; +use std::env; +use std::fs; +use std::path::PathBuf; +use std::sync::OnceLock; +use tracing::warn; /// Property test strategies for common Sinex types pub struct SinexStrategies; @@ -194,550 +195,137 @@ impl SinexStrategies { } } -/// Property test runner that integrates with TestContext -pub struct PropertyTester<'ctx> { - ctx: &'ctx TestContext, - runner: proptest::test_runner::TestRunner, +/// Harness overrides provided by procedural macros. +#[derive(Debug, Default, Clone, Copy)] +pub struct RunnerOverrides { + pub cases: Option, + pub seed: Option, + pub max_shrink_time_ms: Option, } -impl<'ctx> PropertyTester<'ctx> { - pub fn new(ctx: &'ctx TestContext) -> Self { - Self { - ctx, - runner: proptest::test_runner::TestRunner::deterministic(), - } - } +/// Build a [`TestRunner`] honoring env overrides plus harness settings. +pub fn make_runner(overrides: RunnerOverrides) -> TestRunner { + static DEFAULT_DIR: Lazy = Lazy::new(|| { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_root = manifest_dir + .parent() + .and_then(|p| p.parent()) + .and_then(|p| p.parent()) + .map(PathBuf::from) + .unwrap_or(manifest_dir); - /// Run property test with custom strategy - /// - /// Note: Due to Rust lifetime limitations with async closures, the property function - /// must be written carefully. Use the pattern shown in the examples. - pub async fn test_property( - &mut self, - strategy: S, - test_cases: u32, - property: F, - ) -> TestResult<()> - where - S: Strategy, - F: Fn(&TestContext, T) -> Fut, - Fut: std::future::Future> + 'ctx, - T: 'ctx, - { - for case_num in 0..test_cases { - let tree = strategy.new_tree(&mut self.runner).map_err(|e| { - SinexError::unknown(format!("Strategy tree generation failed: {e:?}")) - })?; - let value = tree.current(); - - property(self.ctx, value).await.map_err(|e| { - SinexError::validation(format!("Property test case {case_num} failed: {e}")) - })?; - } + let target_dir = env::var("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| workspace_root.join("target")); - Ok(()) - } + target_dir.join("proptest-regressions") + }); - /// Test event creation property - pub async fn test_event_creation_property(&mut self, test_cases: u32) -> TestResult<()> { - for case_num in 0..test_cases { - let tree = SinexStrategies::any_event() - .new_tree(&mut self.runner) - .map_err(|e| { - SinexError::unknown(format!("Strategy tree generation failed: {e:?}")) - })?; - let (source, event_type, payload) = tree.current(); - - // Property: All valid events should be creatable and insertable - let event = self - .ctx - .create_test_event(&source, &event_type, payload) - .await - .map_err(|e| { - SinexError::validation(format!("Property test case {case_num} failed: {e}")) - })?; - - // Verify basic properties - assert_eq!(event.source.as_str(), source); - assert_eq!(event.event_type.as_str(), event_type); - assert!(event.id.is_some()); // Should have an ID after insertion - } + let mut config = proptest::test_runner::Config::default(); + config.cases = overrides.cases.or_else(env_cases).unwrap_or(256).max(1); - Ok(()) + if let Some(seed) = overrides.seed.or_else(env_seed) { + config.rng_algorithm = RngAlgorithm::ChaCha; + config.rng_seed = RngSeed::Fixed(seed); } - /// Test event querying property - pub async fn test_event_querying_property(&mut self, test_cases: u32) -> TestResult<()> { - for case_num in 0..test_cases { - let tree = SinexStrategies::any_event() - .new_tree(&mut self.runner) - .map_err(|e| { - SinexError::unknown(format!("Strategy tree generation failed: {e:?}")) - })?; - let (source, event_type, payload) = tree.current(); - - // Property: Inserted events should be retrievable - let event = self - .ctx - .create_test_event(&source, &event_type, payload) - .await - .map_err(|e| { - SinexError::validation(format!("Property test case {case_num} failed: {e}")) - })?; - - // Should be findable by ID - if let Some(event_id) = &event.id { - let by_id = self.ctx.pool.events().get_by_id(event_id.clone()).await?; - assert!(by_id.is_some()); - - // Should be findable by source - let source_ref = sinex_core::EventSource::from(source.as_str()); - let by_source = self - .ctx - .pool - .events() - .get_by_source( - &source_ref, - sinex_core::types::Pagination::new(Some(10), None), - ) - .await?; - assert!(by_source.iter().any(|e| e.id.as_ref() == Some(event_id))); - } - - // Should be findable by type - let type_ref = sinex_core::EventType::from(event_type.as_str()); - let by_type = self - .ctx - .pool - .events() - .get_by_event_type( - &type_ref, - sinex_core::types::Pagination::new(Some(10), None), - ) - .await?; - assert!(by_type.iter().any(|e| e.id == event.id)); - } - - Ok(()) + if let Some(ms) = overrides.max_shrink_time_ms.or_else(env_max_shrink_time_ms) { + let clamped = ms.min(u64::from(u32::MAX)) as u32; + config.max_shrink_time = clamped.max(1); } - /// Test malicious input handling - pub async fn test_malicious_input_rejection(&mut self, test_cases: u32) -> TestResult<()> { - for _case_num in 0..test_cases { - let tree = SinexStrategies::malicious_payload() - .new_tree(&mut self.runner) - .map_err(|e| { - SinexError::unknown(format!("Strategy tree generation failed: {e:?}")) - })?; - let malicious_payload = tree.current(); - - // Property: Malicious payloads should be rejected or sanitized - let result = self - .ctx - .create_test_event("security_test", "malicious.input", malicious_payload) - .await; - - // Either the event is rejected (preferred) or it's sanitized - match result { - Ok(event) => { - // If accepted, verify it doesn't contain dangerous patterns - let payload_str = event.payload.to_string(); - assert!(!payload_str.contains("DROP TABLE")); - assert!(!payload_str.contains("