From c1bbf1c9069ebb681445429f3a4dff4dda9a513d Mon Sep 17 00:00:00 2001 From: Markodiba Date: Sun, 28 Jun 2026 15:52:15 +0100 Subject: [PATCH] feat(invoice): emit EscrowReleasedEvent with minimal payload on release_escrow Define a dedicated EscrowReleasedEvent contracttype with only the fields indexers need (id, merchant, amount_usdc, released_at) and update escrow_released() to publish it instead of the full Invoice struct. Leaner payloads reduce Soroban event storage costs and make deserialization simpler for consumers that should not need to import all contract types. Also add abis/invoice.json snapshot to the workspace root (required by the existing ABI snapshot test), and repair pre-existing test file corruption in invoice_test.rs (interleaved function bodies, duplicate old-API blocks) that prevented the suite from compiling. Closes #[issue_id] --- abis/invoice.json | 29 + contracts/invoice/src/events.rs | 19 +- contracts/invoice/src/lib.rs | 1 + contracts/invoice/tests/invoice_test.rs | 697 +++--------------------- 4 files changed, 115 insertions(+), 631 deletions(-) create mode 100644 abis/invoice.json diff --git a/abis/invoice.json b/abis/invoice.json new file mode 100644 index 0000000..a95e449 --- /dev/null +++ b/abis/invoice.json @@ -0,0 +1,29 @@ +{ + "contract": "invoice", + "version": "1.1.0", + "functions": [ + "initialize", + "create_invoice", + "mark_paid", + "get_invoice", + "get_invoice_status", + "cancel_invoice", + "request_refund", + "batch_expire", + "pause", + "unpause", + "set_grace_window", + "get_grace_window", + "release_escrow" + ], + "events": [ + "invoice_created", + "invoice_paid", + "invoice_expired", + "invoice_cancelled", + "invoice_refund_req", + "escrow_released", + "contract_paused", + "contract_unpaused" + ] +} diff --git a/contracts/invoice/src/events.rs b/contracts/invoice/src/events.rs index da4bfad..0e239bd 100644 --- a/contracts/invoice/src/events.rs +++ b/contracts/invoice/src/events.rs @@ -14,7 +14,16 @@ // - Optional types (Option) serialize to null or value use crate::invoice::Invoice; -use soroban_sdk::{Address, Env, Symbol}; +use soroban_sdk::{contracttype, Address, Env, Symbol}; + +#[contracttype] +#[derive(Clone)] +pub struct EscrowReleasedEvent { + pub id: u64, + pub merchant: Address, + pub amount_usdc: i128, + pub released_at: u64, +} pub fn invoice_created(env: &Env, id: u64, invoice: &Invoice) { env.events() @@ -44,8 +53,14 @@ pub fn invoice_refund_requested(env: &Env, id: u64, invoice: &Invoice) { } pub fn escrow_released(env: &Env, id: u64, invoice: &Invoice) { + let payload = EscrowReleasedEvent { + id, + merchant: invoice.merchant.clone(), + amount_usdc: invoice.amount_usdc, + released_at: env.ledger().timestamp(), + }; env.events() - .publish((Symbol::new(env, "escrow_released"), id), invoice.clone()); + .publish((Symbol::new(env, "escrow_released"), id), payload); } pub fn contract_paused(env: &Env, admin: &Address) { diff --git a/contracts/invoice/src/lib.rs b/contracts/invoice/src/lib.rs index 5646c49..bc354d9 100644 --- a/contracts/invoice/src/lib.rs +++ b/contracts/invoice/src/lib.rs @@ -4,6 +4,7 @@ mod events; mod invoice; mod validation; +pub use events::EscrowReleasedEvent; pub use invoice::{DataKey, Invoice, InvoiceError, InvoiceStatus, MaybeAddress, MaybeBytes}; use soroban_sdk::{contract, contractimpl, Address, Env, Vec}; diff --git a/contracts/invoice/tests/invoice_test.rs b/contracts/invoice/tests/invoice_test.rs index aaae3e8..71a9ff9 100644 --- a/contracts/invoice/tests/invoice_test.rs +++ b/contracts/invoice/tests/invoice_test.rs @@ -1,9 +1,10 @@ use invoice::{ - InvoiceContract, InvoiceContractClient, InvoiceError, InvoiceStatus, MaybeAddress, MaybeBytes, + EscrowReleasedEvent, InvoiceContract, InvoiceContractClient, InvoiceError, InvoiceStatus, + MaybeAddress, MaybeBytes, }; use soroban_sdk::{ - testutils::{Address as _, Ledger}, - Address, Env, + testutils::{Address as _, Events, Ledger}, + Address, Env, Symbol, TryFromVal, }; extern crate std; @@ -448,6 +449,20 @@ fn test_release_escrow_transitions_paid_to_released() { let (env, admin, client) = setup(); let merchant = Address::generate(&env); let payer = Address::generate(&env); + let id = client.create_invoice( + &merchant, + &10_000_000, + &10_250_000, + &3600, + &MaybeBytes::None, + &MaybeBytes::None, + &0, + ); + client.mark_paid(&admin, &id, &payer); + client.release_escrow(&admin, &id); + assert_eq!(client.get_invoice(&id).status, InvoiceStatus::Released); +} + #[test] fn test_cancel_invoice_transitions_to_cancelled() { let (env, _admin, client) = setup(); @@ -459,14 +474,13 @@ fn test_cancel_invoice_transitions_to_cancelled() { &3600, &MaybeBytes::None, &MaybeBytes::None, + &0, ); - client.cancel_invoice(&merchant, &invoice_id); - let invoice = client.get_invoice(&invoice_id); assert_eq!(invoice.status, InvoiceStatus::Cancelled); assert_eq!( - client.get_invoice_status(&invoice_id).unwrap(), + client.get_invoice_status(&invoice_id), InvoiceStatus::Cancelled ); } @@ -483,15 +497,14 @@ fn test_cancelled_invoice_cannot_be_marked_paid() { &3600, &MaybeBytes::None, &MaybeBytes::None, + &0, ); - client.cancel_invoice(&merchant, &invoice_id); let err = client .try_mark_paid(&admin, &invoice_id, &payer) .unwrap_err() .unwrap(); assert_eq!(err, InvoiceError::NotPending); - let invoice = client.get_invoice(&invoice_id); assert_eq!(invoice.status, InvoiceStatus::Cancelled); } @@ -510,9 +523,13 @@ fn test_cancel_invoice_unauthorized_rejected() { &MaybeBytes::None, &0, ); - client.mark_paid(&admin, &id, &payer); - client.release_escrow(&admin, &id); - assert_eq!(client.get_invoice(&id).status, InvoiceStatus::Released); + let err = client + .try_cancel_invoice(&unauthorized, &id) + .unwrap_err() + .unwrap(); + assert_eq!(err, InvoiceError::Unauthorized); + let invoice = client.get_invoice(&id); + assert_eq!(invoice.status, InvoiceStatus::Pending); } #[test] @@ -551,17 +568,45 @@ fn test_release_escrow_requires_admin() { assert!(client.try_release_escrow(&rogue, &id).is_err()); } -// ABI snapshot comparison +#[test] +fn test_escrow_released_event_emits_minimal_payload() { + let (env, admin, client) = setup(); + let merchant = Address::generate(&env); + let payer = Address::generate(&env); + let id = client.create_invoice( + &merchant, + &10_000_000, + &10_250_000, + &3600, + &MaybeBytes::None, + &MaybeBytes::None, + &0, ); + client.mark_paid(&admin, &id, &payer); + env.ledger().with_mut(|l| l.timestamp = 999); + client.release_escrow(&admin, &id); - let err = client - .try_cancel_invoice(&unauthorized, &id) - .unwrap_err() - .unwrap(); - assert_eq!(err, InvoiceError::Unauthorized); + // Filter by topic symbol first (Symbol::try_from_val is safe for non-symbol Vals); + // only then deserialize data — contracttype try_from_val panics on wrong map shape. + let all_events = env.events().all(); + let target = Symbol::new(&env, "escrow_released"); + let (_, _, data) = all_events + .iter() + .find(|(_, topics, _)| { + topics + .first() + .and_then(|t| Symbol::try_from_val(&env, t.as_ref()).ok()) + .map(|s| s == target) + .unwrap_or(false) + }) + .expect("escrow_released event not emitted"); + let payload = EscrowReleasedEvent::try_from_val(&env, &data) + .expect("EscrowReleasedEvent deserialization failed"); - let invoice = client.get_invoice(&id); - assert_eq!(invoice.status, InvoiceStatus::Pending); + assert_eq!(payload.id, id); + assert_eq!(payload.merchant, merchant); + assert_eq!(payload.amount_usdc, 10_000_000); + assert_eq!(payload.released_at, 999); } // ABI snapshot comparison: asserts abis/invoice.json stays in sync with the @@ -697,29 +742,7 @@ fn test_mark_paid_blocked_when_paused() { assert!(client.try_mark_paid(&admin, &id, &payer).is_err()); } -// Issue #93: mark_paid is rejected when the contract is paused -#[test] -fn test_mark_paid_blocked_when_paused() { - let (env, admin, client) = setup(); - let merchant = Address::generate(&env); - let payer = Address::generate(&env); - let id = client.create_invoice( - &merchant, - &10_000_000, - &10_250_000, - &3600, - &MaybeBytes::None, - &MaybeBytes::None, - ); - client.pause(&admin); - let result = client.try_mark_paid(&admin, &id, &payer); - assert!(result.is_err(), "mark_paid must be blocked when paused"); -} - // Issue #94: create_invoice must enforce merchant authorization. -// Uses cancel_invoice (which has an explicit Unauthorized check) to prove that a -// non-merchant/non-admin caller is rejected. Also verifies that the merchant's auth -// was recorded by create_invoice, confirming require_auth() is enforced. #[test] fn test_create_invoice_unauthorized_merchant() { let (env, _admin, client) = setup(); @@ -734,13 +757,11 @@ fn test_create_invoice_unauthorized_merchant() { &MaybeBytes::None, &0, ); - let auths = env.auths(); assert!( auths.iter().any(|(addr, _)| addr == &merchant), "create_invoice must require merchant authorization" ); - let err = client .try_cancel_invoice(&unauthorized, &id) .unwrap_err() @@ -748,35 +769,11 @@ fn test_create_invoice_unauthorized_merchant() { assert_eq!(err, InvoiceError::Unauthorized); } -#[test] -fn test_invoice_create_to_expired_flow() { - let (env, admin, client) = setup(); - let merchant = Address::generate(&env); - let id = client.create_invoice( - &merchant, - &10_000_000, - &10_250_000, - &3600, - &MaybeBytes::None, - &MaybeBytes::None, - &0, - ); - assert_eq!( - err, - InvoiceError::Unauthorized, - "Expected Unauthorized for non-merchant non-admin caller" - ); - - let invoice = client.get_invoice(&id); - assert_eq!(invoice.status, InvoiceStatus::Pending); -} - // Issue #92: e2e flow — create invoice, advance ledger past deadline, run batch_expire, assert Expired #[test] fn test_invoice_create_to_expired_flow() { let (env, admin, client) = setup(); let merchant = Address::generate(&env); - let id = client.create_invoice( &merchant, &10_000_000, @@ -784,45 +781,22 @@ fn test_invoice_create_to_expired_flow() { &3600, &MaybeBytes::None, &MaybeBytes::None, + &0, ); - - env.ledger().with_mut(|li| { - li.timestamp = client.get_invoice(&id).expires_at + 1; - }); - + env.ledger() + .with_mut(|l| l.timestamp = client.get_invoice(&id).expires_at + 1); let ids = soroban_sdk::vec![&env, id]; let expired_count = client.batch_expire(&admin, &ids); assert_eq!(expired_count, 1); - assert_eq!(client.get_invoice(&id).status, InvoiceStatus::Expired); } -#[test] -fn test_invoice_create_to_paid_escrow_flow() { - let (env, admin, client) = setup(); - let merchant = Address::generate(&env); - let payer = Address::generate(&env); - let id = client.create_invoice( - &merchant, - &10_000_000, - &10_250_000, - &3600, - &MaybeBytes::None, - &MaybeBytes::None, - &0, - ); - let invoice = client.get_invoice(&id); - assert_eq!(invoice.status, InvoiceStatus::Expired); - assert_eq!(invoice.merchant, merchant); -} - // Issue #91: e2e happy path — create invoice, admin marks paid, assert Paid status and payer recorded #[test] fn test_invoice_create_to_paid_escrow_flow() { let (env, admin, client) = setup(); let merchant = Address::generate(&env); let payer = Address::generate(&env); - let id = client.create_invoice( &merchant, &10_000_000, @@ -830,8 +804,8 @@ fn test_invoice_create_to_paid_escrow_flow() { &3600, &MaybeBytes::None, &MaybeBytes::None, + &0, ); - client.mark_paid(&admin, &id, &payer); let paid = client.get_invoice(&id); assert_eq!(paid.status, InvoiceStatus::Paid); @@ -959,538 +933,3 @@ fn test_same_nonce_different_merchants_accepted() { &7, ); } - use invoice::{ - InvoiceContract, InvoiceContractClient, InvoiceError, InvoiceStatus, MaybeAddress, MaybeBytes, - }; - use soroban_sdk::{ - testutils::{Address as _, Ledger}, - Address, Env, - }; - - extern crate std; - use std::{collections::HashSet, fs, path::Path}; - - fn setup() -> (Env, Address, InvoiceContractClient<'static>) { - let env = Env::default(); - env.mock_all_auths(); - let admin = Address::generate(&env); - let id = env.register_contract(None, InvoiceContract); - let client = InvoiceContractClient::new(&env, &id); - client.initialize(&admin); - (env, admin, client) - } - - #[test] - fn test_create_invoice_succeeds() { - let (env, _admin, client) = setup(); - let merchant = Address::generate(&env); - let id = client.create_invoice( - &merchant, - &10_000_000, - &10_250_000, - &3600, - &MaybeBytes::None, - &MaybeBytes::None, - ); - let invoice = client.get_invoice(&id); - assert_eq!(invoice.id, 1); - assert_eq!(invoice.status, InvoiceStatus::Pending); - assert_eq!(invoice.amount_usdc, 10_000_000); - assert_eq!(invoice.gross_usdc, 10_250_000); - assert_eq!(invoice.payer, MaybeAddress::None); - } - - #[test] - fn test_mark_paid_requires_admin() { - let (env, _admin, client) = setup(); - let merchant = Address::generate(&env); - let payer = Address::generate(&env); - let rogue_admin = Address::generate(&env); - let id = client.create_invoice( - &merchant, - &10_000_000, - &10_250_000, - &3600, - &MaybeBytes::None, - &MaybeBytes::None, - ); - assert!(client.try_mark_paid(&rogue_admin, &id, &payer).is_err()); - } - - #[test] - fn test_expired_invoice_cannot_be_paid() { - let (env, admin, client) = setup(); - let merchant = Address::generate(&env); - let payer = Address::generate(&env); - let id = client.create_invoice( - &merchant, - &10_000_000, - &10_250_000, - &1, - &MaybeBytes::None, - &MaybeBytes::None, - ); - env.ledger().with_mut(|ledger| ledger.timestamp += 2); - assert!(client.try_mark_paid(&admin, &id, &payer).is_err()); - } - - #[test] - fn test_pause_blocks_create_invoice() { - let (env, admin, client) = setup(); - let merchant = Address::generate(&env); - client.pause(&admin); - assert!(client - .try_create_invoice( - &merchant, - &10_000_000, - &10_250_000, - &3600, - &MaybeBytes::None, - &MaybeBytes::None - ) - .is_err()); - } - - #[test] - fn test_pause_blocks_mark_paid() { - let (env, admin, client) = setup(); - let merchant = Address::generate(&env); - let payer = Address::generate(&env); - let id = client.create_invoice( - &merchant, - &10_000_000, - &10_250_000, - &3600, - &MaybeBytes::None, - &MaybeBytes::None, - ); - client.pause(&admin); - assert!(client.try_mark_paid(&admin, &id, &payer).is_err()); - } - - #[test] - fn test_double_payment_rejected() { - let (env, admin, client) = setup(); - let merchant = Address::generate(&env); - let payer = Address::generate(&env); - let id = client.create_invoice( - &merchant, - &10_000_000, - &10_250_000, - &3600, - &MaybeBytes::None, - &MaybeBytes::None, - ); - client.mark_paid(&admin, &id, &payer); - assert!(client.try_mark_paid(&admin, &id, &payer).is_err()); - } - - #[test] - fn test_get_invoice_unknown_id_returns_not_found() { - let (_env, _admin, client) = setup(); - let err = client.try_get_invoice(&999).unwrap_err().unwrap(); - assert_eq!(err, InvoiceError::NotFound); - } - - #[test] - fn test_mark_paid_unknown_id_returns_not_found() { - let (env, admin, client) = setup(); - let payer = Address::generate(&env); - let err = client - .try_mark_paid(&admin, &999, &payer) - .unwrap_err() - .unwrap(); - assert_eq!(err, InvoiceError::NotFound); - } - - #[test] - fn test_payer_set_after_payment() { - let (env, admin, client) = setup(); - let merchant = Address::generate(&env); - let payer = Address::generate(&env); - let id = client.create_invoice( - &merchant, - &10_000_000, - &10_250_000, - &3600, - &MaybeBytes::None, - &MaybeBytes::None, - ); - client.mark_paid(&admin, &id, &payer); - let invoice = client.get_invoice(&id); - assert_eq!(invoice.payer, MaybeAddress::Some(payer)); - } - - #[test] - fn test_expired_event_emitted_on_stale_mark_paid() { - let (env, admin, client) = setup(); - let merchant = Address::generate(&env); - let payer = Address::generate(&env); - let id = client.create_invoice( - &merchant, - &10_000_000, - &10_250_000, - &1, - &MaybeBytes::None, - &MaybeBytes::None, - ); - env.ledger().with_mut(|ledger| ledger.timestamp += 2); - let err = client - .try_mark_paid(&admin, &id, &payer) - .unwrap_err() - .unwrap(); - assert_eq!(err, InvoiceError::Expired); - // Storage is rolled back on error; invoice remains Pending - let invoice = client.get_invoice(&id); - assert_eq!(invoice.status, InvoiceStatus::Pending); - } - - // Payment at exactly expires_at is rejected — the boundary is exclusive. - // expires_in_seconds=10, ledger starts at 0, so expires_at=10. - // Setting timestamp=10 means now >= expires_at → Expired. - #[test] - fn test_payment_at_exact_expiry_is_rejected() { - let (env, admin, client) = setup(); - let merchant = Address::generate(&env); - let payer = Address::generate(&env); - let id = client.create_invoice( - &merchant, - &10_000_000, - &10_250_000, - &10, - &MaybeBytes::None, - &MaybeBytes::None, - ); - env.ledger().with_mut(|ledger| ledger.timestamp = 10); - let err = client - .try_mark_paid(&admin, &id, &payer) - .unwrap_err() - .unwrap(); - assert_eq!(err, InvoiceError::Expired); - } - - // Payment one second before expires_at succeeds — last valid moment is expires_at - 1. - #[test] - fn test_payment_before_expiry_succeeds() { - let (env, admin, client) = setup(); - let merchant = Address::generate(&env); - let payer = Address::generate(&env); - let id = client.create_invoice( - &merchant, - &10_000_000, - &10_250_000, - &10, - &MaybeBytes::None, - &MaybeBytes::None, - ); - env.ledger().with_mut(|ledger| ledger.timestamp = 9); - client.mark_paid(&admin, &id, &payer); - let invoice = client.get_invoice(&id); - assert_eq!(invoice.status, InvoiceStatus::Paid); - } - - #[test] - fn test_initialize_requires_admin_auth() { - let env = Env::default(); - env.mock_all_auths(); - let admin = Address::generate(&env); - let id = env.register_contract(None, InvoiceContract); - let client = InvoiceContractClient::new(&env, &id); - client.initialize(&admin); - let auths = env.auths(); - assert!(auths.iter().any(|(addr, _)| addr == &admin)); - } - - #[test] - fn test_initialize_cannot_be_called_twice() { - let (env, _admin, client) = setup(); - let new_admin = Address::generate(&env); - assert!(client.try_initialize(&new_admin).is_err()); - } - - #[test] - fn test_zero_duration_invoice_rejected() { - let (env, _admin, client) = setup(); - let merchant = Address::generate(&env); - assert!(client - .try_create_invoice( - &merchant, - &10_000_000, - &10_250_000, - &0, - &MaybeBytes::None, - &MaybeBytes::None - ) - .is_err()); - } - - #[test] - fn test_expiry_overflow_rejected() { - let (env, _admin, client) = setup(); - let merchant = Address::generate(&env); - env.ledger().with_mut(|l| l.timestamp = u64::MAX); - assert!(client - .try_create_invoice( - &merchant, - &10_000_000, - &10_250_000, - &1, - &MaybeBytes::None, - &MaybeBytes::None - ) - .is_err()); - } - - #[test] - fn test_event_stream_redis_webhook_compatibility() { - let (env, admin, client) = setup(); - let merchant = Address::generate(&env); - let payer = Address::generate(&env); - - let invoice_id = client.create_invoice( - &merchant, - &10_000_000, - &10_250_000, - &3600, - &MaybeBytes::None, - &MaybeBytes::None, - ); - - // Verify the invoice can be retrieved (validates event data was properly stored) - let invoice = client.get_invoice(&invoice_id); - assert_eq!(invoice.id, 1); - assert_eq!(invoice.merchant, merchant); - assert_eq!(invoice.amount_usdc, 10_000_000); - assert_eq!(invoice.gross_usdc, 10_250_000); - assert_eq!(invoice.status, InvoiceStatus::Pending); - assert_eq!(invoice.payer, MaybeAddress::None); - - client.mark_paid(&admin, &invoice_id, &payer); - let paid_invoice = client.get_invoice(&invoice_id); - assert_eq!(paid_invoice.status, InvoiceStatus::Paid); - assert_eq!(paid_invoice.payer, MaybeAddress::Some(payer)); - assert!(paid_invoice.paid_at.is_some()); - - client.pause(&admin); - client.unpause(&admin); - } - - // ABI snapshot comparison: asserts abis/invoice.json stays in sync with the - // contract's public surface. Run via `cargo test` or `make check-abi-snapshots`. - #[test] - fn test_abi_snapshot_matches_contract() { - // Canonical function and event lists derived from lib.rs / events.rs. - let expected_functions: HashSet<&str> = [ - "initialize", - "create_invoice", - "mark_paid", - "get_invoice", - "get_invoice_status", - "cancel_invoice", - "request_refund", - "batch_expire", - "pause", - "unpause", - ] - .iter() - .copied() - .collect(); - - let expected_events: HashSet<&str> = [ - "invoice_created", - "invoice_paid", - "invoice_expired", - "invoice_cancelled", - "invoice_refund_req", - "contract_paused", - "contract_unpaused", - ] - .iter() - .copied() - .collect(); - - // Locate abis/invoice.json relative to the workspace root (CARGO_MANIFEST_DIR - // points to contracts/invoice; walk up two levels). - let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let abi_path = manifest_dir.join("../../abis/invoice.json"); - let raw = fs::read_to_string(&abi_path) - .unwrap_or_else(|e| panic!("cannot read {}: {e}", abi_path.display())); - - // --- functions --- - let fns_block = raw - .split("\"functions\"") - .nth(1) - .expect("\"functions\" key missing from abis/invoice.json"); - let fns_array = &fns_block[fns_block.find('[').unwrap()..=fns_block.find(']').unwrap()]; - let snapshot_functions: HashSet<&str> = fns_array - .split('"') - .filter(|s| { - !s.trim().is_empty() - && !s.contains('[') - && !s.contains(']') - && !s.trim().starts_with(',') - && !s.trim().starts_with(',') - && !s.contains(']') - }) - .collect(); - - // --- events --- - let evts_block = raw - .split("\"events\"") - .nth(1) - .expect("\"events\" key missing from abis/invoice.json"); - let evts_array = &evts_block[evts_block.find('[').unwrap()..=evts_block.find(']').unwrap()]; - let snapshot_events: HashSet<&str> = evts_array - .split('"') - .filter(|s| { - !s.trim().is_empty() - && !s.contains('[') - && !s.contains(']') - && !s.trim().starts_with(',') - && !s.trim().starts_with(',') - && !s.contains(']') - }) - .collect(); - - assert_eq!( - snapshot_functions, - expected_functions, - "abis/invoice.json functions list is out of sync with the contract.\n\ - Missing from snapshot : {:?}\n\ - Extra in snapshot : {:?}\n\ - Run `make update-abi-snapshots` to regenerate.", - expected_functions - .difference(&snapshot_functions) - .collect::>(), - snapshot_functions - .difference(&expected_functions) - .collect::>(), - ); - - assert_eq!( - snapshot_events, - expected_events, - "abis/invoice.json events list is out of sync with the contract.\n\ - Missing from snapshot : {:?}\n\ - Extra in snapshot : {:?}\n\ - Run `make update-abi-snapshots` to regenerate.", - expected_events - .difference(&snapshot_events) - .collect::>(), - snapshot_events - .difference(&expected_events) - .collect::>(), - ); - } - - // Issue #93: create_invoice is rejected when the contract is paused - #[test] - fn test_create_invoice_blocked_when_paused() { - let (env, admin, client) = setup(); - let merchant = Address::generate(&env); - client.pause(&admin); - let result = client.try_create_invoice( - &merchant, - &10_000_000, - &10_250_000, - &3600, - &MaybeBytes::None, - &MaybeBytes::None, - ); - assert!( - result.is_err(), - "create_invoice must be blocked when paused" - ); - } - - // Issue #93: mark_paid is rejected when the contract is paused - #[test] - fn test_mark_paid_blocked_when_paused() { - let (env, admin, client) = setup(); - let merchant = Address::generate(&env); - let payer = Address::generate(&env); - let id = client.create_invoice( - &merchant, - &10_000_000, - &10_250_000, - &3600, - &MaybeBytes::None, - &MaybeBytes::None, - ); - client.pause(&admin); - assert!(client.try_mark_paid(&admin, &id, &payer).is_err()); - } - - // Issue #94: create_invoice must enforce merchant authorization. - // Uses cancel_invoice (which has an explicit Unauthorized check) to prove that a - // non-merchant/non-admin caller is rejected. Also verifies that the merchant's auth - // was recorded by create_invoice, confirming require_auth() is enforced. - #[test] - fn test_create_invoice_unauthorized_merchant() { - let (env, _admin, client) = setup(); - let merchant = Address::generate(&env); - let unauthorized = Address::generate(&env); - let id = client.create_invoice( - &merchant, - &10_000_000, - &10_250_000, - &3600, - &MaybeBytes::None, - &MaybeBytes::None, - ); - // An unauthorized address is rejected when attempting to manage the invoice - let err = client - .try_cancel_invoice(&unauthorized, &id) - .unwrap_err() - .unwrap(); - assert_eq!(err, InvoiceError::Unauthorized); - } - - // Issue #92: e2e flow — create invoice, advance ledger past deadline, run batch_expire, assert Expired - #[test] - fn test_invoice_create_to_expired_flow() { - let (env, admin, client) = setup(); - let merchant = Address::generate(&env); - let id = client.create_invoice( - &merchant, - &10_000_000, - &10_250_000, - &1, - &MaybeBytes::None, - &MaybeBytes::None, - ); - // advance ledger past the invoice deadline - env.ledger().with_mut(|li| li.timestamp = li.timestamp + 2); - - let ids = soroban_sdk::vec![&env, id]; - let expired_count = client.batch_expire(&admin, &ids); - assert_eq!(expired_count, 1, "batch_expire should mark one invoice as expired"); - - let invoice = client.get_invoice(&id); - assert_eq!(invoice.status, InvoiceStatus::Expired); - assert_eq!(invoice.merchant, merchant); - } - - // Issue #91: e2e happy path — create invoice, admin marks paid, assert Paid status and payer recorded - #[test] - fn test_invoice_create_to_paid_escrow_flow() { - let (env, admin, client) = setup(); - let merchant = Address::generate(&env); - let payer = Address::generate(&env); - - let id = client.create_invoice( - &merchant, - &10_000_000, - &10_250_000, - &3600, - &MaybeBytes::None, - &MaybeBytes::None, - ); - // admin marks the invoice as paid, recording the payer - client.mark_paid(&admin, &id, &payer); - - let paid = client.get_invoice(&id); - assert_eq!(paid.status, InvoiceStatus::Paid); - assert_eq!(paid.payer, MaybeAddress::Some(payer)); - assert!(paid.paid_at.is_some(), "paid_at must be set after mark_paid"); - }