From c93f01d176a05b1eae2866ab5c467a821dc2c897 Mon Sep 17 00:00:00 2001 From: EneGab <157655503+EneGab@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:06:53 +0100 Subject: [PATCH] test: add assert_event helper for contract event field assertions (#559) Add a shared test assertion helper `assert_event` to reduce boilerplate when verifying emitted contract events in integration tests. - Implement `assert_event(env, contract_id, topic, expected_fields)` in `creator-keys/tests/contract_test_env/mod.rs` to filter events by both contract address and primary topic (index 0). - Deserialize event payloads into generic types and compare them against expected fields, providing clear panic messages showing the topic, expected fields, and actual emitted values on mismatch (or reporting when no matching event was found). - Refactor existing event test cases in `contract_initialization_event.rs` and `creator_registered_event_fields.rs` to replace manual log extraction with `assert_event`. - Add unit tests for the helper in `creator-keys/tests/assert_event.rs` verifying exact match, topic filtering, contract filtering, field mismatch messages, and empty log behavior. --- creator-keys/tests/assert_event.rs | 223 ++++++++++++++++++ .../tests/contract_initialization_event.rs | 44 ++-- creator-keys/tests/contract_test_env/mod.rs | 54 ++++- .../tests/creator_registered_event_fields.rs | 37 ++- 4 files changed, 305 insertions(+), 53 deletions(-) create mode 100644 creator-keys/tests/assert_event.rs diff --git a/creator-keys/tests/assert_event.rs b/creator-keys/tests/assert_event.rs new file mode 100644 index 00000000..bb371c94 --- /dev/null +++ b/creator-keys/tests/assert_event.rs @@ -0,0 +1,223 @@ +//! Unit tests for the `assert_event` test helper. +//! +//! Confirms the helper: +//! - Passes silently when contract ID, topic, and all fields match. +//! - Panics when the topic does not match (filters by topic). +//! - Panics when the contract ID does not match (filters by contract). +//! - Panics when a field value mismatches, displaying the topic, expected, and actual fields. +//! - Panics when no events have been emitted at all. + +mod contract_test_env; + +use contract_test_env::{assert_event, register_creator_keys, test_env_with_auths}; +use creator_keys::events; +use soroban_sdk::{ + symbol_short, + testutils::{Address as _, Events, Ledger}, + Address, Env, +}; +use std::panic; + +fn setup_initialized_contract( + env: &Env, +) -> (Address, Address, Address, events::ContractInitializedEvent) { + let (client, contract_id) = register_creator_keys(env); + let admin = Address::generate(env); + let recipient = Address::generate(env); + client.set_protocol_admin(&admin, &admin); + client.set_protocol_fee_recipient(&admin, &recipient); + + let test_ledger = 42u32; + let mut ledger_info = env.ledger().get(); + ledger_info.sequence_number = test_ledger; + env.ledger().set(ledger_info); + + env.events().all(); + client.set_fee_config(&admin, &9000u32, &1000u32); + + let expected = events::ContractInitializedEvent { + admin, + protocol_fee_bps: 1000u32, + protocol_fee_recipient: recipient, + initialized_at_ledger: test_ledger, + }; + + ( + contract_id, + expected.admin.clone(), + expected.protocol_fee_recipient.clone(), + expected, + ) +} + +#[test] +fn test_assert_event_matching_succeeds() { + let env = test_env_with_auths(); + let (contract_id, _, _, expected) = setup_initialized_contract(&env); + + assert_event( + &env, + &contract_id, + events::CONTRACT_INITIALIZED_EVENT_NAME, + &expected, + ); +} + +#[test] +fn test_assert_event_filters_out_wrong_topic() { + let env = test_env_with_auths(); + let (contract_id, _, _, expected) = setup_initialized_contract(&env); + + let wrong_topic = symbol_short!("wrong_top"); + + let result = panic::catch_unwind(panic::AssertUnwindSafe(|| { + assert_event(&env, &contract_id, wrong_topic, &expected); + })); + + assert!( + result.is_err(), + "assert_event should panic when topic does not match" + ); + let err = result.unwrap_err(); + let msg = err + .downcast_ref::() + .cloned() + .or_else(|| { + err.downcast_ref::<&str>() + .map(|s| std::string::String::from(*s)) + }) + .unwrap_or_default(); + + assert!( + msg.contains("no matching event found"), + "panic message should state that no matching event was found: {msg}" + ); + assert!( + msg.contains("wrong_top"), + "panic message should mention the queried topic: {msg}" + ); +} + +#[test] +fn test_assert_event_filters_out_wrong_contract() { + let env = test_env_with_auths(); + let (_contract_id, _, _, expected) = setup_initialized_contract(&env); + let wrong_contract_id = Address::generate(&env); + + let result = panic::catch_unwind(panic::AssertUnwindSafe(|| { + assert_event( + &env, + &wrong_contract_id, + events::CONTRACT_INITIALIZED_EVENT_NAME, + &expected, + ); + })); + + assert!( + result.is_err(), + "assert_event should panic when contract_id does not match" + ); + let err = result.unwrap_err(); + let msg = err + .downcast_ref::() + .cloned() + .or_else(|| { + err.downcast_ref::<&str>() + .map(|s| std::string::String::from(*s)) + }) + .unwrap_or_default(); + + assert!( + msg.contains("no matching event found"), + "panic message should state that no matching event was found: {msg}" + ); +} + +#[test] +fn test_assert_event_fails_on_field_mismatch() { + let env = test_env_with_auths(); + let (contract_id, _, _, mut expected) = setup_initialized_contract(&env); + + // Tamper with one field + expected.protocol_fee_bps = 9999u32; + + let result = panic::catch_unwind(panic::AssertUnwindSafe(|| { + assert_event( + &env, + &contract_id, + events::CONTRACT_INITIALIZED_EVENT_NAME, + &expected, + ); + })); + + assert!( + result.is_err(), + "assert_event should panic on field value mismatch" + ); + let err = result.unwrap_err(); + let msg = err + .downcast_ref::() + .cloned() + .or_else(|| { + err.downcast_ref::<&str>() + .map(|s| std::string::String::from(*s)) + }) + .unwrap_or_default(); + + assert!( + msg.contains("event field mismatch"), + "panic message should indicate field mismatch: {msg}" + ); + assert!( + msg.contains("init"), + "panic message should include the topic: {msg}" + ); + assert!( + msg.contains("9999"), + "panic message should include expected fields with the tampered value: {msg}" + ); + assert!( + msg.contains("1000"), + "panic message should include actual fields with the real value: {msg}" + ); +} + +#[test] +fn test_assert_event_panics_when_no_events_at_all() { + let env = test_env_with_auths(); + let contract_id = Address::generate(&env); + let expected = events::ContractInitializedEvent { + admin: Address::generate(&env), + protocol_fee_bps: 1000u32, + protocol_fee_recipient: Address::generate(&env), + initialized_at_ledger: 1, + }; + + let result = panic::catch_unwind(panic::AssertUnwindSafe(|| { + assert_event( + &env, + &contract_id, + events::CONTRACT_INITIALIZED_EVENT_NAME, + &expected, + ); + })); + + assert!( + result.is_err(), + "assert_event should panic when no events exist" + ); + let err = result.unwrap_err(); + let msg = err + .downcast_ref::() + .cloned() + .or_else(|| { + err.downcast_ref::<&str>() + .map(|s| std::string::String::from(*s)) + }) + .unwrap_or_default(); + + assert!( + msg.contains("no matching event found"), + "panic message should state that no matching event was found: {msg}" + ); +} diff --git a/creator-keys/tests/contract_initialization_event.rs b/creator-keys/tests/contract_initialization_event.rs index 641e70fa..6b23b51b 100644 --- a/creator-keys/tests/contract_initialization_event.rs +++ b/creator-keys/tests/contract_initialization_event.rs @@ -6,7 +6,7 @@ mod contract_test_env; -use contract_test_env::{register_creator_keys, test_env_with_auths}; +use contract_test_env::{assert_event, register_creator_keys, test_env_with_auths}; use creator_keys::events; use soroban_sdk::{ testutils::{Address as _, Events, Ledger}, @@ -16,7 +16,7 @@ use soroban_sdk::{ #[test] fn test_initialization_event_emitted_on_first_set_fee_config() { let env = test_env_with_auths(); - let (client, _) = register_creator_keys(&env); + let (client, contract_id) = register_creator_keys(&env); let admin = Address::generate(&env); let recipient = Address::generate(&env); @@ -31,34 +31,18 @@ fn test_initialization_event_emitted_on_first_set_fee_config() { env.events().all(); client.set_fee_config(&admin, &9000u32, &1000u32); - let event_log = env.events().all(); - let (_, _, data) = event_log - .iter() - .find(|(_, topics, _)| { - topics - .get(events::TOPIC_EVENT_NAME_INDEX) - .map(|v| { - let name: Symbol = v.into_val(&env); - name == events::CONTRACT_INITIALIZED_EVENT_NAME - }) - .unwrap_or(false) - }) - .expect("initialization event must be emitted on first set_fee_config"); - - let payload: events::ContractInitializedEvent = data.into_val(&env); - - assert_eq!(payload.admin, admin, "admin field must match caller"); - assert_eq!( - payload.protocol_fee_bps, 1000u32, - "protocol_fee_bps must match" - ); - assert_eq!( - payload.protocol_fee_recipient, recipient, - "protocol_fee_recipient must match stored recipient" - ); - assert_eq!( - payload.initialized_at_ledger, test_ledger, - "initialized_at_ledger must match current ledger" + let expected = events::ContractInitializedEvent { + admin, + protocol_fee_bps: 1000u32, + protocol_fee_recipient: recipient, + initialized_at_ledger: test_ledger, + }; + + assert_event( + &env, + &contract_id, + events::CONTRACT_INITIALIZED_EVENT_NAME, + &expected, ); } diff --git a/creator-keys/tests/contract_test_env/mod.rs b/creator-keys/tests/contract_test_env/mod.rs index 3160fe82..b8f68424 100644 --- a/creator-keys/tests/contract_test_env/mod.rs +++ b/creator-keys/tests/contract_test_env/mod.rs @@ -12,8 +12,8 @@ use creator_keys::{constants, CreatorKeysContract, CreatorKeysContractClient}; use soroban_sdk::{ - testutils::{Address as _, Ledger}, - Address, Env, IntoVal, String, Val, + testutils::{Address as _, Events, Ledger}, + Address, Env, IntoVal, String, Symbol, TryFromVal, Val, }; use std::string::String as StdString; @@ -189,6 +189,56 @@ where ); } +/// Asserts that a contract event matching `contract_id` and `topic` was emitted, +/// and that its payload fields match `expected_fields`. +/// +/// Filters the environment's event log for events emitted by `contract_id` where the +/// canonical event topic (index 0) equals `topic`. If no matching event is found, or if +/// the event payload does not equal `expected_fields`, panics with a detailed, descriptive message. +/// +/// # Panics +/// - If no event from `contract_id` with `topic` is found in the log. +/// - If the event payload cannot be deserialized into `T`. +/// - If the deserialized payload does not equal `expected_fields`. +pub fn assert_event(env: &Env, contract_id: &Address, topic: Symbol, expected_fields: &T) +where + T: TryFromVal + PartialEq + core::fmt::Debug, +{ + let matching_event = env.events().all().iter().rev().find(|(c, topics, _)| { + c == contract_id + && topics + .get(0) + .and_then(|v| Symbol::try_from_val(env, &v).ok()) + .map(|s| s == topic) + .unwrap_or(false) + }); + + let (_, _, data) = match matching_event { + Some(e) => e, + None => panic!( + "assert_event failed: no matching event found for contract {:?} and topic {:?}.\nExpected fields: {:#?}", + contract_id, topic, expected_fields + ), + }; + + let actual: T = match T::try_from_val(env, &data) { + Ok(val) => val, + Err(_) => panic!( + "assert_event failed: could not deserialize event data for contract {:?} and topic {:?}.\nExpected fields: {:#?}", + contract_id, topic, expected_fields + ), + }; + + assert_eq!( + &actual, + expected_fields, + "assert_event failed: event field mismatch for topic {:?}.\nExpected: {:#?}\nActual: {:#?}", + topic, + expected_fields, + actual + ); +} + fn account_strkey_from_seed(seed: &str) -> StdString { let mut payload = [0u8; 32]; let mut state = 0xcbf2_9ce4_8422_2325u64; diff --git a/creator-keys/tests/creator_registered_event_fields.rs b/creator-keys/tests/creator_registered_event_fields.rs index 5d411051..b48e5a48 100644 --- a/creator-keys/tests/creator_registered_event_fields.rs +++ b/creator-keys/tests/creator_registered_event_fields.rs @@ -10,7 +10,9 @@ mod contract_test_env; -use contract_test_env::{register_creator_keys, set_ledger_sequence, test_env_with_auths}; +use contract_test_env::{ + assert_event, register_creator_keys, set_ledger_sequence, test_env_with_auths, +}; use creator_keys::events; use soroban_sdk::{ testutils::{Address as _, Events}, @@ -158,7 +160,7 @@ fn test_creator_registered_event_registered_at_ledger_matches_current_ledger() { #[test] fn test_creator_registered_event_all_four_fields_present_in_one_registration() { let env = test_env_with_auths(); - let (client, _) = register_creator_keys(&env); + let (client, contract_id) = register_creator_keys(&env); let ledger_seq: u32 = 100; set_ledger_sequence(&env, ledger_seq); @@ -167,25 +169,18 @@ fn test_creator_registered_event_all_four_fields_present_in_one_registration() { let handle_str = "frank"; register_creator(&client, &env, &creator, handle_str); - let payload = last_registration_event(&env); - - assert_eq!( - payload.creator, creator, - "creator (creator_wallet) field mismatch" - ); - assert_eq!( - payload.handle, - String::from_str(&env, handle_str), - "handle (display_name) field mismatch" - ); - assert_eq!( - payload.fee_recipient, creator, - "fee_recipient field mismatch — must default to creator wallet" - ); - assert_eq!( - payload.registered_at_ledger, ledger_seq, - "registered_at_ledger field mismatch" - ); + let expected = events::CreatorRegisteredEvent { + creator: creator.clone(), + handle: String::from_str(&env, handle_str), + supply: 0, + holder_count: 0, + creator_bps: 0, + protocol_bps: 0, + fee_recipient: creator, + registered_at_ledger: ledger_seq, + }; + + assert_event(&env, &contract_id, events::REGISTER_EVENT_NAME, &expected); } // ── AC2: no event emitted on failed registration (duplicate wallet) ───────────