diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 84d9f4f..2812437 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -38,8 +38,18 @@ 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 }} @@ -49,6 +59,7 @@ 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 }} @@ -64,15 +75,16 @@ 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** | ${process.env.DEPLOYMENT_URL} |`, + `| **URL** | ${hasCreds ? process.env.DEPLOYMENT_URL : '_unavailable for this PR_'} |`, `| **Commit** | \`${short}\` |`, - `| **Status** | ✅ Ready |`, + `| **Status** | ${hasCreds ? '✅ Ready' : '⚠️ Skipped — Cloudflare credentials are not available to PRs from forks'} |`, '', '_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 09f9fbe..d910c54 100644 --- a/contract/contracts/hello-world/src/autoshare_logic.rs +++ b/contract/contracts/hello-world/src/autoshare_logic.rs @@ -1,16 +1,11 @@ use crate::base::errors::Error; use crate::base::events::{ AdminTransferred, AuditAction, AuditRecordAppended, AuthorizationFailure, AutoshareCreated, - AutoshareUpdated, BatchNotificationsCreated, CategoryRegistered, ContractPaused, - ContractUnpaused, GroupActivated, GroupDeactivated, NotificationCategory, NotificationExpired, + AutoshareUpdated, BatchNotificationsCreated, BatchProcessingCompleted, CategoryRegistered, + ContractPaused, ContractUnpaused, GroupActivated, GroupDeactivated, NotificationAccessed, + NotificationCategory, NotificationExpired, NotificationExtended, NotificationLimitsConfigured, NotificationPriority, NotificationRevoked, NotificationScheduled, - NotificationExtended, NotificationPriority, NotificationRevoked, NotificationScheduled, - ScheduledNotificationCancelled, Withdrawal, - NotificationPriority, NotificationRevoked, NotificationScheduled, ScheduledNotificationCancelled, - Withdrawal, BatchProcessingCompleted, - NotificationExtended, NotificationLimitsConfigured, NotificationPriority, NotificationRevoked, - NotificationScheduled, ScheduledNotificationCancelled, Withdrawal, - SchemaVersionSet, NotificationAccessed, + ScheduledNotificationCancelled, SchemaVersionSet, Withdrawal, }; use crate::base::types::{ AuditRecord, AutoShareDetails, GroupMember, NotificationLimits, PaymentHistory, @@ -59,6 +54,8 @@ 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 2df8e08..b7579af 100644 --- a/contract/contracts/hello-world/src/base/events.rs +++ b/contract/contracts/hello-world/src/base/events.rs @@ -319,6 +319,13 @@ 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)] @@ -336,11 +343,20 @@ pub struct NotificationExtended { /// Emitted when a sender's reputation score is updated. /// Triggered by successful or failed notification delivery. -#[contractevent(data_format = "single-value")] +#[contractevent] #[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)] @@ -351,13 +367,14 @@ pub struct NotificationLimitsConfigured { pub category: NotificationCategory, #[topic] pub priority: NotificationPriority, - pub new_score: i64, - pub successful_count: u32, - pub failed_count: u32, + pub max_payload_size: u32, + pub max_expiration_seconds: u64, + pub min_expiration_seconds: u64, + pub max_batch_size: u32, } /// Emitted when a sender's reputation tier changes (e.g., from Bronze to Silver). -#[contractevent(data_format = "single-value")] +#[contractevent] #[derive(Clone)] pub struct ReputationTierChanged { #[topic] @@ -371,13 +388,6 @@ 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 4b28d33..1656642 100644 --- a/contract/contracts/hello-world/src/base/reputation.rs +++ b/contract/contracts/hello-world/src/base/reputation.rs @@ -126,6 +126,7 @@ impl SenderReputation { #[cfg(test)] mod tests { use super::*; + use soroban_sdk::testutils::Address as _; #[test] fn test_reputation_tier_classification() { @@ -156,7 +157,8 @@ mod tests { #[test] fn test_sender_reputation_tracking() { - let sender = Address::random(&Default::default()); + let env = Env::default(); + let sender = Address::generate(&env); 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 a9f86e3..bcc51ef 100644 --- a/contract/contracts/hello-world/src/lib.rs +++ b/contract/contracts/hello-world/src/lib.rs @@ -392,6 +392,8 @@ 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 // ============================================================================ @@ -520,7 +522,7 @@ impl AutoShareContract { /// Record a failed notification delivery for a sender. /// Decreases the sender's reputation score based on delivery history. - pub fn record_delivery_failure(env: Env, sender: Address) { + pub fn record_reputation_failure(env: Env, sender: Address) { reputation_logic::record_failed_delivery(&env, &sender).unwrap(); } @@ -638,4 +640,7 @@ 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 08142cd..2172b52 100644 --- a/contract/contracts/hello-world/src/reputation_logic.rs +++ b/contract/contracts/hello-world/src/reputation_logic.rs @@ -1,13 +1,11 @@ +use crate::autoshare_logic::DataKey; use crate::base::events::{NotificationCategory, NotificationPriority, ReputationUpdated, ReputationTierChanged}; -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_"; +use crate::base::reputation::SenderReputation; +use soroban_sdk::{Address, Env, Error}; /// Get the storage key for a sender's reputation. -fn reputation_key(sender: &Address) -> SorobanString { - let key_str = format!("{}{}", REPUTATION_KEY_PREFIX, sender); - SorobanString::from_small_str(&key_str) +fn reputation_key(sender: &Address) -> DataKey { + DataKey::Reputation(sender.clone()) } /// Initialize or get a sender's reputation record. @@ -41,31 +39,27 @@ pub fn record_successful_delivery( env.storage().persistent().set(&key, &reputation); // Emit reputation update event - 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, - }, - ); + 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); // Emit tier change event if tier changed if old_tier != new_tier { - 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, - }, - ); + 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); } Ok(()) @@ -88,31 +82,27 @@ pub fn record_failed_delivery( env.storage().persistent().set(&key, &reputation); // Emit reputation update event - 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, - }, - ); + 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); // Emit tier change event if tier changed if old_tier != new_tier { - 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, - }, - ); + 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); } 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 new file mode 100644 index 0000000..2146c94 --- /dev/null +++ b/contract/contracts/hello-world/src/tests/event_emission_test.rs @@ -0,0 +1,742 @@ +//! 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 1f5c185..2cce092 100644 --- a/contract/contracts/hello-world/src/tests/notification_validation_test.rs +++ b/contract/contracts/hello-world/src/tests/notification_validation_test.rs @@ -19,10 +19,16 @@ 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 last = topics.last()?; - NotificationCategory::try_from_val(env, &last).ok() + let n = topics.len(); + if n < 2 { + return None; + } + let category_topic = topics.get(n - 2)?; + NotificationCategory::try_from_val(env, &category_topic).ok() } // ── create: invalid payload — zero usage count ─────────────────────────────── diff --git a/listener/API.md b/listener/API.md index 6f67a1f..8e3157e 100644 --- a/listener/API.md +++ b/listener/API.md @@ -758,8 +758,8 @@ Retry-After: 12 X-Request-Id: 8b4c3d2e-... { - "error": "Too Many Requests", - "message": "Rate limit exceeded. Try again in 12 seconds." + "error": "TooManyRequests", + "message": "Rate limit exceeded. Please try again later." } ``` diff --git a/listener/RATE-LIMITING-GUIDE.md b/listener/RATE-LIMITING-GUIDE.md index e16e3db..c482cc8 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": "Too Many Requests", - "message": "Rate limit exceeded. Try again in 45 seconds." + "error": "TooManyRequests", + "message": "Rate limit exceeded. Please try again later." } ``` diff --git a/listener/src/api/rate-limiter.test.ts b/listener/src/api/rate-limiter.test.ts index 9b3e6bd..97acbd0 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('Too Many Requests'); - expect(body.message).toContain('Rate limit exceeded'); + expect(body.error).toBe('TooManyRequests'); + expect(body.message).toBe('Rate limit exceeded. Please try again later.'); limiter.destroy(); }); diff --git a/listener/src/api/rate-limiter.ts b/listener/src/api/rate-limiter.ts index 28fe9dd..7ea9698 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: 'Too Many Requests', - message: `Rate limit exceeded. Try again in ${waitSec} seconds.`, + error: 'TooManyRequests', + message: 'Rate limit exceeded. Please try again later.', }) ); return false; diff --git a/listener/src/index.ts b/listener/src/index.ts index c9b6c14..ab66698 100644 --- a/listener/src/index.ts +++ b/listener/src/index.ts @@ -153,6 +153,8 @@ 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 ea1fa0f..1526780 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, + error: validation.error ?? 'Invalid entry', }); } }); diff --git a/listener/src/store/event-registry.ts b/listener/src/store/event-registry.ts index 6d37485..24d5c3f 100644 --- a/listener/src/store/event-registry.ts +++ b/listener/src/store/event-registry.ts @@ -29,10 +29,6 @@ export class EventRegistry { this.cleanupTimer = setInterval(() => this.pruneExpired(), intervalMs); } - setTtlMs(ms: number): void { - this.ttlMs = ms; - } - stopCleanup(): void { if (this.cleanupTimer) { clearInterval(this.cleanupTimer);