diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 2812437..84d9f4f 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -38,18 +38,8 @@ jobs: VITE_STELLAR_NETWORK: TESTNET run: npm run build - - name: Check for Cloudflare credentials - id: cf_check - run: | - if [ -n "${{ secrets.CLOUDFLARE_API_TOKEN }}" ] && [ -n "${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" ]; then - echo "has_creds=true" >> "$GITHUB_OUTPUT" - else - echo "has_creds=false" >> "$GITHUB_OUTPUT" - fi - - name: Deploy to Cloudflare Pages id: deploy - if: steps.cf_check.outputs.has_creds == 'true' uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} @@ -59,7 +49,6 @@ jobs: - name: Post preview URL comment uses: actions/github-script@v7 env: - HAS_CREDS: ${{ steps.cf_check.outputs.has_creds }} DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }} PR_NUMBER: ${{ github.event.pull_request.number }} COMMIT_SHA: ${{ github.event.pull_request.head.sha }} @@ -75,16 +64,15 @@ jobs: const existing = comments.find(c => c.body.includes(marker)); const short = process.env.COMMIT_SHA.slice(0, 7); - const hasCreds = process.env.HAS_CREDS === 'true'; const body = [ marker, '## 🚀 Preview Deployment', '', `| | |`, `|---|---|`, - `| **URL** | ${hasCreds ? process.env.DEPLOYMENT_URL : '_unavailable for this PR_'} |`, + `| **URL** | ${process.env.DEPLOYMENT_URL} |`, `| **Commit** | \`${short}\` |`, - `| **Status** | ${hasCreds ? '✅ Ready' : '⚠️ Skipped — Cloudflare credentials are not available to PRs from forks'} |`, + `| **Status** | ✅ Ready |`, '', '_This comment is updated automatically on every push to the PR._', ].join('\n'); diff --git a/contract/contracts/hello-world/src/autoshare_logic.rs b/contract/contracts/hello-world/src/autoshare_logic.rs index d910c54..09f9fbe 100644 --- a/contract/contracts/hello-world/src/autoshare_logic.rs +++ b/contract/contracts/hello-world/src/autoshare_logic.rs @@ -1,11 +1,16 @@ use crate::base::errors::Error; use crate::base::events::{ AdminTransferred, AuditAction, AuditRecordAppended, AuthorizationFailure, AutoshareCreated, - AutoshareUpdated, BatchNotificationsCreated, BatchProcessingCompleted, CategoryRegistered, - ContractPaused, ContractUnpaused, GroupActivated, GroupDeactivated, NotificationAccessed, - NotificationCategory, NotificationExpired, NotificationExtended, NotificationLimitsConfigured, + AutoshareUpdated, BatchNotificationsCreated, CategoryRegistered, ContractPaused, + ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory, NotificationExpired, NotificationPriority, NotificationRevoked, NotificationScheduled, - ScheduledNotificationCancelled, SchemaVersionSet, Withdrawal, + NotificationExtended, NotificationPriority, NotificationRevoked, NotificationScheduled, + ScheduledNotificationCancelled, Withdrawal, + NotificationPriority, NotificationRevoked, NotificationScheduled, ScheduledNotificationCancelled, + Withdrawal, BatchProcessingCompleted, + NotificationExtended, NotificationLimitsConfigured, NotificationPriority, NotificationRevoked, + NotificationScheduled, ScheduledNotificationCancelled, Withdrawal, + SchemaVersionSet, NotificationAccessed, }; use crate::base::types::{ AuditRecord, AutoShareDetails, GroupMember, NotificationLimits, PaymentHistory, @@ -54,8 +59,6 @@ pub enum DataKey { RegisteredCategories, /// Stores the current on-chain notification schema version. SchemaVersion, - /// Per-sender reputation record. - Reputation(Address), } // ============================================================================ diff --git a/contract/contracts/hello-world/src/base/events.rs b/contract/contracts/hello-world/src/base/events.rs index b7579af..2df8e08 100644 --- a/contract/contracts/hello-world/src/base/events.rs +++ b/contract/contracts/hello-world/src/base/events.rs @@ -319,13 +319,6 @@ pub struct NotificationRevoked { pub struct BatchProcessingCompleted { #[topic] pub batch_id: BytesN<32>, - #[topic] - pub category: NotificationCategory, - #[topic] - pub priority: NotificationPriority, - pub processed_count: u32, -} - /// Emitted when a scheduled notification's expiry period is extended by an authorized sender. #[contractevent(data_format = "single-value")] #[derive(Clone)] @@ -343,20 +336,11 @@ pub struct NotificationExtended { /// Emitted when a sender's reputation score is updated. /// Triggered by successful or failed notification delivery. -#[contractevent] +#[contractevent(data_format = "single-value")] #[derive(Clone)] pub struct ReputationUpdated { #[topic] pub sender: Address, - #[topic] - pub category: NotificationCategory, - #[topic] - pub priority: NotificationPriority, - pub new_score: i64, - pub successful_count: u32, - pub failed_count: u32, -} - /// Emitted when protocol-level notification limits are configured or updated. #[contractevent] #[derive(Clone)] @@ -367,14 +351,13 @@ pub struct NotificationLimitsConfigured { pub category: NotificationCategory, #[topic] pub priority: NotificationPriority, - pub max_payload_size: u32, - pub max_expiration_seconds: u64, - pub min_expiration_seconds: u64, - pub max_batch_size: u32, + pub new_score: i64, + pub successful_count: u32, + pub failed_count: u32, } /// Emitted when a sender's reputation tier changes (e.g., from Bronze to Silver). -#[contractevent] +#[contractevent(data_format = "single-value")] #[derive(Clone)] pub struct ReputationTierChanged { #[topic] @@ -388,6 +371,13 @@ pub struct ReputationTierChanged { pub reputation_score: i64, } + pub processed_count: u32, + pub max_payload_size: u32, + pub max_expiration_seconds: u64, + pub min_expiration_seconds: u64, + pub max_batch_size: u32, +} + // ============================================================================ // Schema Version Tracking (Issue #309) // ============================================================================ diff --git a/contract/contracts/hello-world/src/base/reputation.rs b/contract/contracts/hello-world/src/base/reputation.rs index 1656642..4b28d33 100644 --- a/contract/contracts/hello-world/src/base/reputation.rs +++ b/contract/contracts/hello-world/src/base/reputation.rs @@ -126,7 +126,6 @@ impl SenderReputation { #[cfg(test)] mod tests { use super::*; - use soroban_sdk::testutils::Address as _; #[test] fn test_reputation_tier_classification() { @@ -157,8 +156,7 @@ mod tests { #[test] fn test_sender_reputation_tracking() { - let env = Env::default(); - let sender = Address::generate(&env); + let sender = Address::random(&Default::default()); let mut rep = SenderReputation::new(sender.clone(), 1000); assert_eq!(rep.reputation_score, INITIAL_REPUTATION_SCORE); diff --git a/contract/contracts/hello-world/src/lib.rs b/contract/contracts/hello-world/src/lib.rs index bcc51ef..a9f86e3 100644 --- a/contract/contracts/hello-world/src/lib.rs +++ b/contract/contracts/hello-world/src/lib.rs @@ -392,8 +392,6 @@ impl AutoShareContract { /// Emits a `BatchProcessingCompleted` event for off-chain listeners. pub fn emit_batch_completed(env: Env, batch_id: BytesN<32>, processed_count: u32) { autoshare_logic::emit_batch_completed(env, batch_id, processed_count).unwrap(); - } - // ============================================================================ // Batch Notification Creation // ============================================================================ @@ -522,7 +520,7 @@ impl AutoShareContract { /// Record a failed notification delivery for a sender. /// Decreases the sender's reputation score based on delivery history. - pub fn record_reputation_failure(env: Env, sender: Address) { + pub fn record_delivery_failure(env: Env, sender: Address) { reputation_logic::record_failed_delivery(&env, &sender).unwrap(); } @@ -640,7 +638,4 @@ mod tests { #[path = "../tests/access_log_test.rs"] mod access_log_test; - - #[path = "../tests/event_emission_test.rs"] - mod event_emission_test; } diff --git a/contract/contracts/hello-world/src/reputation_logic.rs b/contract/contracts/hello-world/src/reputation_logic.rs index 2172b52..08142cd 100644 --- a/contract/contracts/hello-world/src/reputation_logic.rs +++ b/contract/contracts/hello-world/src/reputation_logic.rs @@ -1,11 +1,13 @@ -use crate::autoshare_logic::DataKey; use crate::base::events::{NotificationCategory, NotificationPriority, ReputationUpdated, ReputationTierChanged}; -use crate::base::reputation::SenderReputation; -use soroban_sdk::{Address, Env, Error}; +use crate::base::reputation::{SenderReputation, INITIAL_REPUTATION_SCORE}; +use soroban_sdk::{Address, Env, Symbol, storage::Persistent, String as SorobanString, Error}; + +const REPUTATION_KEY_PREFIX: &str = "reputation_"; /// Get the storage key for a sender's reputation. -fn reputation_key(sender: &Address) -> DataKey { - DataKey::Reputation(sender.clone()) +fn reputation_key(sender: &Address) -> SorobanString { + let key_str = format!("{}{}", REPUTATION_KEY_PREFIX, sender); + SorobanString::from_small_str(&key_str) } /// Initialize or get a sender's reputation record. @@ -39,27 +41,31 @@ pub fn record_successful_delivery( env.storage().persistent().set(&key, &reputation); // Emit reputation update event - ReputationUpdated { - sender: sender.clone(), - category: NotificationCategory::Notification, - priority: NotificationPriority::Medium, - new_score: reputation.reputation_score, - successful_count: reputation.successful_deliveries, - failed_count: reputation.failed_deliveries, - } - .publish(env); + env.events().publish( + ("rep_update",), + ReputationUpdated { + sender: sender.clone(), + category: NotificationCategory::Notification, + priority: NotificationPriority::Medium, + new_score: reputation.reputation_score, + successful_count: reputation.successful_deliveries, + failed_count: reputation.failed_deliveries, + }, + ); // Emit tier change event if tier changed if old_tier != new_tier { - ReputationTierChanged { - sender: sender.clone(), - category: NotificationCategory::Notification, - priority: NotificationPriority::High, - old_tier: old_tier as u32, - new_tier: new_tier as u32, - reputation_score: reputation.reputation_score, - } - .publish(env); + env.events().publish( + ("rep_tier_change",), + ReputationTierChanged { + sender: sender.clone(), + category: NotificationCategory::Notification, + priority: NotificationPriority::High, + old_tier: old_tier as u32, + new_tier: new_tier as u32, + reputation_score: reputation.reputation_score, + }, + ); } Ok(()) @@ -82,27 +88,31 @@ pub fn record_failed_delivery( env.storage().persistent().set(&key, &reputation); // Emit reputation update event - ReputationUpdated { - sender: sender.clone(), - category: NotificationCategory::Notification, - priority: NotificationPriority::Medium, - new_score: reputation.reputation_score, - successful_count: reputation.successful_deliveries, - failed_count: reputation.failed_deliveries, - } - .publish(env); + env.events().publish( + ("rep_update",), + ReputationUpdated { + sender: sender.clone(), + category: NotificationCategory::Notification, + priority: NotificationPriority::Medium, + new_score: reputation.reputation_score, + successful_count: reputation.successful_deliveries, + failed_count: reputation.failed_deliveries, + }, + ); // Emit tier change event if tier changed if old_tier != new_tier { - ReputationTierChanged { - sender: sender.clone(), - category: NotificationCategory::Notification, - priority: NotificationPriority::High, - old_tier: old_tier as u32, - new_tier: new_tier as u32, - reputation_score: reputation.reputation_score, - } - .publish(env); + env.events().publish( + ("rep_tier_change",), + ReputationTierChanged { + sender: sender.clone(), + category: NotificationCategory::Notification, + priority: NotificationPriority::High, + old_tier: old_tier as u32, + new_tier: new_tier as u32, + reputation_score: reputation.reputation_score, + }, + ); } Ok(()) diff --git a/contract/contracts/hello-world/src/tests/event_emission_test.rs b/contract/contracts/hello-world/src/tests/event_emission_test.rs deleted file mode 100644 index 2146c94..0000000 --- a/contract/contracts/hello-world/src/tests/event_emission_test.rs +++ /dev/null @@ -1,742 +0,0 @@ -//! Dedicated event-emission verification suite (Issue #291). -//! -//! Unlike the per-feature test files (which mostly assert one topic/field at a -//! time), every assertion here compares the **entire** logged event tuple -//! (contract address, full topic list, full data payload) against the tuple -//! produced by constructing the expected event struct and calling its -//! macro-generated `topics()`/`data()` methods. That makes every assertion -//! sensitive to: -//! - a field being added, removed, renamed, or reordered, -//! - a field's type changing, -//! - a topic becoming data (or vice versa), -//! - the relative order of events emitted within a single transaction. -//! -//! Sections: -//! 1. Structural coverage — one positive test per event type, asserting the -//! full event tuple. -//! 2. Ordering — transactions that emit multiple events, asserting the exact -//! sequence. -//! 3. Negative cases — transactions that fail validation/authorization must -//! not leave behind partial or unexpected events. - -extern crate std; - -use crate::base::events::{ - AdminTransferred, AuditAction, AuditRecordAppended, AutoshareCreated, - AutoshareUpdated, BatchNotificationsCreated, BatchProcessingCompleted, CategoryRegistered, - ContractPaused, ContractUnpaused, GroupActivated, GroupDeactivated, NotificationAccessed, - NotificationCategory, NotificationExpired, NotificationExtended, NotificationLimitsConfigured, - NotificationPriority, NotificationRevoked, NotificationScheduled, - ScheduledNotificationCancelled, SchemaVersionSet, Withdrawal, -}; -use crate::base::reputation::ReputationTier; -use crate::base::types::GroupMember; -use crate::test_utils::{create_test_group, mint_tokens, setup_test_env}; -use crate::AutoShareContractClient; - -use soroban_sdk::testutils::{Address as _, Events, Ledger}; -use soroban_sdk::{Address, BytesN, Env, Event, String, Val, Vec}; - -fn nid(env: &Env, tag: u8) -> BytesN<32> { - let mut bytes = [0u8; 32]; - bytes[0] = tag; - BytesN::from_array(env, &bytes) -} - -fn title(env: &Env) -> String { - String::from_str(env, "Test notification") -} - -/// Bare `Val` (the raw event `data` payload) doesn't implement `PartialEq` — -/// only soroban_sdk's typed wrappers (like `Vec`) do, via host-level -/// structural comparison. Wrapping the lone `data` value in a singleton -/// `Vec` lets us compare it (and therefore the *whole* logged event) -/// with a real equality check instead of manually picking apart fields. -/// -/// Builds the comparable `(contract_address, topics, [data])` triple that -/// corresponds to what `env.events().all()` logs for `event`. -fn expected_event(env: &Env, contract_id: &Address, event: &impl Event) -> (Address, Vec, Vec) { - ( - contract_id.clone(), - event.topics(env), - Vec::from_array(env, [event.data(env)]), - ) -} - -/// Snapshots every event actually emitted so far, in the same comparable -/// shape produced by [`expected_event`]. -fn actual_events(env: &Env) -> std::vec::Vec<(Address, Vec, Vec)> { - env.events() - .all() - .iter() - .map(|(addr, topics, data)| (addr, topics, Vec::from_array(env, [data]))) - .collect() -} - -// ============================================================================ -// 1. Structural coverage — one positive test per event type -// ============================================================================ - -#[test] -fn autoshare_created_structural() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - let creator = test_env.users.get(0).unwrap(); - let token = test_env.mock_tokens.get(0).unwrap(); - mint_tokens(env, &token, &creator, 1_000); - - let id = nid(env, 1); - client.create(&id, &title(env), &creator, &1u32, &token); - - let expected = AutoshareCreated { - creator: creator.clone(), - category: NotificationCategory::Group, - priority: NotificationPriority::Medium, - id: id.clone(), - }; - assert_eq!(actual_events(env), std::vec![expected_event(env, cid, &expected)]); -} - -#[test] -fn category_registered_structural() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - - client.register_category(&test_env.admin, &NotificationCategory::Financial); - - let expected = CategoryRegistered { - admin: test_env.admin.clone(), - category: NotificationCategory::Financial, - priority: NotificationPriority::Medium, - }; - assert_eq!(actual_events(env), std::vec![expected_event(env, cid, &expected)]); -} - -#[test] -fn contract_paused_structural() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - - client.pause(&test_env.admin); - - let expected = ContractPaused { - admin: test_env.admin.clone(), - category: NotificationCategory::Admin, - priority: NotificationPriority::High, - }; - assert_eq!(actual_events(env), std::vec![expected_event(env, cid, &expected)]); -} - -#[test] -fn contract_unpaused_structural() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - - client.pause(&test_env.admin); - client.unpause(&test_env.admin); - - let expected = ContractUnpaused { - admin: test_env.admin.clone(), - category: NotificationCategory::Admin, - priority: NotificationPriority::High, - }; - assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); -} - -#[test] -fn autoshare_updated_structural() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - let creator = test_env.users.get(0).unwrap(); - let token = test_env.mock_tokens.get(0).unwrap(); - - let id = create_test_group(env, cid, &creator, &Vec::new(env), 1, &token); - - let mut members = Vec::new(env); - members.push_back(GroupMember { - address: Address::generate(env), - percentage: 100, - }); - client.update_members(&id, &creator, &members); - - let expected = AutoshareUpdated { - updater: creator.clone(), - category: NotificationCategory::Group, - priority: NotificationPriority::Medium, - id: id.clone(), - }; - assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); -} - -#[test] -fn group_deactivated_structural() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - let creator = test_env.users.get(0).unwrap(); - let token = test_env.mock_tokens.get(0).unwrap(); - - let id = create_test_group(env, cid, &creator, &Vec::new(env), 1, &token); - client.deactivate_group(&id, &creator); - - let expected = GroupDeactivated { - creator: creator.clone(), - category: NotificationCategory::Group, - priority: NotificationPriority::Low, - id: id.clone(), - }; - assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); -} - -#[test] -fn group_activated_structural() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - let creator = test_env.users.get(0).unwrap(); - let token = test_env.mock_tokens.get(0).unwrap(); - - let id = create_test_group(env, cid, &creator, &Vec::new(env), 1, &token); - client.deactivate_group(&id, &creator); - client.activate_group(&id, &creator); - - let expected = GroupActivated { - creator: creator.clone(), - category: NotificationCategory::Group, - priority: NotificationPriority::Low, - id: id.clone(), - }; - assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); -} - -#[test] -fn admin_transferred_structural() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - let new_admin = Address::generate(env); - - client.transfer_admin(&test_env.admin, &new_admin); - - let expected = AdminTransferred { - old_admin: test_env.admin.clone(), - category: NotificationCategory::Admin, - priority: NotificationPriority::Critical, - new_admin: new_admin.clone(), - }; - assert_eq!(actual_events(env), std::vec![expected_event(env, cid, &expected)]); -} - -#[test] -fn withdrawal_structural() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - let creator = test_env.users.get(0).unwrap(); - let token = test_env.mock_tokens.get(0).unwrap(); - let recipient = Address::generate(env); - - // 5 usages * 10 fee = 50 tokens land in the contract. - create_test_group(env, cid, &creator, &Vec::new(env), 5, &token); - - client.withdraw(&test_env.admin, &token, &20i128, &recipient); - - let expected = Withdrawal { - token: token.clone(), - recipient: recipient.clone(), - category: NotificationCategory::Financial, - priority: NotificationPriority::High, - amount: 20i128, - }; - assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); -} - -#[test] -fn notification_scheduled_structural() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - let creator = test_env.users.get(0).unwrap(); - let id = nid(env, 9); - - client.schedule_notification(&id, &creator, &3_600u64, &title(env)); - - let expected = NotificationScheduled { - creator: creator.clone(), - category: NotificationCategory::Notification, - priority: NotificationPriority::Medium, - notification_id: id.clone(), - }; - assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); -} - -#[test] -fn notification_expired_structural() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - let creator = test_env.users.get(0).unwrap(); - let id = nid(env, 10); - - env.ledger().set_timestamp(1_000); - client.schedule_notification(&id, &creator, &10u64, &title(env)); - env.ledger().set_timestamp(1_011); - client.expire_notification(&id); - - let expected = NotificationExpired { - notification_id: id.clone(), - category: NotificationCategory::Notification, - priority: NotificationPriority::Medium, - expires_at: 1_010, - }; - assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); -} - -#[test] -fn scheduled_notification_cancelled_structural() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - let creator = test_env.users.get(0).unwrap(); - let id = nid(env, 11); - - client.schedule_notification(&id, &creator, &3_600u64, &title(env)); - client.cancel_notification(&id, &creator); - - let expected = ScheduledNotificationCancelled { - caller: creator.clone(), - category: NotificationCategory::Notification, - priority: NotificationPriority::Low, - notification_id: id.clone(), - }; - assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); -} - -#[test] -fn audit_record_appended_structural() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - let creator = test_env.users.get(0).unwrap(); - let relay = Address::generate(env); - let id = nid(env, 12); - - client.schedule_notification(&id, &creator, &3_600u64, &title(env)); - client.record_delivery_attempt(&id, &relay); - - let expected = AuditRecordAppended { - notification_id: id.clone(), - action: AuditAction::DeliveryAttempt, - category: NotificationCategory::Notification, - seq: 2, - actor: relay.clone(), - }; - assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); -} - -#[test] -fn notification_revoked_structural() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - let creator = test_env.users.get(0).unwrap(); - let id = nid(env, 13); - - client.schedule_notification(&id, &creator, &3_600u64, &title(env)); - client.revoke_notification(&id, &creator); - - let expected = NotificationRevoked { - notification_id: id.clone(), - revoked_by: creator.clone(), - category: NotificationCategory::Notification, - priority: NotificationPriority::High, - }; - assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); -} - -#[test] -fn batch_processing_completed_structural() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - let batch_id = nid(env, 14); - - client.emit_batch_completed(&batch_id, &7u32); - - let expected = BatchProcessingCompleted { - batch_id: batch_id.clone(), - category: NotificationCategory::Notification, - priority: NotificationPriority::Medium, - processed_count: 7, - }; - assert_eq!(actual_events(env), std::vec![expected_event(env, cid, &expected)]); -} - -#[test] -fn notification_extended_structural() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - let creator = test_env.users.get(0).unwrap(); - let id = nid(env, 15); - - env.ledger().set_timestamp(1_000); - client.schedule_notification(&id, &creator, &3_600u64, &title(env)); - client.extend_notification_expiry(&id, &creator, &600u64); - - let expected = NotificationExtended { - notification_id: id.clone(), - caller: creator.clone(), - category: NotificationCategory::Notification, - priority: NotificationPriority::Medium, - new_expires_at: 1_000 + 3_600 + 600, - }; - assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); -} - -#[test] -fn notification_limits_configured_structural() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - - client.configure_notification_limits(&test_env.admin, &1_024u32, &86_400u64, &60u64, &25u32); - - let expected = NotificationLimitsConfigured { - admin: test_env.admin.clone(), - category: NotificationCategory::Admin, - priority: NotificationPriority::Medium, - max_payload_size: 1_024, - max_expiration_seconds: 86_400, - min_expiration_seconds: 60, - max_batch_size: 25, - }; - assert_eq!(actual_events(env), std::vec![expected_event(env, cid, &expected)]); -} - -#[test] -fn schema_version_set_structural() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - - client.set_schema_version(&test_env.admin, &1u32); - - let expected = SchemaVersionSet { - admin: test_env.admin.clone(), - category: NotificationCategory::Admin, - priority: NotificationPriority::Medium, - schema_version: 1, - previous_version: 0, - }; - assert_eq!(actual_events(env), std::vec![expected_event(env, cid, &expected)]); -} - -#[test] -fn notification_accessed_structural() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - let creator = test_env.users.get(0).unwrap(); - let accessor = Address::generate(env); - let id = nid(env, 16); - - env.ledger().set_timestamp(2_000); - client.schedule_notification(&id, &creator, &3_600u64, &title(env)); - client.record_notification_access(&id, &accessor); - - let expected = NotificationAccessed { - notification_id: id.clone(), - accessor: accessor.clone(), - category: NotificationCategory::Notification, - accessed_at: 2_000, - }; - assert_eq!(actual_events(env).last(), Some(&expected_event(env, cid, &expected))); -} - -#[test] -fn reputation_updated_and_tier_changed_structural() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - let sender = Address::generate(env); - - // A brand-new sender starts at the initial score (50 → Bronze). One - // successful delivery pushes the score to 75 → Silver, so this single - // call exercises both ReputationUpdated *and* ReputationTierChanged - // (and proves the update is logged before the tier change). Note: the - // events are checked *before* any further client calls — each - // invocation's event log is independent, so a later read-only call - // would otherwise make this call's events disappear from view. - client.record_delivery_success(&sender); - - use crate::base::events::{ReputationTierChanged, ReputationUpdated}; - let expected_update = ReputationUpdated { - sender: sender.clone(), - category: NotificationCategory::Notification, - priority: NotificationPriority::Medium, - new_score: 75, - successful_count: 1, - failed_count: 0, - }; - let expected_tier = ReputationTierChanged { - sender: sender.clone(), - category: NotificationCategory::Notification, - priority: NotificationPriority::High, - old_tier: ReputationTier::Bronze as u32, - new_tier: ReputationTier::Silver as u32, - reputation_score: 75, - }; - assert_eq!( - actual_events(env), - std::vec![ - expected_event(env, cid, &expected_update), - expected_event(env, cid, &expected_tier), - ] - ); - - let rep = client.get_sender_reputation(&sender); - assert_eq!(rep.reputation_score, 75); - assert_eq!(client.get_sender_reputation_tier(&sender), ReputationTier::Silver as u32); -} - -// ============================================================================ -// 2. Ordering — multi-event transactions -// ============================================================================ - -/// `batch_schedule_notifications` must emit one `NotificationScheduled` per -/// notification id, strictly in input order, followed by exactly one -/// `BatchNotificationsCreated` summary event — never interleaved or reordered. -#[test] -fn batch_schedule_emits_events_in_order() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - let creator = test_env.users.get(0).unwrap(); - - let mut ids = Vec::new(env); - let mut ttls = Vec::new(env); - let mut titles = Vec::new(env); - for tag in [20u8, 21, 22] { - ids.push_back(nid(env, tag)); - ttls.push_back(3_600u64); - titles.push_back(title(env)); - } - - client.batch_schedule_notifications(&ids, &creator, &ttls, &titles); - - let mut expected: std::vec::Vec<_> = std::vec::Vec::new(); - for (i, id) in ids.iter().enumerate() { - let audit = AuditRecordAppended { - notification_id: id.clone(), - action: AuditAction::Created, - category: NotificationCategory::Notification, - seq: (i + 1) as u64, - actor: creator.clone(), - }; - expected.push(expected_event(env, cid, &audit)); - - let scheduled = NotificationScheduled { - creator: creator.clone(), - category: NotificationCategory::Notification, - priority: NotificationPriority::Medium, - notification_id: id.clone(), - }; - expected.push(expected_event(env, cid, &scheduled)); - } - let summary = BatchNotificationsCreated { - creator: creator.clone(), - category: NotificationCategory::Notification, - priority: NotificationPriority::Medium, - count: 3, - ids: ids.clone(), - }; - expected.push(expected_event(env, cid, &summary)); - - assert_eq!(actual_events(env), expected); -} - -/// `schedule_notification` appends an audit record *before* announcing the -/// notification — both events come from the same invocation, so their -/// relative order is exactly what off-chain consumers will see. -#[test] -fn schedule_notification_emits_audit_then_scheduled_in_order() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - let creator = test_env.users.get(0).unwrap(); - let id = nid(env, 23); - - client.schedule_notification(&id, &creator, &3_600u64, &title(env)); - - let scheduled_audit = AuditRecordAppended { - notification_id: id.clone(), - action: AuditAction::Created, - category: NotificationCategory::Notification, - seq: 1, - actor: creator.clone(), - }; - let scheduled = NotificationScheduled { - creator: creator.clone(), - category: NotificationCategory::Notification, - priority: NotificationPriority::Medium, - notification_id: id.clone(), - }; - - let expected = std::vec![ - expected_event(env, cid, &scheduled_audit), - expected_event(env, cid, &scheduled), - ]; - assert_eq!(actual_events(env), expected); -} - -// ============================================================================ -// 3. Negative cases — failed transactions must not emit (partial) events -// ============================================================================ - -#[test] -fn create_with_zero_usage_emits_no_event() { - let test_env = setup_test_env(); - let env = &test_env.env; - let client = AutoShareContractClient::new(env, &test_env.autoshare_contract); - let creator = test_env.users.get(0).unwrap(); - let token = test_env.mock_tokens.get(0).unwrap(); - - let result = client.try_create(&nid(env, 30), &title(env), &creator, &0u32, &token); - assert!(result.is_err()); - assert!(env.events().all().is_empty()); -} - -// Note: the test `Env`'s event log (`env.events().all()`) is scoped to the -// *most recent* top-level contract invocation, mirroring real ledger -// semantics where a failed/reverted call's events never commit and a fresh -// call starts from a clean slate. So "no event on failure" is asserted as -// `is_empty()` right after the failing call, not as "unchanged from before". - -#[test] -fn update_members_bad_percentages_emits_no_event() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - let creator = test_env.users.get(0).unwrap(); - let token = test_env.mock_tokens.get(0).unwrap(); - - let id = create_test_group(env, cid, &creator, &Vec::new(env), 1, &token); - - let mut members = Vec::new(env); - members.push_back(GroupMember { - address: Address::generate(env), - percentage: 40, // doesn't sum to 100 - }); - let result = client.try_update_members(&id, &creator, &members); - assert!(result.is_err()); - assert!(env.events().all().is_empty()); -} - -#[test] -fn pause_while_already_paused_emits_no_event() { - let test_env = setup_test_env(); - let env = &test_env.env; - let client = AutoShareContractClient::new(env, &test_env.autoshare_contract); - - client.pause(&test_env.admin); - - let result = client.try_pause(&test_env.admin); - assert!(result.is_err()); - assert!(env.events().all().is_empty()); -} - -#[test] -fn schedule_notification_zero_ttl_emits_no_event() { - let test_env = setup_test_env(); - let env = &test_env.env; - let client = AutoShareContractClient::new(env, &test_env.autoshare_contract); - let creator = test_env.users.get(0).unwrap(); - - let result = client.try_schedule_notification(&nid(env, 31), &creator, &0u64, &title(env)); - assert!(result.is_err()); - assert!(env.events().all().is_empty()); -} - -#[test] -fn register_category_twice_emits_no_event_on_second_call() { - let test_env = setup_test_env(); - let env = &test_env.env; - let client = AutoShareContractClient::new(env, &test_env.autoshare_contract); - - client.register_category(&test_env.admin, &NotificationCategory::Group); - - let result = client.try_register_category(&test_env.admin, &NotificationCategory::Group); - assert!(result.is_err()); - assert!(env.events().all().is_empty()); -} - -/// `transfer_admin` calls `publish_authorization_failure` *before* returning -/// `Error::Unauthorized` — but every contract entry point in `lib.rs` calls -/// `.unwrap()` on that `Result`, turning the `Err` into a panic. A panicking -/// invocation reverts the whole transaction, including any events it -/// published before the panic point. So `AuthorizationFailure` (like any -/// event emitted on a path that ends in an error) is never actually -/// observable through the public contract interface as currently wired — -/// this test locks in that behavior so a future refactor that changes it -/// (e.g. switching to non-panicking entry points) gets caught. -#[test] -fn unauthorized_transfer_admin_reverts_with_no_observable_event() { - let test_env = setup_test_env(); - let env = &test_env.env; - let client = AutoShareContractClient::new(env, &test_env.autoshare_contract); - let impostor = Address::generate(env); - let new_admin = Address::generate(env); - - let result = client.try_transfer_admin(&impostor, &new_admin); - assert!(result.is_err()); - assert!(env.events().all().is_empty()); - - // The admin must be unchanged — confirming AdminTransferred never fired. - assert_eq!(client.get_admin(), test_env.admin); -} - -#[test] -fn withdraw_exceeding_balance_emits_no_event() { - let test_env = setup_test_env(); - let env = &test_env.env; - let cid = &test_env.autoshare_contract; - let client = AutoShareContractClient::new(env, cid); - let creator = test_env.users.get(0).unwrap(); - let token = test_env.mock_tokens.get(0).unwrap(); - let recipient = Address::generate(env); - - create_test_group(env, cid, &creator, &Vec::new(env), 1, &token); // 10 tokens in contract - - let result = client.try_withdraw(&test_env.admin, &token, &1_000_000i128, &recipient); - assert!(result.is_err()); - assert!(env.events().all().is_empty()); -} diff --git a/contract/contracts/hello-world/src/tests/notification_validation_test.rs b/contract/contracts/hello-world/src/tests/notification_validation_test.rs index 2cce092..1f5c185 100644 --- a/contract/contracts/hello-world/src/tests/notification_validation_test.rs +++ b/contract/contracts/hello-world/src/tests/notification_validation_test.rs @@ -19,16 +19,10 @@ use soroban_sdk::{Address, BytesN, String, TryFromVal, Vec}; // ─── helpers ──────────────────────────────────────────────────────────────── -/// Categories are the **second-to-last** topic (priority is the trailing -/// topic), so this reads from the back accordingly. fn last_category(env: &soroban_sdk::Env) -> Option { let (_addr, topics, _data) = env.events().all().last()?; - let n = topics.len(); - if n < 2 { - return None; - } - let category_topic = topics.get(n - 2)?; - NotificationCategory::try_from_val(env, &category_topic).ok() + let last = topics.last()?; + NotificationCategory::try_from_val(env, &last).ok() } // ── create: invalid payload — zero usage count ─────────────────────────────── diff --git a/listener/API.md b/listener/API.md index 8e3157e..6f67a1f 100644 --- a/listener/API.md +++ b/listener/API.md @@ -758,8 +758,8 @@ Retry-After: 12 X-Request-Id: 8b4c3d2e-... { - "error": "TooManyRequests", - "message": "Rate limit exceeded. Please try again later." + "error": "Too Many Requests", + "message": "Rate limit exceeded. Try again in 12 seconds." } ``` diff --git a/listener/RATE-LIMITING-GUIDE.md b/listener/RATE-LIMITING-GUIDE.md index c482cc8..e16e3db 100644 --- a/listener/RATE-LIMITING-GUIDE.md +++ b/listener/RATE-LIMITING-GUIDE.md @@ -115,8 +115,8 @@ Retry-After: 45 Content-Type: application/json { - "error": "TooManyRequests", - "message": "Rate limit exceeded. Please try again later." + "error": "Too Many Requests", + "message": "Rate limit exceeded. Try again in 45 seconds." } ``` diff --git a/listener/src/api/rate-limiter.test.ts b/listener/src/api/rate-limiter.test.ts index 97acbd0..9b3e6bd 100644 --- a/listener/src/api/rate-limiter.test.ts +++ b/listener/src/api/rate-limiter.test.ts @@ -189,8 +189,8 @@ describe('RateLimiter', () => { expect(res3._getHeaders().get('retry-after')).toBeDefined(); const body = JSON.parse(res3._getBody()); - expect(body.error).toBe('TooManyRequests'); - expect(body.message).toBe('Rate limit exceeded. Please try again later.'); + expect(body.error).toBe('Too Many Requests'); + expect(body.message).toContain('Rate limit exceeded'); limiter.destroy(); }); diff --git a/listener/src/api/rate-limiter.ts b/listener/src/api/rate-limiter.ts index 7ea9698..28fe9dd 100644 --- a/listener/src/api/rate-limiter.ts +++ b/listener/src/api/rate-limiter.ts @@ -155,8 +155,8 @@ export class RateLimiter { res.writeHead(429, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ - error: 'TooManyRequests', - message: 'Rate limit exceeded. Please try again later.', + error: 'Too Many Requests', + message: `Rate limit exceeded. Try again in ${waitSec} seconds.`, }) ); return false; diff --git a/listener/src/index.ts b/listener/src/index.ts index ab66698..c9b6c14 100644 --- a/listener/src/index.ts +++ b/listener/src/index.ts @@ -153,8 +153,6 @@ async function main() { if (reconciliationEngine) { reconciliationEngine.stop(); - } - if (metricsRunner) { await metricsRunner.stop(); } diff --git a/listener/src/services/batch-validation-service.ts b/listener/src/services/batch-validation-service.ts index 1526780..ea1fa0f 100644 --- a/listener/src/services/batch-validation-service.ts +++ b/listener/src/services/batch-validation-service.ts @@ -187,7 +187,7 @@ export class BatchValidationService { } else { invalidEntries.push({ index, - error: validation.error ?? 'Invalid entry', + error: validation.error, }); } }); diff --git a/listener/src/store/event-registry.ts b/listener/src/store/event-registry.ts index 24d5c3f..6d37485 100644 --- a/listener/src/store/event-registry.ts +++ b/listener/src/store/event-registry.ts @@ -29,6 +29,10 @@ export class EventRegistry { this.cleanupTimer = setInterval(() => this.pruneExpired(), intervalMs); } + setTtlMs(ms: number): void { + this.ttlMs = ms; + } + stopCleanup(): void { if (this.cleanupTimer) { clearInterval(this.cleanupTimer);